Skip to content
20 changes: 20 additions & 0 deletions GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace GenHub.Core.Interfaces.Common;

/// <summary>
/// Determines whether GenHub can create storage at a filesystem location.
/// </summary>
public interface IStorageWritabilityProbe
{
/// <summary>
/// Checks whether a directory can be created at, or files written into, the given path.
/// </summary>
/// <param name="storagePath">The storage path to check.</param>
/// <returns><c>true</c> when the location accepts writes; otherwise, <c>false</c>.</returns>
bool CanCreateStorageAt(string storagePath);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// <summary>
/// Discards any cached result for a storage path so the next check probes the filesystem again.
/// </summary>
/// <param name="storagePath">The storage path to re-probe, or <c>null</c> to discard every cached result.</param>
void Invalidate(string? storagePath = null);
}
6 changes: 6 additions & 0 deletions GenHub/GenHub.Core/Interfaces/Storage/ICasPoolResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ public interface ICasPoolResolver
/// <returns>The root path for the appropriate pool.</returns>
string GetPoolRootPath(ContentType contentType);

/// <summary>
/// Gets the previous installation-pool root retained for read-only lookup.
/// </summary>
/// <returns>The legacy root, or an empty string when none is configured.</returns>
string GetLegacyInstallationPoolRootPath();

/// <summary>
/// Checks if the installation pool is configured and available.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using GenHub.Core.Models.GameInstallations;

namespace GenHub.Core.Interfaces.Storage;

/// <summary>
/// Selects and persists an effective installation CAS pool from detected installations.
/// </summary>
public interface IInstallationCasPoolService
{
/// <summary>
/// Ensures installation-pool settings reflect the currently detected installations.
/// </summary>
/// <param name="installations">The detected game installations.</param>
/// <param name="cancellationToken">A token that can cancel the settings update.</param>
/// <returns><c>true</c> when content acquisition may continue; otherwise, <c>false</c>.</returns>
Task<bool> EnsurePoolPathAsync(
IReadOnlyList<GameInstallation> installations,
CancellationToken cancellationToken = default);
}
14 changes: 14 additions & 0 deletions GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ public TimeSpan GcLockTimeout
/// </summary>
public string InstallationPoolRootPath { get; set; } = string.Empty;

/// <summary>
/// Gets or sets a value indicating whether <see cref="InstallationPoolRootPath"/> was
/// selected automatically from a detected game installation.
/// </summary>
public bool IsInstallationPoolRootPathAutoDerived { get; set; }

/// <summary>
/// Gets or sets a previous installation-pool root that remains available for read-only
/// object lookup after new writes have fallen back to another pool.
/// </summary>
public string LegacyInstallationPoolRootPath { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the hash algorithm to use for content addressing.
/// </summary>
Expand Down Expand Up @@ -133,6 +145,8 @@ public object Clone()
EnableAutomaticGc = EnableAutomaticGc,
CasRootPath = CasRootPath,
InstallationPoolRootPath = InstallationPoolRootPath,
IsInstallationPoolRootPathAutoDerived = IsInstallationPoolRootPathAutoDerived,
LegacyInstallationPoolRootPath = LegacyInstallationPoolRootPath,
HashAlgorithm = HashAlgorithm,
GcGracePeriod = GcGracePeriod,
MaxCacheSizeBytes = MaxCacheSizeBytes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using GenHub.Core.Models.Common;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.Storage;
using Microsoft.Extensions.Logging;
using Moq;

Expand All @@ -21,6 +22,7 @@ public sealed class StorageLocationServiceTests : IDisposable
private readonly Mock<IConfigurationProviderService> _configurationProviderService = new();
private readonly Mock<IGameInstallationService> _gameInstallationService = new();
private readonly string _applicationDataPath;
private readonly string _primaryCasPath;
private readonly string _tempPath;

/// <summary>
Expand All @@ -30,9 +32,55 @@ public StorageLocationServiceTests()
{
_tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
_applicationDataPath = Path.Combine(_tempPath, "AppData");
_primaryCasPath = Path.Combine(_applicationDataPath, DirectoryNames.CasPool);
Directory.CreateDirectory(_applicationDataPath);

_configurationProviderService.Setup(service => service.GetApplicationDataPath()).Returns(_applicationDataPath);
_configurationProviderService
.Setup(service => service.GetCasConfiguration())
.Returns(new CasConfiguration { CasRootPath = _primaryCasPath });
}

/// <summary>
/// Reports the effective primary CAS path when installation-adjacent storage is unavailable.
/// </summary>
[Fact]
public void GetCasPoolPath_WhenAdjacentPathIsUnavailable_UsesPrimaryPool()
{
var settings = new UserSettings { UseInstallationAdjacentStorage = true };
_userSettingsService.Setup(service => service.Get()).Returns(settings);
var installationPath = Path.Combine(_tempPath, "Game");
var installation = new GameInstallation(installationPath, GameInstallationType.Retail);
var adjacentPath = Path.Combine(installationPath, DirectoryNames.GenHubCasPool);
var probe = new Mock<IStorageWritabilityProbe>();
probe.Setup(service => service.CanCreateStorageAt(adjacentPath)).Returns(false);
var service = CreateService(probe.Object);

var result = service.GetCasPoolPath(installation);

Assert.Equal(_primaryCasPath, result);
}

/// <summary>
/// Reports a writable user-configured installation CAS path.
/// </summary>
[Fact]
public void GetCasPoolPath_WhenConfiguredPathIsWritable_UsesConfiguredPool()
{
var configuredPath = Path.Combine(_tempPath, "CustomCas");
var settings = new UserSettings
{
CasConfiguration = new CasConfiguration { InstallationPoolRootPath = configuredPath },
};
_userSettingsService.Setup(service => service.Get()).Returns(settings);
var probe = new Mock<IStorageWritabilityProbe>();
probe.Setup(service => service.CanCreateStorageAt(configuredPath)).Returns(true);
var service = CreateService(probe.Object);
var installation = new GameInstallation(Path.Combine(_tempPath, "Game"), GameInstallationType.Retail);

var result = service.GetCasPoolPath(installation);

Assert.Equal(configuredPath, result);
}

/// <summary>
Expand Down Expand Up @@ -188,9 +236,10 @@ public void Dispose()
GC.SuppressFinalize(this);
}

