File: Projects\AppHostCandidateFinder.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Immutable;
using Aspire.Cli.Git;
using Aspire.Cli.Telemetry;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Projects;
 
/// <summary>
/// Finds candidate project files before AppHost validation.
/// </summary>
internal interface IAppHostCandidateFinder
{
    /// <summary>
    /// Finds files matching AppHost detection patterns using the requested discovery scope.
    /// </summary>
    /// <param name="searchDirectory">The directory to search recursively.</param>
    /// <param name="patterns">The detection patterns to match.</param>
    /// <param name="nugetCachePath">The NuGet package cache path to exclude, if known.</param>
    /// <param name="scope">Controls which files are considered. See <see cref="AppHostDiscoveryScope"/>.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <param name="maxDepth">The maximum subdirectory depth to search, where 0 only considers files in <paramref name="searchDirectory"/>.</param>
    /// <param name="onDirectoryEnumerated">
    /// Optional callback invoked with the running total of directories examined during discovery.
    /// In the git-aware path the callback is invoked once with the count of distinct directories; in the
    /// filesystem fallback it is invoked synchronously on the walker thread as each directory is
    /// enumerated. Callers must keep the callback cheap and thread-safe.
    /// </param>
    /// <returns>A search result with matching files and one count entry for every requested pattern.</returns>
    Task<AppHostCandidateFileSearchResult> FindCandidateFilesAsync(
        DirectoryInfo searchDirectory,
        IReadOnlyList<string> patterns,
        string? nugetCachePath,
        AppHostDiscoveryScope scope,
        CancellationToken cancellationToken,
        int? maxDepth = null,
        Action<int>? onDirectoryEnumerated = null);
}
 
/// <summary>
/// Contains matched candidate files and per-pattern match counts.
/// </summary>
internal sealed record AppHostCandidateFileSearchResult(FileInfo[] Files, Dictionary<string, int> CountsByPattern);
 
