diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs
index 81b6c547..55ff00df 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestDiscoveryServiceTests.cs
@@ -143,6 +143,71 @@ await File.WriteAllTextAsync(
Assert.Equal(nestedManifestId, manifest.Key);
}
+ ///
+ /// Tests that unavailable descendants do not prevent discovery in accessible sibling directories.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task DiscoverManifestsAsync_WithUnavailableDescendants_ContinuesDiscoveringAccessibleManifests()
+ {
+ // Arrange
+ const string accessibleManifestId = "1.0.genhub.mod.accessible";
+ var accessibleDirectory = Directory.CreateDirectory(
+ Path.Combine(_tempDirectory, "accessible")).FullName;
+ var inaccessibleDirectory = Directory.CreateDirectory(
+ Path.Combine(_tempDirectory, "inaccessible")).FullName;
+ var removedDirectory = Directory.CreateDirectory(
+ Path.Combine(_tempDirectory, "removed")).FullName;
+ var unlistableDirectory = Directory.CreateDirectory(
+ Path.Combine(_tempDirectory, "unlistable")).FullName;
+ var unreachableDirectory = Directory.CreateDirectory(
+ Path.Combine(unlistableDirectory, "unreachable")).FullName;
+ await File.WriteAllTextAsync(
+ Path.Combine(accessibleDirectory, "accessible.json"),
+ SerializeManifest(accessibleManifestId));
+ await File.WriteAllTextAsync(
+ Path.Combine(unreachableDirectory, "unreachable.json"),
+ SerializeManifest("1.0.genhub.mod.unreachable"));
+
+ IEnumerable EnumerateFiles(string directory, string pattern)
+ {
+ if (directory == inaccessibleDirectory)
+ {
+ throw new UnauthorizedAccessException("Injected inaccessible directory.");
+ }
+
+ if (directory == removedDirectory)
+ {
+ throw new DirectoryNotFoundException("Injected concurrently removed directory.");
+ }
+
+ return Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly);
+ }
+
+ IEnumerable EnumerateDirectories(string directory)
+ {
+ if (directory == unlistableDirectory)
+ {
+ throw new UnauthorizedAccessException("Injected unlistable directory.");
+ }
+
+ return Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly);
+ }
+
+ var discoveryService = new ManifestDiscoveryService(
+ _loggerMock.Object,
+ _cacheMock.Object,
+ EnumerateFiles,
+ EnumerateDirectories);
+
+ // Act
+ var manifests = await discoveryService.DiscoverManifestsAsync([_tempDirectory]);
+
+ // Assert
+ var manifest = Assert.Single(manifests);
+ Assert.Equal(accessibleManifestId, manifest.Key);
+ }
+
///
/// Tests that ValidateDependencies returns false when a required dependency is missing.
///
diff --git a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs
index 9691169b..e272862e 100644
--- a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs
+++ b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs
@@ -20,6 +20,29 @@ namespace GenHub.Features.Manifest;
public class ManifestDiscoveryService(ILogger logger, IManifestCache manifestCache)
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
+ private readonly Func> _enumerateFiles =
+ (directory, pattern) => Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly);
+
+ private readonly Func> _enumerateDirectories =
+ directory => Directory.EnumerateDirectories(directory);
+
+ ///
+ /// Initializes a new instance of the class with filesystem test seams.
+ ///
+ /// The logger.
+ /// The manifest cache.
+ /// The top-level file enumerator.
+ /// The top-level directory enumerator.
+ internal ManifestDiscoveryService(
+ ILogger logger,
+ IManifestCache manifestCache,
+ Func> enumerateFiles,
+ Func> enumerateDirectories)
+ : this(logger, manifestCache)
+ {
+ _enumerateFiles = enumerateFiles;
+ _enumerateDirectories = enumerateDirectories;
+ }
///
/// Gets manifests by content type.
@@ -61,7 +84,10 @@ public async Task> DiscoverManifestsAsync(
foreach (var directory in searchDirectories.Where(Directory.Exists))
{
logger.LogInformation("Scanning directory for manifests: {Directory}", directory);
- var manifestFiles = Directory.EnumerateFiles(directory, FileTypes.JsonFilePattern, SearchOption.AllDirectories);
+ var manifestFiles = EnumerateFilesSafely(
+ directory,
+ FileTypes.JsonFilePattern,
+ cancellationToken);
foreach (var manifestFile in manifestFiles)
{
try
@@ -157,6 +183,11 @@ public bool ValidateDependencies(
return true;
}
+ private static bool IsSkippableEnumerationException(Exception exception)
+ {
+ return exception is UnauthorizedAccessException or IOException;
+ }
+
private static bool IsVersionCompatible(string actualVersion, string minVersion, string maxVersion)
{
if (!string.IsNullOrEmpty(minVersion) && string.Compare(actualVersion, minVersion, StringComparison.OrdinalIgnoreCase) < 0)
@@ -184,16 +215,71 @@ private static bool IsVersionCompatible(string actualVersion, string minVersion,
return null;
}
+ private IEnumerable EnumerateFilesSafely(
+ string rootDirectory,
+ string searchPattern,
+ CancellationToken cancellationToken)
+ {
+ var pendingDirectories = new Stack();
+ pendingDirectories.Push(rootDirectory);
+
+ while (pendingDirectories.Count > 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var currentDirectory = pendingDirectories.Pop();
+
+ string[] files;
+ try
+ {
+ files = _enumerateFiles(currentDirectory, searchPattern).ToArray();
+ }
+ catch (Exception ex) when (IsSkippableEnumerationException(ex))
+ {
+ logger.LogWarning(
+ ex,
+ "Skipping files in inaccessible or unavailable manifest directory: {Directory}",
+ currentDirectory);
+ files = [];
+ }
+
+ foreach (var file in files)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return file;
+ }
+
+ string[] childDirectories;
+ try
+ {
+ childDirectories = _enumerateDirectories(currentDirectory).ToArray();
+ }
+ catch (Exception ex) when (IsSkippableEnumerationException(ex))
+ {
+ logger.LogWarning(
+ ex,
+ "Skipping inaccessible or unavailable manifest directory: {Directory}",
+ currentDirectory);
+ childDirectories = [];
+ }
+
+ for (var index = childDirectories.Length - 1; index >= 0; index--)
+ {
+ pendingDirectories.Push(childDirectories[index]);
+ }
+ }
+ }
+
private async Task DiscoverFileSystemManifestsAsync(IEnumerable searchDirectories, CancellationToken cancellationToken)
{
foreach (var directory in searchDirectories.Where(Directory.Exists))
{
logger.LogInformation("Scanning directory for manifests: {Directory}", directory);
- // Look for both .json and .manifest.json files to avoid conflicts with stored manifests
- var manifestFiles = Directory.EnumerateFiles(directory, FileTypes.ManifestFilePattern, SearchOption.AllDirectories)
- .Concat(Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories)
- .Where(f => !f.EndsWith(FileTypes.ManifestFileExtension)));
+ // The JSON pattern includes both .json and .manifest.json files.
+ var manifestFiles = EnumerateFilesSafely(
+ directory,
+ FileTypes.JsonFilePattern,
+ cancellationToken);
foreach (var manifestFile in manifestFiles)
{