private StorageLocationService CreateService() => new(
private StorageLocationService CreateService(IStorageWritabilityProbe? writabilityProbe = null) => new(
_userSettingsService.Object,
_configurationProviderService.Object,
_gameInstallationService.Object,
writabilityProbe ?? new StorageWritabilityProbe(new Mock<ILogger<StorageWritabilityProbe>>().Object),
new Mock<ILogger<StorageLocationService>>().Object);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.GameInstallations;
using GenHub.Core.Interfaces.Manifest;
using GenHub.Core.Interfaces.Storage;
using GenHub.Core.Models.Content;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
Expand All @@ -22,7 +23,7 @@ public class ContentOrchestratorTests
private readonly Mock<IContentValidator> _contentValidatorMock = default!;
private readonly Mock<IContentManifestPool> _manifestPoolMock = default!;
private readonly Mock<IGameInstallationService> _installationServiceMock = default!;
private readonly Mock<IUserSettingsService> _userSettingsServiceMock = default!;
private readonly Mock<IInstallationCasPoolService> _installationCasPoolServiceMock = default!;
private readonly Mock<ILogger<ContentOrchestrator>> _loggerMock = default!;

/// <summary>
Expand All @@ -34,7 +35,7 @@ public ContentOrchestratorTests()
_contentValidatorMock = new Mock<IContentValidator>();
_manifestPoolMock = new Mock<IContentManifestPool>();
_installationServiceMock = new Mock<IGameInstallationService>();
_userSettingsServiceMock = new Mock<IUserSettingsService>();
_installationCasPoolServiceMock = new Mock<IInstallationCasPoolService>();
_loggerMock = new Mock<ILogger<ContentOrchestrator>>();
}

Expand Down Expand Up @@ -71,7 +72,7 @@ public async Task SearchAsync_AggregatesResultsFromMultipleProviders_Successfull
_contentValidatorMock.Object,
_manifestPoolMock.Object,
_installationServiceMock.Object,
_userSettingsServiceMock.Object);
_installationCasPoolServiceMock.Object);

// Act
var result = await orchestrator.SearchAsync(new ContentSearchQuery());
Expand Down Expand Up @@ -132,7 +133,7 @@ public async Task AcquireContentAsync_ValidatesAndStoresContent_Successfully()
_contentValidatorMock.Object,
_manifestPoolMock.Object,
_installationServiceMock.Object,
_userSettingsServiceMock.Object);
_installationCasPoolServiceMock.Object);

// Act
var result = await orchestrator.AcquireContentAsync(searchResult);
Expand Down
Loading
Loading