Skip to content
24 changes: 24 additions & 0 deletions GenHub/GenHub.Core/Interfaces/Common/IStorageWritabilityProbe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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>
/// <remarks>
/// A successful check creates the storage directory when it does not already exist and leaves
/// that directory in place. Callers should account for this filesystem side effect.
/// </remarks>
/// <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 @@ -3,6 +3,7 @@
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Models.Common;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Storage;
using Microsoft.Extensions.Logging;
using Moq;

Expand Down Expand Up @@ -84,6 +85,30 @@ public void Constructor_WithNullLogger_ThrowsArgumentNullException()
null!));
}

/// <summary>
/// Preserves every CAS option when applying the default primary pool path.
/// </summary>
[Fact]
public void GetCasConfiguration_WhenPrimaryPathIsEmpty_PreservesGcLockTimeout()
{
var expectedTimeout = TimeSpan.FromSeconds(91);
var settings = new UserSettings
{
CasConfiguration = new CasConfiguration
{
CasRootPath = string.Empty,
GcLockTimeout = expectedTimeout,
},
};
_mockUserSettings.Setup(service => service.Get()).Returns(settings);
var provider = CreateProvider();

var result = provider.GetCasConfiguration();

Assert.Equal(expectedTimeout, result.GcLockTimeout);
Assert.False(string.IsNullOrWhiteSpace(result.CasRootPath));
}

/// <summary>
/// Verifies that GetWorkspacePath returns user setting when it's valid and directory exists.
/// </summary>
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,75 @@ 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>
/// Keeps a dotted installation directory intact when resolving adjacent CAS storage.
/// </summary>
[Fact]
public void GetCasPoolPath_WhenInstallationDirectoryContainsDot_UsesFullDirectory()
{
var settings = new UserSettings { UseInstallationAdjacentStorage = true };
_userSettingsService.Setup(service => service.Get()).Returns(settings);
var installationPath = Path.Combine(_tempPath, "ZeroHour v1.04");
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(true);
var service = CreateService(probe.Object);

var result = service.GetCasPoolPath(installation);

Assert.Equal(adjacentPath, result);
}

/// <summary>
Expand Down Expand Up @@ -188,9 +256,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 @@ -4,6 +4,7 @@
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Models.Common;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Storage;
using Microsoft.Extensions.Logging;
using Moq;

Expand Down Expand Up @@ -131,6 +132,37 @@ public async Task LoadSettings_AfterSave_LoadsCorrectData()
Assert.Equal(NavigationTab.Downloads, loadedSettings.LastSelectedTab);
}

/// <summary>
/// Verifies that the historical installation-pool provenance marker survives settings persistence.
/// </summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Fact]
public async Task LoadSettings_AfterSave_PreservesInstallationPoolProvenanceMarker()
{
var settingsPath = Path.Combine(_tempDirectory, "provenance", FileTypes.SettingsFileName);
Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!);
var service1 = new TestableUserSettingsService(
_mockLogger.Object,
CreateAppConfigMock(),
settingsPath);
service1.Update(settings =>
{
settings.CasConfiguration.InstallationPoolRootPath = "/historical/installation/.genhub-cas";
settings.MarkAsExplicitlySet(nameof(CasConfiguration.InstallationPoolRootPath));
});
await service1.SaveAsync();

var service2 = new TestableUserSettingsService(
_mockLogger.Object,
CreateAppConfigMock(),
settingsPath);
var loadedSettings = service2.Get();

Assert.Contains(
nameof(CasConfiguration.InstallationPoolRootPath),
loadedSettings.ExplicitlySetProperties);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// <summary>
/// Verifies that GetSettings returns default values with corrupted JSON.
/// </summary>
Expand Down Expand Up @@ -394,4 +426,4 @@ public TestableUserSettingsService(ILogger<UserSettingsService> logger, IAppConf
SetSettingsFilePath(settingsFilePath);
}
}
}
}
Loading
Loading