Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);

/// <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 roots retained for read-only lookup.
/// </summary>
/// <returns>The legacy roots, or an empty list when none are configured.</returns>
IReadOnlyList<string> GetLegacyInstallationPoolRootPaths();

/// <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);
}
16 changes: 16 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,20 @@ 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 the previous installation-pool roots that remain available for read-only
/// object lookup after new writes have fallen back to another pool. Every root the pool has
/// previously used is retained, because objects written to any of them stay reachable only
/// through this list.
/// </summary>
public List<string> LegacyInstallationPoolRootPaths { get; set; } = [];

/// <summary>
/// Gets or sets the hash algorithm to use for content addressing.
/// </summary>
Expand Down Expand Up @@ -133,6 +147,8 @@ public object Clone()
EnableAutomaticGc = EnableAutomaticGc,
CasRootPath = CasRootPath,
InstallationPoolRootPath = InstallationPoolRootPath,
IsInstallationPoolRootPathAutoDerived = IsInstallationPoolRootPathAutoDerived,
LegacyInstallationPoolRootPaths = [.. LegacyInstallationPoolRootPaths],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Clone throws NullReferenceException if the list is null.

If a persisted CasConfiguration deserializes LegacyInstallationPoolRootPaths as null (e.g. hand-edited or corrupted settings), the spread [.. LegacyInstallationPoolRootPaths] throws here, and the equivalent [.. userCasConfig.LegacyInstallationPoolRootPaths] in CasModule.cs runs during startup options binding. Guard the spread.

Suggested change
LegacyInstallationPoolRootPaths = [.. LegacyInstallationPoolRootPaths],
LegacyInstallationPoolRootPaths = [.. (LegacyInstallationPoolRootPaths ?? [])],

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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 @@ -185,9 +253,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,39 @@ 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);
var historicalPoolPath = "/historical/installation/.genhub-cas";
Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!);
var service1 = new TestableUserSettingsService(
_mockLogger.Object,
CreateAppConfigMock(),
settingsPath);
service1.Update(settings =>
{
settings.CasConfiguration.InstallationPoolRootPath = historicalPoolPath;
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);
Assert.Equal(historicalPoolPath, loadedSettings.CasConfiguration.InstallationPoolRootPath);
}

/// <summary>
/// Verifies that GetSettings returns default values with corrupted JSON.
/// </summary>
Expand Down Expand Up @@ -394,4 +428,4 @@ public TestableUserSettingsService(ILogger<UserSettingsService> logger, IAppConf
SetSettingsFilePath(settingsFilePath);
}
}
}
}
Loading