/// <summary>
/// Finds AppHost candidate files using git-aware and filesystem discovery.
/// </summary>
internal sealed class AppHostCandidateFinder(
    IGitRepository gitRepository,
    IEnvironment environment,
    ProfilingTelemetry profilingTelemetry,
    ILogger<AppHostCandidateFinder> logger) : IAppHostCandidateFinder
{
    // Directory names (and path-scoped subpaths) excluded from AppHost discovery by default. These
    // are common build outputs, package caches, and tooling directories that should never contain a
    // user's AppHost. The list is intentionally conservative - it omits names like "vendor" (Go/PHP
    // first-party), "build" (some repos use as source), and "packages" (pnpm/Lerna workspace root)
    // because those are sometimes legitimate. Matching is case-insensitive on every platform; on
    // Linux that means a directory named "Bin" coincidentally collides with "bin" and gets skipped,
    // which is the right tradeoff given these are conventional names.
    private static readonly ImmutableArray<string> s_defaultExcludedDirectoryNames =
    [
        // Build outputs / artifacts
        "bin", "obj", "dist", "out", "target", "artifacts", "coverage",
 
        // VCS / IDE
        ".git", ".vs", ".idea",
 
        // Agent skill roots ship apphost.* files as code samples, not runnable AppHosts (see
        // SkillLocation). .agents and .claude are tool-owned, so the whole directory is excluded;
        // .github and .opencode hold non-skill content too, so only the skills subpath is excluded.
        ".agents", ".claude", Path.Combine(".github", "skills"), Path.Combine(".opencode", "skill"),
 
        // JavaScript / TypeScript ecosystems
        "node_modules", ".next", ".nuxt", ".cache", ".turbo", ".svelte-kit",
        ".parcel-cache", ".yarn", ".pnpm-store", "bower_components",
 
        // Python
        "__pycache__", ".venv", "venv", ".tox",
        ".pytest_cache", ".mypy_cache", ".ruff_cache",
 
        // JVM / other
        ".gradle",
    ];
 
    // Segment view of the exclusion list, computed once. A bare name like "bin" becomes a
    // one-segment sequence and a path-scoped entry like ".github/skills" becomes a multi-segment
    // sequence; both are matched as a contiguous subsequence of a candidate's directory segments.
    private static readonly ImmutableArray<ImmutableArray<string>> s_excludedDirectorySegments =
    [
        .. s_defaultExcludedDirectoryNames.Select(static name =>
            name.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)
                .ToImmutableArray()),
    ];
 
    public async Task<AppHostCandidateFileSearchResult> FindCandidateFilesAsync(
        DirectoryInfo searchDirectory,
        IReadOnlyList<string> patterns,
        string? nugetCachePath,
        AppHostDiscoveryScope scope,
        CancellationToken cancellationToken,
        int? maxDepth = null,
        Action<int>? onDirectoryEnumerated = null)
    {
        using var discoveryActivity = profilingTelemetry.StartAppHostCandidateDiscovery(searchDirectory, scope, patterns.Count, nugetCachePath is not null);
 
        // This method often starts from the Ctrl+C path in `aspire ls`. Check before doing any
        // discovery work so a cancellation observed after telemetry setup does not continue into
        // git or filesystem enumeration.
        cancellationToken.ThrowIfCancellationRequested();
 
        if (patterns.Count == 0)
        {
            discoveryActivity.SetAppHostDiscoverySource(ProfilingTelemetry.Values.AppHostDiscoverySourceNone);
            discoveryActivity.SetAppHostCandidateCount(0);
            return new([], new Dictionary<string, int>(StringComparer.Ordinal));
        }
 
        // In ambient discovery mode, prefer git as the source of candidate paths so the
        // user's .gitignore is honored. The git-included path returns null when git is
        // unavailable, the directory is not in a working tree, or the command fails - in
        // which case we fall back to the filesystem walk below.
        if (scope == AppHostDiscoveryScope.DefaultFiltered)
        {
            var includedPaths = await gitRepository.GetIncludedFilesAsync(searchDirectory, cancellationToken);
            if (includedPaths is not null)
            {
                logger.LogDebug("Using git-included file list ({Count} entries) as discovery source.", includedPaths.Count);
                using var matchActivity = profilingTelemetry.StartAppHostCandidateGitMatch(includedPaths.Count, patterns.Count);
                var result = MatchFromIncludedPaths(searchDirectory, patterns, includedPaths, nugetCachePath, maxDepth, cancellationToken);
                matchActivity.SetAppHostCandidateCount(result.Files.Length);
                discoveryActivity.SetAppHostDiscoverySource(ProfilingTelemetry.Values.AppHostDiscoverySourceGit);
                discoveryActivity.SetAppHostDiscoveryIncludedFileCount(includedPaths.Count);
                discoveryActivity.SetAppHostCandidateCount(result.Files.Length);
 
                // Surface a single aggregate directory count so progress UI (e.g. `aspire ls`) can show that
                // work happened. Git enumeration is a single shot over files, so we approximate the directory
                // walk by counting unique directories that contain git-included files.
                onDirectoryEnumerated?.Invoke(CountIncludedDirectories(includedPaths));
 
                return result;
            }
 
            logger.LogDebug("Git enumeration unavailable for {SearchDirectory}; falling back to filesystem walk.", searchDirectory.FullName);
        }
 
        // If cancellation happened while the external git command was failing or being torn down,
        // don't immediately start the slower fallback filesystem walk over a large repository.
        cancellationToken.ThrowIfCancellationRequested();
 
        // Directory exclusions only apply to the filtered scope; AllFiles opts in to everything.
        var applyDirectoryExclusions = scope != AppHostDiscoveryScope.AllFiles;
        var enumerationOptions = new EnumerationOptions
        {
            RecurseSubdirectories = true,
            IgnoreInaccessible = true
        };
 
        using var walkActivity = profilingTelemetry.StartAppHostCandidateFilesystemWalk(searchDirectory, patterns.Count, applyDirectoryExclusions, nugetCachePath is not null);
        var searchResult = FindMatchingFiles(searchDirectory, patterns, enumerationOptions, nugetCachePath, applyDirectoryExclusions, maxDepth, walkActivity, onDirectoryEnumerated, cancellationToken);
        walkActivity.SetAppHostCandidateCount(searchResult.Files.Length);
        discoveryActivity.SetAppHostDiscoverySource(ProfilingTelemetry.Values.AppHostDiscoverySourceFilesystem);
        discoveryActivity.SetAppHostCandidateCount(searchResult.Files.Length);
        return searchResult;
    }
 
    private AppHostCandidateFileSearchResult MatchFromIncludedPaths(
        DirectoryInfo searchDirectory,
        IReadOnlyList<string> patterns,
        IReadOnlySet<string> includedPaths,
        string? nugetCachePath,
        int? maxDepth,
        CancellationToken cancellationToken)
    {
        var pathComparison = GetPathComparison();
        var pathComparer = GetPathComparer();
 
        var rootFullName = searchDirectory.FullName;
 
        // Git gives us absolute paths, but the globbing library matches relative paths.
        // Example: if the search root is "/repo" and git returns
        // "/repo/src/MyApp.AppHost/MyApp.AppHost.csproj", we match
        // "src/MyApp.AppHost/MyApp.AppHost.csproj" against patterns like
        // "**/*.csproj".
        var matcher = CreateMatcher(patterns);
 
        // Keep a combined matcher for the actual candidate list, then run one matcher
        // per original pattern to preserve the debug counts ProjectLocator logs.
        // Example: a file can match both "*.csproj" and "src/**/AppHost.csproj";
        // it should appear once in Files but increment both pattern counts.
        var matchersByPattern = patterns.Select(p => (Pattern: p, Matcher: CreateMatcher(p))).ToArray();
        var countsByPattern = patterns.ToDictionary(p => p, _ => 0, StringComparer.Ordinal);
 
        var matchedFiles = new List<FileInfo>();
        var matchedSet = new HashSet<string>(pathComparer);
 
        foreach (var absolutePath in includedPaths)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            // The git list may include tracked-but-deleted files; skip anything that no
            // longer exists on disk so callers don't end up validating phantom files.
            if (!File.Exists(absolutePath))
            {
                continue;
            }
 
            // Apply the NuGet cache exclusion uniformly across both code paths.
            if (nugetCachePath is not null && IsUnderPath(absolutePath, nugetCachePath, pathComparison))
            {
                continue;
            }
 
            // Belt-and-suspenders: even when git is the source of truth, prune anything
            // under a hardcoded skip-listed directory so a repo that forgot to gitignore
            // node_modules/ still gets cleaned up.
            if (HasSkipListedDirectorySegment(rootFullName, absolutePath))
            {
                continue;
            }
 
            var relativeNative = Path.GetRelativePath(rootFullName, absolutePath);
            if (!IsWithinDepth(relativeNative, maxDepth))
            {
                continue;
            }
 
            // Microsoft.Extensions.FileSystemGlobbing matchers operate on forward-slash
            // paths even on Windows. Convert "src\\AppHost\\AppHost.csproj" to
            // "src/AppHost/AppHost.csproj" before matching.
            var relativeForwardSlash = Path.DirectorySeparatorChar == '/'
                ? relativeNative
                : relativeNative.Replace(Path.DirectorySeparatorChar, '/');
 
            if (!matcher.Match(relativeForwardSlash).HasMatches)
            {
                continue;
            }
 
            if (matchedSet.Add(absolutePath))
            {
                matchedFiles.Add(new FileInfo(absolutePath));
            }
 
            foreach (var (pattern, perPatternMatcher) in matchersByPattern)
            {
                if (perPatternMatcher.Match(relativeForwardSlash).HasMatches)
                {
                    countsByPattern[pattern]++;
                }
            }
        }
 
        return new(matchedFiles.ToArray(), countsByPattern);
    }
 
    private static bool IsUnderPath(string candidatePath, string ancestorPath, StringComparison pathComparison)
    {
        var candidate = Path.GetFullPath(candidatePath);
        var ancestor = Path.GetFullPath(ancestorPath);
        return candidate.Equals(ancestor, pathComparison)
            || candidate.StartsWith(ancestor + Path.DirectorySeparatorChar, pathComparison);
    }
 
    private int CountIncludedDirectories(IReadOnlySet<string> includedPaths)
    {
        var directories = new HashSet<string>(GetPathComparer());
        foreach (var includedPath in includedPaths)
        {
            if (Path.GetDirectoryName(includedPath) is { } directory)
            {
                directories.Add(Path.GetFullPath(directory));
            }
        }
 
        return directories.Count;
    }
 
    private static bool HasSkipListedDirectorySegment(string rootFullName, string absolutePath)
    {
        if (Path.GetDirectoryName(absolutePath) is not { Length: > 0 } absoluteDirectory)
        {
            return false;
        }
 
        // Match against the directory path relative to the search root. Only segments below the root
        // are considered, so the root's own name (and any ancestor above it) never participates -- a
        // repo checked out under a folder named like an excluded directory (e.g. C:\obj\repo or a
        // root literally named "obj") keeps its real AppHosts.
        var relative = Path.GetRelativePath(rootFullName, absoluteDirectory);
 
        // GetRelativePath returns ".." when absoluteDirectory is outside the root; nothing to match
        // in that case (shouldn't happen with well-formed git output rooted under the search root).
        if (relative == "." || relative.StartsWith("..", StringComparison.Ordinal))
        {
            return false;
        }
 
        var segments = relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
        return ContainsExcludedSegmentSequence(segments);
    }
 
    // True when any excluded sequence appears as a contiguous run of segments. Single-name entries
    // are length-1 sequences, so this also covers the plain directory-name exclusions.
    private static bool ContainsExcludedSegmentSequence(ReadOnlySpan<string> segments)
    {
        foreach (var sequence in s_excludedDirectorySegments)
        {
            if (ContainsContiguousSubsequence(segments, sequence.AsSpan()))
            {
                return true;
            }
        }
 
        return false;
    }
 
    private static bool ContainsContiguousSubsequence(ReadOnlySpan<string> segments, ReadOnlySpan<string> sequence)
    {
        if (sequence.Length == 0 || sequence.Length > segments.Length)
        {
            return false;
        }
 
        for (var start = 0; start <= segments.Length - sequence.Length; start++)
        {
            var matched = true;
            for (var offset = 0; offset < sequence.Length; offset++)
            {
                if (!segments[start + offset].Equals(sequence[offset], StringComparison.OrdinalIgnoreCase))
                {
                    matched = false;
                    break;
                }
            }
 
            if (matched)
            {
                return true;
            }
        }
 
        return false;
    }
 
    // Filesystem-walk counterpart: prunes at the directory that IS an excluded root by matching the
    // directory and its ancestors against an excluded sequence's trailing segments. The walk is
    // bounded by the sequence length, so ancestors above the matched root are never inspected.
    private static bool IsExcludedDirectory(DirectoryInfo directory)
    {
        foreach (var sequence in s_excludedDirectorySegments)
        {
            if (MatchesTrailingSegments(directory, sequence.AsSpan()))
            {
                return true;
            }
        }
 
        return false;
    }
 
    private static bool MatchesTrailingSegments(DirectoryInfo directory, ReadOnlySpan<string> sequence)
    {
        DirectoryInfo? current = directory;
        for (var i = sequence.Length - 1; i >= 0; i--)
        {
            if (current is null || !current.Name.Equals(sequence[i], StringComparison.OrdinalIgnoreCase))
            {
                return false;
            }
 
            current = current.Parent;
        }
 
        return true;
    }
 
    private AppHostCandidateFileSearchResult FindMatchingFiles(
        DirectoryInfo searchDirectory,
        IReadOnlyList<string> patterns,
        EnumerationOptions options,
        string? excludePath,
        bool applyDirectoryExclusions,
        int? maxDepth,
        ProfilingTelemetry.ActivityScope walkActivity,
        Action<int>? onDirectoryEnumerated,
        CancellationToken cancellationToken)
    {
        if (patterns.Count == 0)
        {
            return new([], new Dictionary<string, int>(StringComparer.Ordinal));
        }
 
        var pathComparison = GetPathComparison();
 
        var matcher = CreateMatcher(patterns);
 
        // The MatcherDirectoryInfo adapter lets Microsoft.Extensions.FileSystemGlobbing
        // drive a single recursive traversal while still honoring our fallback filters.
        // This is important for patterns like "*.csproj": we normalize them to
        // "**/*.csproj" and let the matcher walk once instead of enumerating once per
        // language detection pattern.
        var counters = new DiscoveryCounters();
        var directory = new MatcherDirectoryInfo(searchDirectory, options, excludePath, applyDirectoryExclusions, pathComparison, counters, onDirectoryEnumerated, depth: 0, maxDepth, cancellationToken);
        var matchedFilePaths = matcher.Execute(directory).Files.Select(match => match.Path).ToArray();
        walkActivity.SetAppHostDiscoveryWalkCounts(counters.FilesEnumerated, counters.DirectoriesEnumerated, counters.DirectoriesSkipped);
 
        var matchedFiles = matchedFilePaths
            .Select(path => new FileInfo(Path.Combine(searchDirectory.FullName, path.Replace('/', Path.DirectorySeparatorChar))))
            .ToArray();
 
        var countsByPattern = patterns.ToDictionary(pattern => pattern, _ => 0, StringComparer.Ordinal);
 
        // The combined matcher above gives the union of all matches. Re-run the already
        // matched relative paths through each original pattern so callers can report the
        // same per-pattern counts without paying for another filesystem traversal.
        // Example: if "**/*.csproj" and "**/*.fsproj" are configured, both counts are
        // derived from matchedFilePaths, not from two separate directory walks.
        var matchersByPattern = patterns.Select(pattern => (Pattern: pattern, Matcher: CreateMatcher(pattern))).ToArray();
        foreach (var matchedFilePath in matchedFilePaths)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            foreach (var (pattern, patternMatcher) in matchersByPattern)
            {
                if (patternMatcher.Match(matchedFilePath).HasMatches)
                {
                    countsByPattern[pattern]++;
                }
            }
        }
 
        return new(matchedFiles, countsByPattern);
    }
 
    private static string ToRecursiveGlobPattern(string pattern)
    {
        var normalizedPattern = pattern.Replace(Path.DirectorySeparatorChar, '/');
        if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar)
        {
            normalizedPattern = normalizedPattern.Replace(Path.AltDirectorySeparatorChar, '/');
        }
 
        // Language detection patterns are usually file names ("*.csproj"). Without the
        // recursive prefix, FileSystemGlobbing only matches them at the search root.
        // Convert "*.csproj" to "**/*.csproj" so nested AppHosts are discovered, while
        // leaving directory-aware patterns such as "src/**/AppHost.csproj" unchanged.
        return normalizedPattern.Contains('/', StringComparison.Ordinal)
            ? normalizedPattern
            : $"**/{normalizedPattern}";
    }
 
    private static bool IsWithinDepth(string relativePath, int? maxDepth)
    {
        if (maxDepth is null)
        {
            return true;
        }
 
        var relativeDirectory = Path.GetDirectoryName(relativePath);
        if (string.IsNullOrEmpty(relativeDirectory))
        {
            return true;
        }
 
        var depth = relativeDirectory.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries).Length;
        return depth <= maxDepth;
    }
 
    private static Matcher CreateMatcher(IEnumerable<string> patterns)
    {
        var matcher = new Matcher(StringComparison.OrdinalIgnoreCase);
        matcher.AddIncludePatterns(patterns.Select(ToRecursiveGlobPattern));
        return matcher;
    }
 
    private static Matcher CreateMatcher(string pattern)
    {
        var matcher = new Matcher(StringComparison.OrdinalIgnoreCase);
        matcher.AddInclude(ToRecursiveGlobPattern(pattern));
        return matcher;
    }
 
    private StringComparison GetPathComparison()
    {
        return environment.IsWindows() || environment.IsMacOS()
            ? StringComparison.OrdinalIgnoreCase
            : StringComparison.Ordinal;
    }
 
    private StringComparer GetPathComparer()
    {
        return environment.IsWindows() || environment.IsMacOS()
            ? StringComparer.OrdinalIgnoreCase
            : StringComparer.Ordinal;
    }
 
    private sealed class DiscoveryCounters
    {
        // Matcher.Execute drives DirectoryInfoBase enumeration synchronously on a
        // single thread. If this adapter is ever used from parallel enumeration,
        // these counters need Interlocked updates or equivalent synchronization.
        public int FilesEnumerated { get; set; }
 
        public int DirectoriesEnumerated { get; set; }
 
        public int DirectoriesSkipped { get; set; }
    }
 
    private sealed class MatcherDirectoryInfo(DirectoryInfo directory, EnumerationOptions options, string? excludePath, bool applyDirectoryExclusions, StringComparison pathComparison, DiscoveryCounters counters, Action<int>? onDirectoryEnumerated, int depth, int? maxDepth, CancellationToken cancellationToken) : DirectoryInfoBase
    {
        private readonly DirectoryInfo _directory = directory;
        private readonly EnumerationOptions _options = options;
        private readonly string? _excludePath = excludePath;
        private readonly bool _applyDirectoryExclusions = applyDirectoryExclusions;
        private readonly StringComparison _pathComparison = pathComparison;
        private readonly DiscoveryCounters _counters = counters;
        private readonly Action<int>? _onDirectoryEnumerated = onDirectoryEnumerated;
        private readonly int _depth = depth;
        private readonly int? _maxDepth = maxDepth;
        private readonly CancellationToken _cancellationToken = cancellationToken;
 
        public override string Name => _directory.Name;
 
        public override string FullName => _directory.FullName;
 
        public override DirectoryInfoBase ParentDirectory => _directory.Parent is { } parent
            ? new MatcherDirectoryInfo(parent, _options, _excludePath, _applyDirectoryExclusions, _pathComparison, _counters, _onDirectoryEnumerated, _depth - 1, _maxDepth, _cancellationToken)
            : null!;
 
        public override IEnumerable<FileSystemInfoBase> EnumerateFileSystemInfos()
        {
            foreach (var entry in _directory.EnumerateFileSystemInfos("*", CreateTopDirectoryOnlyOptions(_options)))
            {
                _cancellationToken.ThrowIfCancellationRequested();
 
                if (entry is DirectoryInfo childDirectory)
                {
                    _counters.DirectoriesEnumerated++;
                    // Report the running count so progress UI (e.g. `aspire ls`) can give the user a visible
                    // signal that work is happening even before the first AppHost candidate is found.
                    _onDirectoryEnumerated?.Invoke(_counters.DirectoriesEnumerated);
                    if (CanRecurse && !ShouldExcludeDirectory(childDirectory))
                    {
                        yield return new MatcherDirectoryInfo(childDirectory, _options, _excludePath, _applyDirectoryExclusions, _pathComparison, _counters, _onDirectoryEnumerated, _depth + 1, _maxDepth, _cancellationToken);
                    }
                    else
                    {
                        _counters.DirectoriesSkipped++;
                    }
                }
                else if (entry is FileInfo childFile)
                {
                    _counters.FilesEnumerated++;
                    if (CanIncludeFiles)
                    {
                        yield return new MatcherFileInfo(childFile, _options, _excludePath, _applyDirectoryExclusions, _pathComparison, _counters, _onDirectoryEnumerated, _depth, _maxDepth, _cancellationToken);
                    }
                }
            }
        }
 
        public override DirectoryInfoBase GetDirectory(string path)
        {
            return new MatcherDirectoryInfo(new DirectoryInfo(Path.Combine(_directory.FullName, path)), _options, _excludePath, _applyDirectoryExclusions, _pathComparison, _counters, _onDirectoryEnumerated, _depth + 1, _maxDepth, _cancellationToken);
        }
 
        public override FileInfoBase GetFile(string path)
        {
            return new MatcherFileInfo(new FileInfo(Path.Combine(_directory.FullName, path)), _options, _excludePath, _applyDirectoryExclusions, _pathComparison, _counters, _onDirectoryEnumerated, _depth, _maxDepth, _cancellationToken);
        }
 
        private bool ShouldExcludeDirectory(DirectoryInfo directory)
        {
            // Exclusions are disabled under the AllFiles scope (applyDirectoryExclusions is false).
            if (_applyDirectoryExclusions && IsExcludedDirectory(directory))
            {
                return true;
            }
 
            if (_excludePath is null)
            {
                return false;
            }
 
            var directoryPath = Path.GetFullPath(directory.FullName);
            return directoryPath.Equals(_excludePath, _pathComparison)
                || directoryPath.StartsWith(_excludePath + Path.DirectorySeparatorChar, _pathComparison);
        }
 
        private bool CanRecurse => _maxDepth is null || _depth < _maxDepth;
 
        private bool CanIncludeFiles => _maxDepth is null || _depth <= _maxDepth;
    }
 
    private sealed class MatcherFileInfo(FileInfo file, EnumerationOptions options, string? excludePath, bool applyDirectoryExclusions, StringComparison pathComparison, DiscoveryCounters counters, Action<int>? onDirectoryEnumerated, int depth, int? maxDepth, CancellationToken cancellationToken) : FileInfoBase
    {
        private readonly FileInfo _file = file;
        private readonly EnumerationOptions _options = options;
        private readonly string? _excludePath = excludePath;
        private readonly bool _applyDirectoryExclusions = applyDirectoryExclusions;
        private readonly StringComparison _pathComparison = pathComparison;
        private readonly DiscoveryCounters _counters = counters;
        private readonly Action<int>? _onDirectoryEnumerated = onDirectoryEnumerated;
        private readonly int _depth = depth;
        private readonly int? _maxDepth = maxDepth;
        private readonly CancellationToken _cancellationToken = cancellationToken;
 
        public override string Name => _file.Name;
 
        public override string FullName => _file.FullName;
 
        public override DirectoryInfoBase ParentDirectory => _file.Directory is { } parent
            ? new MatcherDirectoryInfo(parent, _options, _excludePath, _applyDirectoryExclusions, _pathComparison, _counters, _onDirectoryEnumerated, _depth, _maxDepth, _cancellationToken)
            : null!;
    }
 
    private static EnumerationOptions CreateTopDirectoryOnlyOptions(EnumerationOptions options)
    {
        return new EnumerationOptions
        {
            AttributesToSkip = options.AttributesToSkip,
            BufferSize = options.BufferSize,
            IgnoreInaccessible = options.IgnoreInaccessible,
            MatchCasing = options.MatchCasing,
            MatchType = options.MatchType,
            MaxRecursionDepth = options.MaxRecursionDepth,
            RecurseSubdirectories = false,
            ReturnSpecialDirectories = options.ReturnSpecialDirectories
        };
    }
}