diff --git a/Directory.Packages.props b/Directory.Packages.props index a19d2e2a75..ef0e163115 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -46,7 +46,7 @@ - + diff --git a/src/Elastic.Codex/Building/CodexBuildService.cs b/src/Elastic.Codex/Building/CodexBuildService.cs index 2b4faacb6f..ad361ed781 100644 --- a/src/Elastic.Codex/Building/CodexBuildService.cs +++ b/src/Elastic.Codex/Building/CodexBuildService.cs @@ -14,6 +14,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Isolated; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; @@ -69,7 +70,7 @@ public async Task BuildAll( var buildContexts = new List(); var environment = context.Configuration.Environment ?? "internal"; - using var codexLinkIndexReader = new GitLinkIndexReader(environment, FileSystemFactory.AppData, skipFetch: true); + using var codexLinkIndexReader = new GitLinkIndexReader(environment, new ApplicationDataFileSystem(), skipFetch: true); // Phase 1: Load and parse all documentation sets foreach (var checkout in cloneResult.Checkouts) @@ -187,16 +188,13 @@ public async Task BuildAll( // The docset file itself is passed explicitly so build reuses the docset clone discovery already // selected (which may prefer a non-default path such as `docs-dev/`), rather than rediscovering // one from the repository root and always landing on `docs/`. - var buildContext = new BuildContext( - context.Collector, - fileSystem, - fileSystem, - configurationContext, - ExportOptions.Default, - checkout.RepositoryDirectory.FullName, - outputPath, - git, - configurationFile: checkout.DocsetFile) + var docFs = DocumentationFileSystem.Resolve(checkout.RepositoryDirectory, new DocumentationScopeOptions + { + Output = outputPath, + Git = git, + ConfigurationFile = checkout.DocsetFile.FullName, + }); + var buildContext = new BuildContext(context.Collector, docFs, configurationContext) { UrlPathPrefix = pathPrefix, SiteRootPath = siteRootPath, @@ -414,10 +412,7 @@ internal sealed class CodexDocumentationContext(CodexContext codexContext) : ICo public IDiagnosticsCollector Collector => codexContext.Collector; /// - public ScopedFileSystem ReadFileSystem => codexContext.ReadFileSystem; - - /// - public ScopedFileSystem WriteFileSystem => codexContext.WriteFileSystem; + public DocumentationWriteFileSystem WriteFileSystem => codexContext.WriteFileSystem; /// public IDirectoryInfo OutputDirectory => codexContext.OutputDirectory; diff --git a/src/Elastic.Codex/CodexContext.cs b/src/Elastic.Codex/CodexContext.cs index 327a9743af..ef99f62a62 100644 --- a/src/Elastic.Codex/CodexContext.cs +++ b/src/Elastic.Codex/CodexContext.cs @@ -7,7 +7,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Codex; @@ -16,8 +16,8 @@ namespace Elastic.Codex; /// public class CodexContext : IDocsSyncContext { - public ScopedFileSystem ReadFileSystem { get; } - public ScopedFileSystem WriteFileSystem { get; } + public CheckoutsFileSystem ReadFileSystem { get; } + public DocumentationWriteFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } public CodexConfiguration Configuration { get; } public IFileInfo ConfigurationPath { get; } @@ -37,23 +37,23 @@ public CodexContext( CodexConfiguration configuration, IFileInfo configurationPath, IDiagnosticsCollector collector, - ScopedFileSystem readFileSystem, - ScopedFileSystem writeFileSystem, - string? checkoutDirectory, - string? outputDirectory) + CheckoutsFileSystem fileSystem, + string? checkoutDirectory = null, + string? outputDirectory = null + ) { Configuration = configuration; ConfigurationPath = configurationPath; Collector = collector; - ReadFileSystem = readFileSystem; - WriteFileSystem = writeFileSystem; + ReadFileSystem = fileSystem; + WriteFileSystem = fileSystem.Write; EnvironmentName = string.IsNullOrEmpty(configuration.Environment) ? "codex" : configuration.Environment; var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "codex", "clone"); CheckoutDirectory = checkoutDirectory is null - ? FileSystemFactory.AppData.DirectoryInfo.New(defaultCheckoutDirectory) - : ReadFileSystem.DirectoryInfo.New(checkoutDirectory); + ? fileSystem.DirectoryInfo.New(defaultCheckoutDirectory) + : fileSystem.DirectoryInfo.New(checkoutDirectory); var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "codex", "docs"); OutputDirectory = WriteFileSystem.DirectoryInfo.New(outputDirectory ?? defaultOutputDirectory); diff --git a/src/Elastic.Codex/CodexFileSystem.cs b/src/Elastic.Codex/CodexFileSystem.cs new file mode 100644 index 0000000000..4e1b16e8bf --- /dev/null +++ b/src/Elastic.Codex/CodexFileSystem.cs @@ -0,0 +1,41 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; + +namespace Elastic.Codex; + +/// +/// Scope over the working directory for codex operations. Rooted at the process working directory +/// with the config file's git root added as an extra allowed root, so the config file and any +/// paths inside its repository are always readable. +/// +/// Use this in codex commands instead of constructing a manually. +/// +/// +public class CodexFileSystem : CheckoutsFileSystem +{ + private static readonly FileSystem Physical = new(); + + /// The codex configuration file, resolved through this scoped filesystem. + public IFileInfo ConfigurationFile { get; } + + /// Full path to the codex configuration file. Its directory is used to locate the git root. + /// Optional explicit output directory. + /// Underlying filesystem — defaults to the physical filesystem when . + public CodexFileSystem(string config, string? output = null, IFileSystem? inner = null) + : this(inner ?? Physical, config, output, inner) + { } + + private CodexFileSystem(IFileSystem fs, string config, string? output, IFileSystem? inner) + : base( + root: fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + output: output is not null ? fs.DirectoryInfo.New(output) : null, + inner: inner, + extraRoots: [Paths.FindGitRoot(fs.DirectoryInfo.New(fs.Path.GetDirectoryName(config)!))?.FullName ?? fs.Path.GetDirectoryName(config)!] + ) + => ConfigurationFile = FileInfo.New(config); +} diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 1d0a285bda..d4337ac18a 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Configuration; @@ -22,46 +23,42 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public static string Version { get; } = Assembly.GetExecutingAssembly().GetCustomAttributes() .FirstOrDefault()?.InformationalVersion ?? "0.0.0"; - public ScopedFileSystem ReadFileSystem { get; } - public ScopedFileSystem WriteFileSystem { get; } - public IReadOnlySet AvailableExporters { get; } + /// The resolved documentation filesystem. All other path/scope properties are computed from this. + public DocumentationFileSystem FileSystem { get; } - public IDirectoryInfo? DocumentationCheckoutDirectory { get; } - public IDirectoryInfo DocumentationSourceDirectory { get; } - public IDirectoryInfo OutputDirectory { get; } + /// + /// Read scope. Satisfies . + /// Use directly when the richer type is needed. + /// + public IDocumentationFileSystem ReadFileSystem => FileSystem.Read; - public ConfigurationFile Configuration { get; private set; } + /// Write scope. Does not permit .git writes. + public DocumentationWriteFileSystem WriteFileSystem => FileSystem.Write; + + public IReadOnlySet AvailableExporters { get; init; } + public IDirectoryInfo DocumentationCheckoutDirectory => FileSystem.Paths.CheckoutDirectory; + public IDirectoryInfo DocumentationSourceDirectory => FileSystem.Paths.SourceDirectory; + public IDirectoryInfo OutputDirectory => FileSystem.Paths.OutputDirectory; + public IFileInfo ConfigurationPath => FileSystem.Paths.ConfigurationPath; + public GitCheckoutInformation Git => FileSystem.Paths.Git; + + public ConfigurationFile Configuration { get; private set; } public DocumentationSetFile ConfigurationYaml { get; set; } public VersionsConfiguration VersionsConfiguration { get; } public ConfigurationFileProvider ConfigurationFileProvider { get; } public DocumentationEndpoints Endpoints { get; } - public ProductsConfiguration ProductsConfiguration { get; } public LegacyUrlMappingConfiguration LegacyUrlMappings { get; } public SearchConfiguration SearchConfiguration { get; } - - public IFileInfo ConfigurationPath { get; } - - public GitCheckoutInformation Git { get; } - public IEnvironmentVariables Environment { get; } - public IDiagnosticsCollector Collector { get; } - public bool Force { get; init; } - public BuildType BuildType { get; init; } = BuildType.Isolated; - - // This property is used to determine if the site should be indexed by search engines public bool AllowIndexing { get; init; } - public GoogleTagManagerConfiguration GoogleTagManager { get; init; } - public OptimizelyConfiguration Optimizely { get; init; } - - // This property is used for the canonical URL public Uri? CanonicalBaseUrl { get; init; } public string? UrlPathPrefix @@ -73,33 +70,20 @@ public string? UrlPathPrefix /// Site root path for HTMX (e.g. codex root). When set, overrides derivation from UrlPathPrefix. public string? SiteRootPath { get; init; } + /// + /// Primary constructor. Pass a resolved from + /// . + /// public BuildContext( IDiagnosticsCollector collector, - ScopedFileSystem fileSystem, + DocumentationFileSystem fileSystem, IConfigurationContext configurationContext, IEnvironmentVariables? environment = null - ) - : this(collector, fileSystem, fileSystem, configurationContext, ExportOptions.Default, null, null, environment: environment) - { - } - - public BuildContext( - IDiagnosticsCollector collector, - ScopedFileSystem readFileSystem, - ScopedFileSystem writeFileSystem, - IConfigurationContext configurationContext, - IReadOnlySet availableExporters, - string? source = null, - string? output = null, - GitCheckoutInformation? gitCheckoutInformation = null, - IEnvironmentVariables? environment = null, - IFileInfo? configurationFile = null ) { Collector = collector; - ReadFileSystem = readFileSystem; - WriteFileSystem = writeFileSystem; - AvailableExporters = availableExporters; + FileSystem = fileSystem; + AvailableExporters = ExportOptions.Default; Environment = environment ?? SystemEnvironmentVariables.Instance; SearchConfiguration = configurationContext.SearchConfiguration; VersionsConfiguration = configurationContext.VersionsConfiguration; @@ -108,42 +92,14 @@ public BuildContext( LegacyUrlMappings = configurationContext.LegacyUrlMappings; Endpoints = configurationContext.Endpoints; - var rootFolder = !string.IsNullOrWhiteSpace(source) - ? ReadFileSystem.DirectoryInfo.New(source) - : ReadFileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName)); - - // When the caller already discovered the docset (e.g. Codex clone discovery, which may prefer - // a non-default docset such as `docs-dev/` over `docs/`), use it directly instead of letting - // Paths.FindDocsFolderFromRoot rediscover a different one from `source`. - (DocumentationSourceDirectory, ConfigurationPath) = configurationFile is not null - ? (configurationFile.Directory!, configurationFile) - : Paths.FindDocsFolderFromRoot(ReadFileSystem, rootFolder); - - DocumentationCheckoutDirectory = Paths.FindGitRoot(DocumentationSourceDirectory, ceiling: rootFolder); - - OutputDirectory = !string.IsNullOrWhiteSpace(output) - ? WriteFileSystem.DirectoryInfo.New(output) - : WriteFileSystem.DirectoryInfo.New(Path.Join(rootFolder.FullName, Path.Join(".artifacts", "docs", "html"))); - - if (ConfigurationPath.FullName != DocumentationSourceDirectory.FullName) - DocumentationSourceDirectory = ConfigurationPath.Directory!; - - Git = gitCheckoutInformation ?? GitCheckoutInformationFactory.Create(DocumentationCheckoutDirectory, ReadFileSystem); + GoogleTagManager = new GoogleTagManagerConfiguration { Enabled = false }; + Optimizely = new OptimizelyConfiguration { Enabled = false }; - // Load and resolve the docset file, or create an empty one if it doesn't exist ConfigurationYaml = ConfigurationPath.Exists - ? DocumentationSetFile.LoadAndResolve(collector, ConfigurationPath, readFileSystem) + ? DocumentationSetFile.LoadAndResolve(collector, ConfigurationPath, fileSystem.Read) : new DocumentationSetFile(); Configuration = new ConfigurationFile(ConfigurationYaml, this, VersionsConfiguration, ProductsConfiguration); - GoogleTagManager = new GoogleTagManagerConfiguration - { - Enabled = false - }; - Optimizely = new OptimizelyConfiguration - { - Enabled = false - }; } /// Re-reads docset.yml from disk and rebuilds the configuration. Used by the serve command on file changes. @@ -151,7 +107,7 @@ public void ReloadConfiguration() { var previousFeatures = Configuration.Features; ConfigurationYaml = ConfigurationPath.Exists - ? DocumentationSetFile.LoadAndResolve(Collector, ConfigurationPath, ReadFileSystem) + ? DocumentationSetFile.LoadAndResolve(Collector, ConfigurationPath, ReadFileSystem as ScopedFileSystem) : new DocumentationSetFile(); Configuration = new ConfigurationFile(ConfigurationYaml, this, VersionsConfiguration, ProductsConfiguration); Configuration.Features.DiagnosticsPanelEnabled = previousFeatures.DiagnosticsPanelEnabled; diff --git a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs index 45c7b676b5..fe7a508013 100644 --- a/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs +++ b/src/Elastic.Documentation.Configuration/ConfigurationFileProvider.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration.Converters; using Elastic.Documentation.Configuration.Serialization; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using YamlDotNet.Serialization; @@ -17,7 +18,7 @@ namespace Elastic.Documentation.Configuration; public partial class ConfigurationFileProvider { - private readonly IFileSystem _fileSystem; + private readonly IAppDataFileSystem _fileSystem; private readonly string _assemblyName; private readonly ILogger _logger; @@ -37,7 +38,7 @@ public partial class ConfigurationFileProvider public ConfigurationFileProvider( ILoggerFactory logFactory, - IFileSystem fileSystem, + IAppDataFileSystem fileSystem, bool skipPrivateRepositories = false, ConfigurationSource? configurationSource = null ) @@ -272,7 +273,7 @@ public static IServiceCollection AddConfigurationFileProvider(this IServiceColle { using var sp = services.BuildServiceProvider(); var logFactory = sp.GetRequiredService(); - var provider = new ConfigurationFileProvider(logFactory, FileSystemFactory.RealRead, skipPrivateRepositories, configurationSource); + var provider = new ConfigurationFileProvider(logFactory, new ConfigurationFileSystem(), skipPrivateRepositories, configurationSource); _ = services.AddSingleton(provider); configure(services, provider); return services; diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 5ea875eb28..9c561c809f 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration.Toc.DetectionRules; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Nullean.ScopedFileSystem; using YamlDotNet.Serialization; using static Elastic.Documentation.SymlinkValidator; @@ -126,7 +127,7 @@ public static DocumentationSetFile LoadMetadata(IFileInfo file) /// public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, IFileInfo docsetPath, ScopedFileSystem? fileSystem = null, HashSet? noSuppress = null) { - fileSystem ??= FileSystemFactory.ScopeSourceDirectory(docsetPath.FileSystem, docsetPath.Directory!.FullName); + fileSystem ??= new CheckoutsFileSystem(docsetPath.Directory!, inner: docsetPath.FileSystem); // Validate that the docset.yml is not a symlink (security: prevents path traversal attacks) EnsureNotSymlink(docsetPath); var yaml = fileSystem.File.ReadAllText(docsetPath.FullName); @@ -148,7 +149,7 @@ public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collecto /// public static DocumentationSetFile LoadAndResolve(IDiagnosticsCollector collector, string yaml, IDirectoryInfo sourceDirectory, ScopedFileSystem? fileSystem = null, HashSet? noSuppress = null) { - fileSystem ??= FileSystemFactory.ScopeSourceDirectory(sourceDirectory.FileSystem, sourceDirectory.FullName); + fileSystem ??= new CheckoutsFileSystem(sourceDirectory, inner: sourceDirectory.FileSystem); var docSet = Deserialize(yaml); var docsetPath = fileSystem.Path.Join(sourceDirectory.FullName, "docset.yml").OptionalWindowsReplace(); docSet.SuppressDiagnostics.ExceptWith(noSuppress ?? []); diff --git a/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs b/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs index d302caa764..205c6a6432 100644 --- a/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs +++ b/src/Elastic.Documentation.LinkIndex/GitLinkIndexReader.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.IO.Abstractions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links; using Nullean.ScopedFileSystem; @@ -27,13 +28,13 @@ public class GitLinkIndexReader : ILinkIndexReader, IDisposable private readonly SemaphoreSlim _cloneLock = new(1, 1); private bool _ensuredClone; - public GitLinkIndexReader(string environment, ScopedFileSystem? fileSystem = null, bool skipFetch = false) + public GitLinkIndexReader(string environment, ApplicationDataFileSystem? fileSystem = null, bool skipFetch = false) { if (string.IsNullOrWhiteSpace(environment)) throw new ArgumentException("Environment must be specified in the codex configuration (e.g., 'internal', 'security').", nameof(environment)); _environment = environment; - _fileSystem = fileSystem ?? FileSystemFactory.AppData; + _fileSystem = fileSystem ?? new ApplicationDataFileSystem(); _skipFetch = skipFetch; } diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index d4dc8d5cca..1baf0e7562 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -8,6 +8,7 @@ using System.Text.Json; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Serialization; using Microsoft.Extensions.Logging; @@ -58,10 +59,10 @@ public record FetchedCrossLinks }; } -public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ScopedFileSystem? fileSystem = null) : IDisposable +public abstract class CrossLinkFetcher(ILoggerFactory logFactory, ILinkIndexReader linkIndexProvider, ApplicationDataFileSystem? fileSystem = null) : IDisposable { protected ILogger Logger { get; } = logFactory.CreateLogger(nameof(CrossLinkFetcher)); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.AppData; + private readonly IFileSystem _fileSystem = fileSystem ?? new ApplicationDataFileSystem(); private LinkRegistry? _linkIndex; public static RepositoryLinks Deserialize(string json) => diff --git a/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs new file mode 100644 index 0000000000..8b83146305 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/DocumentationPathsResolver.cs @@ -0,0 +1,317 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation; + +/// +/// The resolved set of paths and git information for a documentation build or serve invocation. +/// Produced by following the bootstrap first principles: +/// +/// <path> = --path argument, or the current working directory when omitted. +/// SourceDirectory = docset scan from <path>: +/// <path>/docset.yml, <path>/_docset.yml, or any subfolder. +/// This is the anchor — all subsequent resolution is relative to it. +/// CheckoutDirectory = --git-dir?.Parent +/// ?? FindGitRoot(SourceDirectory, maxParents: N) +/// ?? error (required; use --git-dir when the heuristic cannot find .git). +/// GitDirectories = the real .git directory (or worktree commondir) +/// resolved from CheckoutDirectory. Needed to add the main repo's .git to the read scope +/// when the checkout is a worktree. +/// Git = anchored exclusively on +/// CheckoutDirectory — resolved once here, never re-derived downstream. +/// OutputDirectory = --output argument, +/// or <path>/.artifacts/docs/html. +/// +/// +public sealed record ResolvedDocumentationPaths +{ + /// + /// The invocation path — the --path argument, or the process current directory when omitted. + /// All other paths are derived from this starting point. + /// + public required IDirectoryInfo InvocationPath { get; init; } + + /// + /// The docset anchor: the directory that contains docset.yml or _docset.yml. + /// Found by scanning from via the known-location heuristic, + /// then a bounded recursive fallback. + /// + public required IDirectoryInfo SourceDirectory { get; init; } + + /// The resolved docset configuration file (docset.yml or _docset.yml). + public required IFileInfo ConfigurationPath { get; init; } + + /// + /// The repository checkout root — the directory whose immediate child is .git + /// (or whose immediate child is a worktree .git pointer file). + /// Always non-: the resolver emits a hard error when no .git + /// is found within maxParents of and no --git-dir + /// override was supplied. + /// + public required IDirectoryInfo CheckoutDirectory { get; init; } + + /// + /// The real .git directories that must be added to the read scope. For a regular + /// checkout this is [CheckoutDirectory/.git]. For a worktree this also includes the + /// main repo's .git (or the commondir target) so that git config and refs + /// can be read without a scope violation. + /// + public IReadOnlyList GitDirectories { get; init; } = []; + + /// + /// Git checkout information (branch, ref, remote, repository name) resolved from + /// . Never re-derived downstream; always set from this record. + /// + public required GitCheckoutInformation Git { get; init; } + + /// + /// The build output directory — the --output argument, or + /// /.artifacts/docs/html. + /// + public required IDirectoryInfo OutputDirectory { get; init; } + + /// + /// Extra scope roots (e.g. RUNNER_TEMP, extension roots from + /// IDocsBuilderExtension.ExternalScopeRoots) to include in the read scope. + /// Disjointness-filtered: roots nested inside are dropped. + /// + public IReadOnlyList ExtraRoots { get; init; } = []; +} + +/// +/// Arguments that tune . +/// +public sealed record DocumentationScopeOptions +{ + /// Explicit output directory (--output). + public string? Output { get; init; } + + /// + /// Explicit --git-dir override — the .git directory; its .Parent is the checkout. + /// Worktrees are handled automatically through the commondir path; do not point this at a + /// worktree's internal gitdir (.git/worktrees/<name>). + /// + public string? GitDir { get; init; } + + /// Pre-discovered docset configuration file. When set, the docset scan is skipped. + public string? ConfigurationFile { get; init; } + + /// + /// Git checkout information override (for tests). Replaces the GitCheckoutInformationFactory + /// call; goes through resolution rather than bypassing it. + /// + public GitCheckoutInformation? Git { get; init; } + + /// + /// Extra scope roots (e.g. RUNNER_TEMP, extension roots). Disjointness-filtered: roots + /// nested inside the resolved checkout are dropped instead of throwing. + /// + public IEnumerable? ExtraRoots { get; init; } + + /// + /// Minimum number of parent directories to walk above the docset anchor when searching for + /// .git (default: 1). This is a floor, not a cap: the resolver raises it to at least the + /// distance between the invocation root and the resolved anchor, so a docset discovered several + /// levels deep by the recursive scan (step 2) doesn't need this raised just to reach a .git + /// at the invocation root. + /// + public int MaxParents { get; init; } = 1; + + /// The underlying filesystem for reads. Defaults to the real filesystem. Pass a mock in tests. + public IFileSystem? Inner { get; init; } + + /// + /// Override the inner filesystem used for writes. When (the default), + /// is used for both read and write scopes. + /// Use this to wire a mock write scope against a real read scope (e.g. navigation tests that read + /// the real docs tree but write output to an in-memory filesystem). + /// + public IFileSystem? InnerWrite { get; init; } +} + +/// +/// Thrown when cannot find a docset or checkout. +/// +public sealed class DocumentationPathException(string message) : Exception(message); + +/// +/// Orchestrates the six ordered steps documented on . +/// Each step's bootstrap scope is created from what the previous step resolved, then discarded. +/// +public static class DocumentationPathsResolver +{ + /// + /// Resolve all paths for a documentation build/serve invocation. + /// + /// + /// No docset found, or no .git within MaxParents of the anchor and no + /// --git-dir override. + /// + public static ResolvedDocumentationPaths Resolve( + IDirectoryInfo invocation, + DocumentationScopeOptions options, + IFileSystem inner) + { + // 1-2. Anchor. Scoped to the invocation path only; skipped when the docset is already known. + var (source, configuration) = options.ConfigurationFile is { } known + ? (inner.NewFileInfo(known).Directory!, inner.NewFileInfo(known)) + : ScanForDocset(invocation, inner); + + // 2b. Widen the depth guard to at least the distance between the invocation root and the + // anchor. The recursive docset scan (step 2) can legitimately land several levels down + // (e.g. a monorepo docset at `docs//docset.yml`); without this, `--git-dir`-less + // resolution would need `MaxParents` raised globally just to reach a `.git` that sits + // at the invocation root. Anchoring the allowance to the invocation keeps the guard + // meaningful for `--path` pointing deep into an unrelated tree, while making the + // no-`--path` case (invocation == repo root) a non-issue. + var maxParents = Math.Max(options.MaxParents, AnchorDepthBelowInvocation(invocation, source)); + + // 3. Checkout, derived from the anchor — never from the invocation. + var gitScope = new GitResolveFileSystem(source, maxParents, inner: inner); + var checkout = ResolveCheckout(gitScope, source, options, maxParents, inner); + + // 4. Real git directories (the .git pointer path + resolved target for worktrees). + // inner (unscoped) is used for worktree resolution: the resolved gitdir lives outside the + // anchor's ancestry by design, so a scoped FS would block the commondir traversal. + // When --git-dir is explicit the checkout is gitDir.Parent, so gitDir itself must be + // carried forward — ResolveGitDirectories can't find it via the gitScope (out-of-tree). + var gitDirectories = options.GitDir is { } defined && inner.NewDirInfo(defined) is { Exists: true } explicitGitDir + ? [explicitGitDir.FullName] + : ResolveGitDirectories(gitScope, checkout, inner); + + // 5. Git information, through a scope widened by step 4. + // TryCreate reads config/HEAD from the resolved gitdir, which for a worktree lies outside + // the anchor's ancestry — so this is a second instance rather than the same one. + // This step uses a GitResolveFileSystem (for .git-aware scoping) because it reads FILES + // inside .git/ rather than listing directories at the scope root. + var git = options.Git ?? GitCheckoutInformationFactory.Create(checkout, + new GitResolveFileSystem(source, maxParents, gitDirectories, inner)); + + // 6. Output. Default is relative to the checkout, not the invocation. + // --path repo/docs and --path repo/ must both write to repo/.artifacts, not repo/docs/.artifacts. + var output = inner.NewDirInfo(options.Output ?? inner.Path.Join(checkout.FullName, ".artifacts", "docs", "html")); + + // 7. Disjointness-filter the extra roots. + var extraRoots = FilterExtraRoots(options.ExtraRoots, checkout); + + return new ResolvedDocumentationPaths + { + InvocationPath = invocation, + SourceDirectory = source, + ConfigurationPath = configuration, + CheckoutDirectory = checkout, + GitDirectories = gitDirectories, + Git = git, + OutputDirectory = output, + ExtraRoots = extraRoots + }; + } + + private static (IDirectoryInfo, IFileInfo) ScanForDocset(IDirectoryInfo invocation, IFileSystem inner) + { + var scan = new DocsetScanFileSystem(invocation, inner); + if (!Paths.TryFindDocsFolderFromRoot(scan, scan.DirectoryInfo.New(invocation.FullName), out var dir, out var file)) + throw new DocumentationPathException( + $"No docset.yml or _docset.yml found in '{invocation.FullName}' or any subfolder."); + return (dir, file); + } + + /// + /// Counts how many directory levels sits below . + /// Returns 0 when the anchor is at or above the invocation root, or is not one of its descendants + /// (e.g. a pre-supplied ConfigurationFile living outside the invocation tree). + /// + private static int AnchorDepthBelowInvocation(IDirectoryInfo invocation, IDirectoryInfo anchor) + { + var invocationPath = invocation.FullName.TrimEnd('/', '\\'); + var depth = 0; + var directory = anchor; + while (directory is not null) + { + if (string.Equals(directory.FullName.TrimEnd('/', '\\'), invocationPath, StringComparison.OrdinalIgnoreCase)) + return depth; + directory = directory.Parent; + depth++; + } + return 0; + } + + private static IDirectoryInfo ResolveCheckout( + IFileSystem gitScope, + IDirectoryInfo source, + DocumentationScopeOptions options, + int maxParents, + IFileSystem inner) + { + if (options.GitDir is { } configured && inner.NewDirInfo(configured) is { } explicitGitDir) + { + if (!inner.Directory.Exists(explicitGitDir.FullName)) + throw new DocumentationPathException($"--git-dir '{explicitGitDir.FullName}' does not exist."); + if (!inner.File.Exists(inner.Path.Join(explicitGitDir.FullName, "HEAD"))) + throw new DocumentationPathException( + $"--git-dir '{explicitGitDir.FullName}' does not appear to be a valid .git directory (no HEAD file found)."); + return explicitGitDir.Parent + ?? throw new DocumentationPathException($"--git-dir '{explicitGitDir.FullName}' has no parent directory."); + } + + var gitRoot = Paths.FindGitRoot(gitScope.DirectoryInfo.New(source.FullName), maxParents); + if (gitRoot is not null) + return gitRoot; + + // Graceful fallback for mock filesystems without a .git layout (pure-in-memory test scenarios + // that do not need a real checkout boundary). Real filesystems always require an explicit checkout. + var innerType = inner is ScopedFileSystem sf ? sf.InnerType : inner.GetType(); + if (innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + return source; + + throw new DocumentationPathException( + $"No .git found at '{source.FullName}' or within {maxParents} parent directory(ies). " + + "Pass --git-dir to point at the repository's .git directory explicitly."); + } + + private static IReadOnlyList ResolveGitDirectories(IFileSystem gitScope, IDirectoryInfo checkout, IFileSystem inner) + { + var gitPath = gitScope.Path.Join(checkout.FullName, ".git"); + if (gitScope.Directory.Exists(gitPath)) + return [gitPath]; + + // Worktree: .git is a pointer file. Use inner (unscoped) for TryReadGitDirPointer so that + // the commondir traversal can reach the main .git directory, which lies outside the gitScope + // root by design (the worktree gitdir is inside the main repo's .git tree). + return Paths.TryReadGitDirPointer(inner, inner.FileInfo.New(gitPath), out var resolved) && resolved is not null + ? [gitPath, resolved.FullName] + : []; + } + + private static IReadOnlyList FilterExtraRoots( + IEnumerable? extraRoots, + IDirectoryInfo checkout) + { + if (extraRoots is null) + return []; + + var fs = checkout.FileSystem; + var checkoutPath = checkout.FullName; + var result = new List(); + foreach (var root in extraRoots) + { + if (string.IsNullOrEmpty(root)) + continue; + // Drop descendants of checkout (already in scope) and ancestors (would subsume checkout). + if (!IDirectoryInfoExtensions.IsSubPath(root, checkoutPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, root, fs) + && !result.Contains(root, StringComparer.OrdinalIgnoreCase)) + { + result.Add(root); + } + } + return result; + } +} diff --git a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs deleted file mode 100644 index 9df45537b8..0000000000 --- a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs +++ /dev/null @@ -1,280 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.IO.Abstractions; -using System.IO.Abstractions.TestingHelpers; -using Elastic.Documentation.Extensions; -using Nullean.ScopedFileSystem; -using DirectoryInfoExtensions = Elastic.Documentation.Extensions.IDirectoryInfoExtensions; - -// ReSharper disable once CheckNamespace — intentionally preserving the original namespace so consumers need no using changes -#pragma warning disable IDE0130 -namespace Elastic.Documentation.Configuration; - -public static class FileSystemFactory -{ - // Read options: workspace + app data, all confirmed hidden names allowed. - // Includes .git (GitCheckoutInformation reads it) and .artifacts/.doc.state - // (incremental build reads existing output state). - private static readonly ScopedFileSystemOptions WorkingDirectoryReadOptions = new( - [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }; - - // Write options: same scope roots but no .git — nothing in the build output - // pipeline should ever write into the git repository metadata. - // Temp is allowed because deploy operations (e.g. S3 sync) stage files there. - private static readonly ScopedFileSystemOptions WorkingDirectoryWriteOptions = new( - [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, - AllowedSpecialFolders = AllowedSpecialFolder.Temp - }; - - // AppData-only options: for components that only access caches/state files. - private static readonly ScopedFileSystemOptions AppDataOptions = new([Paths.ApplicationData.FullName]) - { - // .git needed for codex-link-index clone directory inside ApplicationData - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } - }; - - /// - /// A pre-allocated for reading workspace files. - /// Scoped to the working directory root and per-user app data; allows .git - /// (read by GitCheckoutInformation), .artifacts and .doc.state - /// (read for incremental build state). - /// - public static ScopedFileSystem RealRead { get; } = new(new FileSystem(), WorkingDirectoryReadOptions); - - /// - /// A pre-allocated for writing build output. - /// Same scope as but without .git access — - /// nothing in the output pipeline should write into git repository metadata. - /// - public static ScopedFileSystem RealWrite { get; } = new(new FileSystem(), WorkingDirectoryWriteOptions); - - /// - /// A pre-allocated scoped only to the per-user - /// elastic/docs-builder application data folder. Use for components that - /// access caches or state and have no need for workspace files - /// (e.g. CrossLinkFetcher, CheckForUpdatesFilter, GitLinkIndexReader). - /// - public static ScopedFileSystem AppData { get; } = new(new FileSystem(), AppDataOptions); - - /// - /// Creates a new wrapping a fresh , - /// using the working-directory read options. Each call returns a new independent in-memory file system. - /// - public static ScopedFileSystem InMemory() => new(new MockFileSystem(), WorkingDirectoryReadOptions); - - /// - /// Like but additionally scopes the mock filesystem to 's - /// git root. Use when serving docs from a directory outside the current working tree so that the - /// in-memory output path (<source>/.artifacts/docs/html) passes scope validation. - /// - public static ScopedFileSystem InMemoryForPath(string? path) - { - if (path is null) - return InMemory(); - var root = Paths.FindGitRoot(path); - if (root == Paths.WorkingDirectoryRoot.FullName) - return InMemory(); - return new(new MockFileSystem(), new ScopedFileSystemOptions( - [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName, root]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }); - } - - /// - /// Scopes to and - /// for reading. Use when the inner FS contains files - /// that live within the current working-directory tree (e.g. a test MockFileSystem - /// seeded with workspace-relative paths). - /// - public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner) => - new(inner, WorkingDirectoryReadOptions); - - /// - /// Scopes to and - /// for reading, extended by - /// (e.g. detection-rules folders declared via - /// ). - /// - public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner, IEnumerable? extensionRoots) - { - if (extensionRoots is null) - return ScopeCurrentWorkingDirectory(inner); - - var workingRoot = inner.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); - var externalRoots = extensionRoots - .Select(r => inner.DirectoryInfo.New(r)) - // Drop descendants of the working root (already covered by the base scope). - // Also drop ancestors of the working root — they would subsume the working root, - // producing overlapping roots that ScopedFileSystem rejects with ArgumentException. - .Where(d => !DirectoryInfoExtensions.IsSubPathOf(d, workingRoot) && !DirectoryInfoExtensions.IsSubPathOf(workingRoot, d)) - .Select(d => d.FullName) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (externalRoots.Length == 0) - return ScopeCurrentWorkingDirectory(inner); - - var roots = new[] { Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName } - .Concat(externalRoots) - .ToArray(); - - return new ScopedFileSystem(inner, new ScopedFileSystemOptions(roots) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }); - } - - // Builds write options that include AllowedSpecialFolders.Temp PLUS the inner FS's own - // GetTempPath() as an explicit root — but only when the inner FS is MockFileSystem. - // - // MockFileSystem hardcodes its temp path ("C:\temp" on Windows, unix-ified to "/temp/" - // elsewhere) instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses - // the real GetTempPath() (e.g. "/tmp/" on Linux, "C:\Users\\AppData\Local\Temp" on - // Windows), so the two diverge on every OS and scope validation fails for any path created - // via mockFs.Path.GetTempPath(). - // - // Fix tracked upstream: https://github.com/TestableIO/System.IO.Abstractions/pull/1454 - // Once that ships and we update the package reference we can drop this workaround. - // - // We use ScopedFileSystem.InnerType (added in Nullean.ScopedFileSystem 0.4.0) to avoid a - // fragile string-based type check. - private static ScopedFileSystemOptions BuildWriteOptions(IFileSystem inner, params string[] roots) - { - var allRoots = roots.ToList(); - var innerType = inner is ScopedFileSystem sf ? sf.InnerType : inner.GetType(); - if (innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) - { - // Cover MockFileSystem's hardcoded temp path - var innerTemp = inner.Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (!string.IsNullOrEmpty(innerTemp) && !allRoots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) - allRoots.Add(innerTemp); - } - return new ScopedFileSystemOptions([.. allRoots]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, - AllowedSpecialFolders = AllowedSpecialFolder.Temp - }; - } - - /// - /// Scopes to and - /// for writing (.git not allowed). Use when - /// the inner FS writes into the working-directory tree. - /// - public static ScopedFileSystem ScopeCurrentWorkingDirectoryForWrite(IFileSystem inner) => - new(inner, BuildWriteOptions( - inner, Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName)); - - /// - /// Scopes to an explicit and - /// for reading. Use when the files to be read live under - /// a specific known root that is not — for example - /// test fixtures with assembler-checkout paths or service code operating on a given directory. - /// - public static ScopedFileSystem ScopeSourceDirectory(IFileSystem inner, string sourceRoot) => - new(inner, new ScopedFileSystemOptions([sourceRoot, Paths.ApplicationData.FullName]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }); - - /// - /// Scopes to an explicit and - /// for writing (.git not allowed). Write variant - /// of . - /// - public static ScopedFileSystem ScopeSourceDirectoryForWrite(IFileSystem inner, string sourceRoot) => - new(inner, BuildWriteOptions(inner, sourceRoot, Paths.ApplicationData.FullName)); - - /// - /// Creates a read scoped to the git root of - /// . Falls back to when - /// is . Use in commands that accept an explicit --path argument. - /// - /// Suitable for command-layer code. Service-layer tests use directly - /// and do not exercise this method. - /// - /// - public static ScopedFileSystem RealGitRootForPath(string? path) - { - var root = path is null ? Paths.WorkingDirectoryRoot.FullName : Paths.FindGitRoot(path); - var roots = new List { root, Paths.ApplicationData.FullName }; - - // In a git worktree the local .git entry is a file pointing to the main repo's - // .git/worktrees/ directory. GitCheckoutInformation needs to read config and - // HEAD from the main repo's .git dir, which lives outside the worktree root. - // Add it as an explicit scope root so those reads are not rejected. - var worktreePointer = Path.Join(root, ".git"); - if (File.Exists(worktreePointer)) - { - var gitdir = File.ReadAllText(worktreePointer).Replace("gitdir:", "").Trim(); - // gitdir = /main/repo/.git/worktrees/ — go up two levels to reach .git - var mainGitDir = Path.GetFullPath(Path.Join(gitdir, "..", "..")); - if (Directory.Exists(mainGitDir)) - roots.Add(mainGitDir); - } - - // Fast path: no worktree detected and path was null — reuse the pre-built instance - if (roots.Count == 2 && path is null) - return RealRead; - - return new ScopedFileSystem(new FileSystem(), new ScopedFileSystemOptions([.. roots]) - { - AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, - AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } - }); - } - - /// - /// Creates a write scoped to the git root of - /// (and if it falls outside that root). - /// Falls back to when both are . - /// Use in commands that accept explicit --path and/or --output arguments. - /// - public static ScopedFileSystem RealGitRootForPathWrite(string? path, string? output = null) - { - if (path is null && output is null) - return RealWrite; - - var gitRoot = path is not null ? Paths.FindGitRoot(path) : Paths.WorkingDirectoryRoot.FullName; - var roots = new List { gitRoot, Paths.ApplicationData.FullName }; - - var plain = new FileSystem(); - if (output is not null) - { - var absOutput = Path.IsPathRooted(output) ? output : Path.GetFullPath(output); - if (!DirectoryInfoExtensions.IsSubPathOf(plain.DirectoryInfo.New(absOutput), plain.DirectoryInfo.New(gitRoot))) - roots.Add(absOutput); - } - - return new ScopedFileSystem(plain, BuildWriteOptions(plain, [.. roots])); - } - - /// - /// Creates a read for CI environments, - /// extending the default scope with RUNNER_TEMP when available. - /// Falls back to when RUNNER_TEMP is not set. - /// Use in CI commands that need to read temporary files staged in the GitHub Actions runner. - /// - public static ScopedFileSystem RealReadForRunnerTemp(IEnvironmentVariables? environmentVariables = null) - { - var runnerTemp = environmentVariables?.GetEnvironmentVariable("RUNNER_TEMP"); - if (string.IsNullOrWhiteSpace(runnerTemp)) - return RealRead; - - return ScopeCurrentWorkingDirectory(new FileSystem(), [runnerTemp]); - } -} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs new file mode 100644 index 0000000000..c52651b934 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/ApplicationDataFileSystem.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// A scoped filesystem rooted at the per-user elastic/docs-builder application data folder. +/// Use for components that access caches or state and have no need for workspace files +/// (e.g. CrossLinkFetcher, CheckForUpdatesFilter, GitLinkIndexReader). +/// +public class ApplicationDataFileSystem(IFileSystem? inner = null) + : ScopedFileSystem( + inner ?? new FileSystem(), + new ScopedFileSystemOptions([Paths.ApplicationData.FullName]) + { + // .git needed for codex-link-index clone directory inside ApplicationData + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }), + IAppDataFileSystem +{ +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs new file mode 100644 index 0000000000..266d91f065 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/ChangelogFileSystem.cs @@ -0,0 +1,38 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Scope for changelog commands: the git root of the target repository. +/// Allows reading .git metadata (remote URL, branch); does not include +/// AppData or build artifacts — changelog operates only within the repo working tree. +/// +public class ChangelogFileSystem(IDirectoryInfo root, IFileSystem? inner = null) + : ScopedFileSystem(inner ?? Physical, new ScopedFileSystemOptions([root.FullName]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }), + IChangelogFileSystem +{ + private static readonly FileSystem Physical = new(); + + /// + /// Creates a scope anchored at the git root of the current working directory. + /// Falls back to the working directory itself when no .git root is found — + /// changelog init is designed to run before a git repository exists. + /// + public static ChangelogFileSystem FromWorkingDirectory(IFileSystem? inner = null) + { + var fs = inner ?? Physical; + var workingRoot = fs.DirectoryInfo.New(fs.Directory.GetCurrentDirectory()); + var gitRoot = Paths.FindGitRoot(workingRoot) ?? workingRoot; + return new ChangelogFileSystem(gitRoot, inner); + } +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs new file mode 100644 index 0000000000..448ee8cd9b --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/CheckoutsFileSystem.cs @@ -0,0 +1,84 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; +using static Elastic.Documentation.Extensions.IDirectoryInfoExtensions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Scope over a directory containing cloned documentation checkouts. No docset anchoring, no git +/// information — the assembler reads configuration and per-clone output, not a documentation set. +/// +/// Use this for the assembler and changelog commands which work with a tree of clones. +/// Use when you have a single documentation set with a docset anchor. +/// +/// +public class CheckoutsFileSystem : ScopedFileSystem, ICheckoutsFileSystem +{ + private static readonly FileSystem Physical = new(); + + private readonly IFileSystem _inner; + + public CheckoutsFileSystem(IDirectoryInfo root, + IDirectoryInfo? output = null, + IFileSystem? inner = null, + IEnumerable? extraRoots = null) + : base(inner ?? Physical, BuildReadOptions(root, extraRoots)) + { + _inner = inner ?? Physical; + Write = new DocumentationWriteFileSystem(root, output, _inner); + } + + /// + /// This instance as a read scope. Always prefer .Read at call sites over passing the + /// instance directly — a slot that wants a read scope should say so, symmetrically with .Write. + /// + public CheckoutsFileSystem Read => this; + + /// Write scope for this checkout tree. + public DocumentationWriteFileSystem Write { get; } + + private static ScopedFileSystemOptions BuildReadOptions(IDirectoryInfo root, IEnumerable? extraRoots) + { + var fs = root.FileSystem; + var rootPath = root.FullName; + var roots = new List { rootPath }; + + // AppData is disjointness-filtered too: on CI the checkouts directory lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/...), so AppData would subsume + // root and the ScopedFileSystem constructor would throw. + var appData = Paths.ApplicationData.FullName; + if (!IsSubPath(appData, rootPath, fs) && !IsSubPath(rootPath, appData, fs)) + roots.Add(appData); + + if (extraRoots is not null) + { + foreach (var extra in extraRoots) + { + if (string.IsNullOrEmpty(extra)) + continue; + // Drop descendants of root (already covered) and ancestors (would subsume root, causing overlap). + if (!IsSubPath(extra, rootPath, fs) && !IsSubPath(rootPath, extra, fs) && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) + roots.Add(extra); + } + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } + }; + } + + /// + /// Creates a scope over the current working directory root. Suitable for assembler and navigation + /// commands that operate on the local checkout tree without a specific docset anchor. + /// + /// The underlying filesystem. Defaults to a new when . + public static CheckoutsFileSystem FromWorkingDirectory(IFileSystem? inner = null) => + new((inner ?? Physical).DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: inner); +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs new file mode 100644 index 0000000000..01d36090b5 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/ConfigurationFileSystem.cs @@ -0,0 +1,31 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// A scoped filesystem covering the docs configuration tree (<cwd>/config) and +/// per-user application data. Used by ConfigurationFileProvider, which reads +/// config/*.yml and writes runtime artefacts under AppData/config-runtime. +/// +public class ConfigurationFileSystem(IFileSystem? inner = null) + : ScopedFileSystem( + // ScopedFileSystem cannot wrap another ScopedFileSystem. When a ScopedFileSystem is + // supplied (e.g. a DocumentationFileSystem from a test context) use the physical FS instead, + // since config files live outside a docset scope anyway. + inner is ScopedFileSystem ? new FileSystem() : (inner ?? new FileSystem()), + new ScopedFileSystemOptions([ + System.IO.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config"), + Paths.ApplicationData.FullName + ]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }), + IAppDataFileSystem +{ +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs new file mode 100644 index 0000000000..dd3bf65839 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocsetScanFileSystem.cs @@ -0,0 +1,22 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Bootstrap-only filesystem rooted at the invocation path. Used only for the docset scan step of +/// ; discarded immediately after the anchor is found. +/// +/// Rooted at (and below) the invocation path because both scan strategies read at-or-below their root: +/// the known-location heuristic checks the path itself and its docs/ subdirectory, and the +/// recursive fallback enumerates downward. Nothing about the docset scan needs a parent directory. +/// +/// +internal sealed class DocsetScanFileSystem(IDirectoryInfo path, IFileSystem? inner = null) + : ScopedFileSystem(inner ?? new FileSystem(), new ScopedFileSystemOptions([path.FullName])) +{ +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs new file mode 100644 index 0000000000..e3738dcc8f --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/DocumentationFileSystem.cs @@ -0,0 +1,119 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Extensions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// The read scope for a single documentation set. Anchored on a resolved docset.yml and its +/// checkout root. Exposes a matching scope derived from the same paths — so read and +/// write cannot disagree about the checkout. +/// +/// Construction is via only. The constructor is private; it takes +/// already-resolved paths so that can run its bootstrap +/// scopes before the final scope is built. +/// +/// +public class DocumentationFileSystem : ScopedFileSystem, IDocumentationFileSystem +{ + private static readonly FileSystem Physical = new(); + + private DocumentationFileSystem(ResolvedDocumentationPaths paths, IFileSystem inner, IFileSystem? innerWrite = null) + : base(inner, BuildReadOptions(paths)) + { + Paths = paths; + Write = new DocumentationWriteFileSystem(paths.CheckoutDirectory, paths.OutputDirectory, innerWrite ?? inner); + } + + /// Everything the anchoring resolved. Read and write scopes are derived from exactly this. + public ResolvedDocumentationPaths Paths { get; } + + /// + /// This instance as a read scope. Always prefer .Read at call sites over passing the + /// instance directly — a slot that wants a read scope should say so, symmetrically with .Write. + /// + public DocumentationFileSystem Read => this; + + /// Write scope derived from the same resolved paths. Never wraps this. + public DocumentationWriteFileSystem Write { get; } + + /// + /// Anchor on the docset under , derive the checkout from it, and scope to + /// the result. For build/serve commands where the user supplied --path (or nothing). + /// + /// + /// The directory to start the docset scan from. When , the current working + /// directory is used. + /// + /// + /// Optional tuning: output directory, explicit --git-dir, pre-discovered configuration file, + /// extra scope roots, max parents for the git walk, and the mock seam. + /// + /// + /// No docset found under , or no .git within MaxParents of the + /// anchor and no --git-dir override. + /// + public static DocumentationFileSystem Resolve(IDirectoryInfo? path = null, DocumentationScopeOptions? options = null) + { + var opts = options ?? new DocumentationScopeOptions(); + var inner = opts.Inner ?? Physical; + var invocation = path ?? inner.DirectoryInfo.New(inner.Directory.GetCurrentDirectory()); + + // Docset first (scoped to the invocation path), then git from the anchor it produced. + // The resolver constructs its own bootstrap scopes in that order and discards them. + var paths = DocumentationPathsResolver.Resolve(invocation, opts, inner); + return new DocumentationFileSystem(paths, inner, opts.InnerWrite); + } + + public static DocumentationFileSystem Resolve(string? path, DocumentationScopeOptions? options = null) + { + var opts = options ?? new DocumentationScopeOptions(); + var inner = opts.Inner ?? Physical; + var invocation = path is not null ? inner.DirectoryInfo.New(path) : null; + return Resolve(invocation, opts); + } + + private static ScopedFileSystemOptions BuildReadOptions(ResolvedDocumentationPaths paths) + { + var fs = paths.CheckoutDirectory.FileSystem; + var checkoutPath = paths.CheckoutDirectory.FullName; + var roots = new List { checkoutPath }; + + // AppData is disjointness-filtered: on CI each individual docset checkout lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/current/), so AppData would + // subsume checkoutPath and trigger ValidateRootsAreDisjoint. + var appData = Configuration.Paths.ApplicationData.FullName; + if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) + { + roots.Add(appData); + } + + foreach (var gitDir in paths.GitDirectories) + { + if (!IDirectoryInfoExtensions.IsSubPath(gitDir, checkoutPath, fs)) + roots.Add(gitDir); + } + + foreach (var extra in paths.ExtraRoots) + { + if (!string.IsNullOrEmpty(extra) + && !IDirectoryInfoExtensions.IsSubPath(extra, checkoutPath, fs) + && !roots.Contains(extra, StringComparer.OrdinalIgnoreCase)) + { + roots.Add(extra); + } + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" } + }; + } +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs new file mode 100644 index 0000000000..4769ffdee9 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/GitResolveFileSystem.cs @@ -0,0 +1,71 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Extensions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Bootstrap-only filesystem for git resolution steps in . +/// Discarded before BuildContext exists. +/// +/// Rooted at maxParents levels above the docset anchor — +/// git resolution measures from the anchor, not the invocation path, so that both +/// --path repo/ and --path repo/docs converge on the same checkout under a single bound. +/// +/// +/// The optional gitDirectories parameter widens the scope to include the resolved +/// .git directories (needed for the second pass that reads config, HEAD, and +/// refs/heads/* — for worktrees these lie outside the anchor's ancestry). +/// +/// +#pragma warning disable IDE0290 // Cannot use primary constructor — delegates to static helper +public class GitResolveFileSystem : ScopedFileSystem +{ + public GitResolveFileSystem( + IDirectoryInfo anchor, + int maxParents = 1, + IReadOnlyList? gitDirectories = null, + IFileSystem? inner = null) + : base(inner ?? new FileSystem(), BuildOptions(anchor, maxParents, gitDirectories)) + { + } +#pragma warning restore IDE0290 + + private static ScopedFileSystemOptions BuildOptions( + IDirectoryInfo anchor, + int maxParents, + IReadOnlyList? gitDirectories) + { + // Walk maxParents above the anchor to get the scope root. + var root = anchor; + for (var i = 0; i < maxParents; i++) + root = root.Parent ?? root; + + // ScopedFileSystem normalises scope roots via TrimEnd(separator). On Unix, "/" trimmed is "". + // An empty scope root makes every path fail the IsWithinRoot check, so guard against it: + // if the computed root IS the filesystem root, fall back to the anchor itself. + var fs = anchor.FileSystem; + var normalised = root.FullName.TrimEnd(fs.Path.DirectorySeparatorChar, fs.Path.AltDirectorySeparatorChar); + var rootPath = string.IsNullOrEmpty(normalised) ? anchor.FullName : root.FullName; + var roots = new List { rootPath }; + + if (gitDirectories is { Count: > 0 }) + { + foreach (var gitDir in gitDirectories) + { + if (!IDirectoryInfoExtensions.IsSubPath(gitDir, rootPath, fs)) + roots.Add(gitDir); + } + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }; + } +} diff --git a/src/Elastic.Documentation.Tooling/FileSystems/IAppDataFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/IAppDataFileSystem.cs new file mode 100644 index 0000000000..0395f73a7c --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/IAppDataFileSystem.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for filesystems that include the application data directory +/// () in their scope. +/// Services that read or write AppData (caches, link indices, config-runtime state) should +/// declare this interface rather than the bare . +/// +public interface IAppDataFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/IChangelogFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/IChangelogFileSystem.cs new file mode 100644 index 0000000000..d006087101 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/IChangelogFileSystem.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for the changelog scope: the git root of the target repository. +/// Changelog services declare this rather than bare so that the +/// compiler enforces that only a properly-scoped filesystem (one that can reach the +/// changelog YAML files and read .git metadata) is wired in. +/// +public interface IChangelogFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/ICheckoutsFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/ICheckoutsFileSystem.cs new file mode 100644 index 0000000000..d7c6c4369d --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/ICheckoutsFileSystem.cs @@ -0,0 +1,13 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for the checkout-tree scope: a working-directory root paired with the +/// application data directory. The checkouts filesystem is the read/write aggregate used by +/// assembler and codex commands that operate across many repository clones. +/// Extends because the checkouts scope always includes AppData. +/// +public interface ICheckoutsFileSystem : IAppDataFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/IRunnerTempFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/IRunnerTempFileSystem.cs new file mode 100644 index 0000000000..561f53905c --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/IRunnerTempFileSystem.cs @@ -0,0 +1,15 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for the CI runner scope: the working directory root plus one or more +/// runner-provided paths (RUNNER_TEMP, artifact output dirs, metadata paths). +/// Services that operate across the CI workspace without a fixed docset anchor declare this +/// rather than bare . +/// +public interface IRunnerTempFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs b/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs new file mode 100644 index 0000000000..5160871446 --- /dev/null +++ b/src/Elastic.Documentation.Tooling/FileSystems/RunnerTempFileSystem.cs @@ -0,0 +1,88 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Nullean.ScopedFileSystem; +using static Elastic.Documentation.Extensions.IDirectoryInfoExtensions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Scope for CI evaluation commands: the working-directory root plus any runner-provided paths +/// (RUNNER_TEMP, artifact output dirs, staging dirs, metadata file locations). +/// +/// Used by evaluate-pr, evaluate-artifact, and prepare-artifact — +/// all three operate on paths vended by the CI environment, not a fixed docset or checkout. +/// Permits .git so changelog configuration can be located by the config loader. +/// +/// +public class RunnerTempFileSystem( + IDirectoryInfo workingRoot, + IEnumerable? ciPaths = null, + IFileSystem? inner = null) + : ScopedFileSystem(inner ?? Physical, BuildOptions(workingRoot, ciPaths)), + IRunnerTempFileSystem +{ + private static readonly FileSystem Physical = new(); + + private static ScopedFileSystemOptions BuildOptions(IDirectoryInfo workingRoot, IEnumerable? ciPaths) + { + var fs = workingRoot.FileSystem; + var roots = new List { workingRoot.FullName }; + + if (ciPaths is not null) + { + foreach (var path in ciPaths) + { + if (string.IsNullOrEmpty(path)) + continue; + // Drop descendants and ancestors of workingRoot to avoid disjointness violations. + var isDescendant = IsSubPath(path, workingRoot.FullName, fs); + var isAncestor = IsSubPath(workingRoot.FullName, path, fs); + var isDuplicate = roots.Contains(path, StringComparer.OrdinalIgnoreCase); + if (!isDescendant && !isAncestor && !isDuplicate) + roots.Add(path); + } + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }; + } + + public static RunnerTempFileSystem ForEvaluatePr(IEnvironmentVariables env, IFileSystem? inner = null) + { + var fs = inner ?? Physical; + var workingRoot = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var runnerTemp = env.GetEnvironmentVariable("RUNNER_TEMP"); + return new RunnerTempFileSystem(workingRoot, + ciPaths: string.IsNullOrWhiteSpace(runnerTemp) ? null : [runnerTemp], + inner: inner); + } + + public static RunnerTempFileSystem ForEvaluateArtifact(string metadataPath, IFileSystem? inner = null) + { + var fs = inner ?? Physical; + var workingRoot = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var metadataDir = System.IO.Path.GetDirectoryName(metadataPath); + return new RunnerTempFileSystem(workingRoot, + ciPaths: string.IsNullOrWhiteSpace(metadataDir) ? null : [metadataDir], + inner: inner); + } + + public static RunnerTempFileSystem ForPrepareArtifact(string? stagingDir, string? outputDir, IFileSystem? inner = null) + { + var fs = inner ?? Physical; + var workingRoot = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var ciPaths = new List(); + if (!string.IsNullOrWhiteSpace(stagingDir)) + ciPaths.Add(stagingDir); + if (!string.IsNullOrWhiteSpace(outputDir)) + ciPaths.Add(outputDir); + return new RunnerTempFileSystem(workingRoot, ciPaths: ciPaths.Count > 0 ? ciPaths : null, inner: inner); + } +} diff --git a/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs b/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs index b5875c25c8..382ba71407 100644 --- a/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs +++ b/src/Elastic.Documentation.Tooling/GitCheckoutInformationFactory.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using System.Text.RegularExpressions; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Extensions; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; @@ -19,57 +20,90 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem if (source is null) return GitCheckoutInformation.Unavailable; - // Return test data for in-memory (mock) file systems. Use ScopedFileSystem.InnerType - // (available since Nullean.ScopedFileSystem 0.4.0) to inspect through the scope wrapper - // rather than relying on the outer type name. - var fsType = fileSystem is ScopedFileSystem sf ? sf.InnerType : fileSystem.GetType(); - if (fsType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + var result = TryCreate(source, fileSystem, logger); + + // Fall back to canned test data only when the inner filesystem is a mock AND no .git entry + // exists at all at the source. This preserves back-compat for tests that seed no git layout + // (they get the well-known canned instance), while tests that seed a .git file or directory + // — even one that fails to resolve — receive the real result (Unavailable). + // Use ScopedFileSystem.InnerType (available since Nullean.ScopedFileSystem 0.4.0) to inspect + // through the scope wrapper rather than relying on the outer type name. + if (result == GitCheckoutInformation.Unavailable) { - return new GitCheckoutInformation + var fsType = fileSystem is ScopedFileSystem sf ? sf.InnerType : fileSystem.GetType(); + if (fsType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase) && IsLegacyTestWithoutGitLayout(fileSystem, source)) { - Branch = $"test-e35fcb27-5f60-4e", - Remote = "elastic/docs-builder", - Ref = "e35fcb27-5f60-4e", - RepositoryName = "docs-builder" - }; + return new GitCheckoutInformation + { + Branch = "test-e35fcb27-5f60-4e", + Remote = "elastic/docs-builder", + Ref = "e35fcb27-5f60-4e", + RepositoryName = "docs-builder" + }; + } } - var fakeRef = Guid.NewGuid().ToString()[..16]; - var gitDir = GitDir(source, ".git"); - if (!gitDir.Exists) + return result; + } + + private static GitCheckoutInformation TryCreate(IDirectoryInfo source, IFileSystem fileSystem, ILogger? logger) + { + // Resolve the actual .git directory. For regular repos this is source/.git/; + // for worktrees source/.git is a file pointing to the real git dir. + IDirectoryInfo gitDir; + var gitDirPath = fileSystem.Path.Join(source.FullName, ".git"); + + if (fileSystem.Directory.Exists(gitDirPath)) { - var worktreeFile = Git(source, ".git"); - if (!worktreeFile.Exists) - return GitCheckoutInformation.Unavailable; - var workTreePath = Read(source, ".git")?.Replace("gitdir: ", string.Empty); - if (workTreePath is null) - return GitCheckoutInformation.Unavailable; - gitDir = fileSystem.DirectoryInfo.New(workTreePath).GetParent(".git"); - if (gitDir is null || !gitDir.Exists) + gitDir = fileSystem.DirectoryInfo.New(gitDirPath); + } + else + { + var gitFile = fileSystem.FileInfo.New(gitDirPath); + if (!Paths.TryReadGitDirPointer(fileSystem, gitFile, out var resolvedGitDir) + || resolvedGitDir is null) return GitCheckoutInformation.Unavailable; + + gitDir = resolvedGitDir; } - var gitConfig = Git(gitDir, "config"); - if (!gitConfig.Exists) + var gitConfigPath = fileSystem.Path.Join(gitDir.FullName, "config"); + if (!fileSystem.File.Exists(gitConfigPath)) { logger?.LogInformation("Git checkout information not available."); return GitCheckoutInformation.Unavailable; } - var head = Read(gitDir, "HEAD") ?? fakeRef; - var gitRef = head; - var branch = head.Replace("refs/heads/", string.Empty); - if (head.StartsWith("ref:", StringComparison.OrdinalIgnoreCase)) + var headPath = fileSystem.Path.Join(gitDir.FullName, "HEAD"); + var headText = fileSystem.File.Exists(headPath) + ? fileSystem.File.ReadAllText(headPath).Trim() + : null; + + if (headText is null) + return GitCheckoutInformation.Unavailable; + + string gitRef; + string branch; + if (headText.StartsWith("ref:", StringComparison.OrdinalIgnoreCase)) { - head = head.Replace("ref: ", string.Empty); - gitRef = Read(gitDir, head) ?? fakeRef; - branch = branch.Replace("ref: ", string.Empty); + var refPath = headText["ref:".Length..].Trim(); + branch = refPath.Replace("refs/heads/", string.Empty); + var refFilePath = fileSystem.Path.Join(gitDir.FullName, refPath.Replace('/', fileSystem.Path.DirectorySeparatorChar)); + gitRef = fileSystem.File.Exists(refFilePath) + ? fileSystem.File.ReadAllText(refFilePath).Trim() + : headText; // symbolic ref not yet written (new empty repo) — use the ref name itself } else - branch = Environment.GetEnvironmentVariable("GITHUB_PR_REF_NAME") ?? Environment.GetEnvironmentVariable("GITHUB_REF_NAME") ?? "detached/head"; + { + // Detached HEAD: raw SHA + gitRef = headText; + branch = Environment.GetEnvironmentVariable("GITHUB_PR_REF_NAME") + ?? Environment.GetEnvironmentVariable("GITHUB_REF_NAME") + ?? "detached/head"; + } var ini = new IniFile(); - using var stream = gitConfig.OpenRead(); + using var stream = fileSystem.File.OpenRead(gitConfigPath); using var streamReader = new StreamReader(stream); ini.Load(streamReader); @@ -113,35 +147,42 @@ public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem logger?.LogInformation("-> Remote Name: {GitRemote}", info.Remote); logger?.LogInformation("-> Repository Name: {RepositoryName}", info.RepositoryName); return info; + } - IFileInfo Git(IDirectoryInfo directoryInfo, string path) => - fileSystem.FileInfo.New(Path.Join(directoryInfo.FullName, path)); - - IDirectoryInfo GitDir(IDirectoryInfo directoryInfo, string path) => - fileSystem.DirectoryInfo.New(Path.Join(directoryInfo.FullName, path)); - - string? Read(IDirectoryInfo directoryInfo, string path) - { - var gitPath = Git(directoryInfo, path).FullName; - return !fileSystem.File.Exists(gitPath) - ? null - : fileSystem.File.ReadAllText(gitPath).Trim(Environment.NewLine.ToCharArray()); - } - - string BranchTrackingRemote(string b, IniFile c) - { - var sections = c.GetSections(); - var branchSection = $"branch \"{b}\""; - if (!sections.Contains(branchSection)) - return string.Empty; + /// + /// Returns for test setups that use a mock filesystem but do not seed a + /// real git layout — either no .git entry at all, or a bare .git directory without + /// a config file. Tests that only add .git/ to make FindGitRoot succeed + /// intentionally fall into the second case; they do not need real git metadata. + /// + /// These test setups pre-date testable git resolution and continue to receive the canned test + /// instance so they do not need to be updated. A .git file (worktree pointer) is + /// excluded: tests that seed a worktree pointer are explicitly modelling a worktree layout and + /// expect real resolution or . + /// + /// + private static bool IsLegacyTestWithoutGitLayout(IFileSystem fileSystem, IDirectoryInfo source) + { + var gitPath = fileSystem.Path.Join(source.FullName, ".git"); + var noGitEntry = !fileSystem.Directory.Exists(gitPath) && !fileSystem.File.Exists(gitPath); + var gitDirWithoutConfig = fileSystem.Directory.Exists(gitPath) + && !fileSystem.File.Exists(fileSystem.Path.Join(gitPath, "config")); + return noGitEntry || gitDirWithoutConfig; + } - var remoteName = ini.GetSetting(branchSection, "remote")?.Trim(); + private static string BranchTrackingRemote(string branch, IniFile config) + { + var sections = config.GetSections(); + var branchSection = $"branch \"{branch}\""; + if (!sections.Contains(branchSection)) + return string.Empty; - var remoteSection = $"remote \"{remoteName}\""; + var remoteName = config.GetSetting(branchSection, "remote")?.Trim(); + if (string.IsNullOrEmpty(remoteName)) + return string.Empty; - remote = ini.GetSetting(remoteSection, "url")?.Trim(); - return remote ?? string.Empty; - } + var remoteSection = $"remote \"{remoteName}\""; + return config.GetSetting(remoteSection, "url")?.Trim() ?? string.Empty; } [GeneratedRegex(@"\.git$", RegexOptions.IgnoreCase)] diff --git a/src/Elastic.Documentation.Tooling/Paths.cs b/src/Elastic.Documentation.Tooling/Paths.cs index a3d7011301..fb3be94d69 100644 --- a/src/Elastic.Documentation.Tooling/Paths.cs +++ b/src/Elastic.Documentation.Tooling/Paths.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; +using System.Security; using Elastic.Documentation.Extensions; // ReSharper disable once CheckNamespace — intentionally preserving the original namespace so consumers need no using changes @@ -16,87 +17,62 @@ public static class Paths public static readonly DirectoryInfo ApplicationData = GetApplicationFolder(); - /// - /// Walks up from until a .git directory or file - /// (worktree pointer) is found and returns that ancestor. Returns - /// itself when no git root is found within the allowed depth. - /// - /// - /// Depth protection: in release builds the .git anchor must be at most 1 directory - /// above — documentation is not expected to live deep inside - /// a repo. In debug builds a deeper .git is accepted when a *.slnx file is - /// adjacent (developer running the binary from an IDE output directory). - /// - public static string FindGitRoot(string startPath) - { - var resolved = Path.IsPathRooted(startPath) ? startPath : Path.GetFullPath(startPath); - var dir = Directory.Exists(resolved) - ? new DirectoryInfo(resolved) - : new DirectoryInfo(Path.GetDirectoryName(resolved) ?? resolved); - var startDir = dir.FullName; // always a directory, used as fallback - var depth = 0; - while (dir != null) - { - var hasGit = dir.GetDirectories(".git").Length > 0 || dir.GetFiles(".git").Length > 0; - if (hasGit) - { -#if DEBUG - if (depth <= 1 || dir.GetFiles("*.slnx").Length > 0) - return dir.FullName; -#else - if (depth <= 1) - return dir.FullName; -#endif - // .git found but too deep — stop searching - return startDir; - } - depth++; - dir = dir.Parent; - } - return startDir; - } - /// /// Walks up from via until /// a .git directory or file (worktree pointer) is found. /// Returns if no git root is found within the allowed depth. /// - /// Directory to start the upward search from. - /// - /// Optional upper bound for the search. When provided, the walk may reach - /// but never goes above it, replacing the fixed depth limit with a directory boundary. - /// When , the original depth-1 limit applies. + /// Directory to start the upward search from (typically the docset anchor). + /// + /// Maximum number of parent directories to walk above + /// (default: 1, i.e. self or one parent). The depth is 0-based: at depth 0 we check + /// itself; at depth 1 its immediate parent, and so on. /// /// - /// Without a ceiling the same depth protection as applies. - /// With a ceiling the caller guarantees the boundary is trustworthy (e.g. the working directory - /// root), so any .git found at or below it is accepted regardless of depth. + /// In DEBUG builds a .git found beyond is still accepted + /// when it has an adjacent *.slnx file — this covers the developer case of running a + /// binary from an IDE output directory (e.g. bin/Debug/net10.0/) where the solution + /// root is several levels up. /// - public static IDirectoryInfo? FindGitRoot(IDirectoryInfo startDirectory, IDirectoryInfo? ceiling = null) + public static IDirectoryInfo? FindGitRoot(IDirectoryInfo startDirectory, int maxParents = 1) { var directory = startDirectory; var depth = 0; while (directory != null) { - if (ceiling is not null && !directory.IsSubPathOf(ceiling)) + bool hasGit; + try + { + hasGit = directory.GetDirectories(".git").Length > 0 + || directory.GetFiles(".git").Length > 0; + } + catch (DirectoryNotFoundException) + { + // Directory does not exist in the (mock) filesystem — no .git here. + // Continue up the tree so the caller can decide. + hasGit = false; + } + catch (SecurityException) + { + // A ScopedFileSystem is blocking access to this directory (e.g. the scope root + // is the anchor itself so the parent is outside scope). Stop searching. return null; + } - var hasGit = directory.GetDirectories(".git").Length > 0 - || directory.GetFiles(".git").Length > 0; if (hasGit) { - if (ceiling is not null) - return directory; #if DEBUG - if (depth <= 1 || directory.GetFiles("*.slnx").Length > 0) + if (depth <= maxParents || directory.GetFiles("*.slnx").Length > 0) return directory; #else - if (depth <= 1) + if (depth <= maxParents) return directory; #endif - // .git found but too deep + // .git found but too deep — stop searching return null; } + if (depth >= maxParents) + return null; depth++; directory = directory.Parent; } @@ -120,15 +96,8 @@ private static DirectoryInfo DetermineWorkingDirectoryRoot() || directory.GetFiles(".git").Length > 0; if (hasGit) { - // Only accept .git beyond 1 level up in debug when a *.slnx is adjacent - // (developer running from IDE output directory such as bin/Debug/net10.0/). -#if DEBUG - if (depth <= 1 || directory.GetFiles("*.slnx").Length > 0) - return directory; -#else if (depth <= 1) return directory; -#endif // .git found but too deep — stop without adopting it return cwd; } @@ -215,6 +184,61 @@ public static (IDirectoryInfo, IFileInfo) FindDocsFolderFromRoot(IFileSystem rea return (docsFolder, configurationPath); } + /// + /// Resolves the real git directory from a worktree pointer (.git file containing + /// gitdir: <path>). Handles both absolute and relative gitdir paths, and follows + /// commondir to the shared object store when present (linked/nested worktree). + /// + /// The filesystem to read through. + /// The .git file (worktree pointer) to read. + /// + /// On success, the resolved git directory (.git/ or the worktrees subdirectory's + /// parent when a commondir is present). + /// + /// when the pointer was read and resolved; + /// when the file is absent, malformed, or the resolved path does not exist. + public static bool TryReadGitDirPointer(IFileSystem fileSystem, IFileInfo gitFile, out IDirectoryInfo? gitDir) + { + gitDir = null; + if (!fileSystem.File.Exists(gitFile.FullName)) + return false; + + var text = fileSystem.File.ReadAllText(gitFile.FullName); + var firstLineBreak = text.IndexOfAny(['\r', '\n']); + var firstLine = (firstLineBreak >= 0 ? text[..firstLineBreak] : text).Trim(); + if (!firstLine.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) + return false; + + var rawGitDir = firstLine["gitdir:".Length..].Trim(); + if (string.IsNullOrEmpty(rawGitDir)) + return false; + + // Resolve relative paths against the directory that contains the .git file + var containingDir = gitFile.Directory?.FullName ?? string.Empty; + var resolvedGitDir = fileSystem.Path.IsPathFullyQualified(rawGitDir) + ? rawGitDir + : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(containingDir, rawGitDir)); + + if (!fileSystem.Directory.Exists(resolvedGitDir)) + return false; + + // Follow commondir to reach the shared .git root (linked/nested worktrees) + var commonDirFile = fileSystem.Path.Combine(resolvedGitDir, "commondir"); + if (fileSystem.File.Exists(commonDirFile)) + { + var commonDirRelative = fileSystem.File.ReadAllText(commonDirFile).Trim(); + var commonDir = fileSystem.Path.IsPathFullyQualified(commonDirRelative) + ? commonDirRelative + : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(resolvedGitDir, commonDirRelative)); + + if (fileSystem.Directory.Exists(commonDir)) + resolvedGitDir = commonDir; + } + + gitDir = fileSystem.DirectoryInfo.New(resolvedGitDir); + return true; + } + /// Validates that is a single path segment with no separators or traversal components. /// Throws when the value is blank, contains separators, or equals "." / "..". public static void ValidateSinglePathSegment(string value, string paramName) diff --git a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs index c3e1af48f7..8985ff2a23 100644 --- a/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs +++ b/src/Elastic.Documentation/Extensions/IFileInfoExtensions.cs @@ -101,6 +101,10 @@ public static bool IsCaseSensitiveFileSystem public static bool IsSubPathOf(this IDirectoryInfo directory, IDirectoryInfo parentDirectory) { var cmp = IsCaseSensitiveFileSystem ? Ordinal : OrdinalIgnoreCase; + // Fast reject: if directory's full path is shorter than parentDirectory's it can't be a + // descendant (+1 accounts for a trailing separator on one side but not the other). + if (directory.FullName.Length + 1 < parentDirectory.FullName.Length) + return false; var parent = directory; do { @@ -112,6 +116,11 @@ public static bool IsSubPathOf(this IDirectoryInfo directory, IDirectoryInfo par return false; } + /// Constructs for both paths from + /// and delegates to . + public static bool IsSubPath(string path, string parent, IFileSystem fs) => + fs.DirectoryInfo.New(path).IsSubPathOf(fs.DirectoryInfo.New(parent)); + /// Checks if has parent directory , defaults to OrdinalIgnoreCase comparison public static bool HasParent(this IDirectoryInfo directory, string parentName, StringComparison comparison = OrdinalIgnoreCase) { diff --git a/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs new file mode 100644 index 0000000000..cdf2374ed7 --- /dev/null +++ b/src/Elastic.Documentation/FileSystems/AssemblyWriteFileSystem.cs @@ -0,0 +1,97 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Extensions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Write scope for the assembler and codex pipeline. Sibling of DocumentationWriteFileSystem +/// under ScopedFileSystem — it does not derive from +/// DocumentationWriteFileSystem so that write slots typed to one cannot silently accept the other. +/// +/// Roots: the checkouts directory and the output directory (if they are not already nested inside each +/// other), plus for S3 upload staging. +/// +/// +/// The checkouts root (e.g. AppData/checkouts/<source>). +/// +/// Optional explicit output directory. When it falls outside , it is added +/// as a second scope root; when , output is assumed to be under +/// /.artifacts and is therefore already covered. +/// +/// +/// The underlying filesystem. Defaults to a new when . +/// Pass a mock in tests. +/// +public class AssemblyWriteFileSystem( + IDirectoryInfo checkout, + IDirectoryInfo? output = null, + IFileSystem? inner = null) + : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout, output, inner)) +{ + /// + /// The per-user application data directory for elastic/docs-builder. + /// Computed inline (rather than via Paths) to avoid a circular project reference. + /// + private static string ApplicationDataPath + { + get + { + var localPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localPath)) + localPath = System.IO.Path.GetTempPath(); + return System.IO.Path.Join(localPath, "elastic", "docs-builder"); + } + } + + private static ScopedFileSystemOptions BuildOptions( + IDirectoryInfo checkout, + IDirectoryInfo? output, + IFileSystem? inner) + { + var fs = inner ?? checkout.FileSystem; + var checkoutPath = checkout.FullName; + var roots = new List { checkoutPath }; + + // AppData is disjointness-filtered: on CI the checkouts directory lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/...), so AppData would subsume + // checkoutPath and trigger ValidateRootsAreDisjoint. + var appData = ApplicationDataPath; + if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) + { + roots.Add(appData); + } + + if (output is not null && !IDirectoryInfoExtensions.IsSubPath(output.FullName, checkoutPath, fs)) + roots.Add(output.FullName); + + // MockFileSystem hardcodes its temp path ("C:\temp" on Windows, unix-ified to "/temp/" + // elsewhere) instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses + // the real GetTempPath() (e.g. "/tmp/" on Linux, "C:\Users\\AppData\Local\Temp" on + // Windows), so the two diverge on every OS and scope validation fails for any path created + // via mockFs.Path.GetTempPath(). + // + // Fix tracked upstream: https://github.com/TestableIO/System.IO.Abstractions/pull/1454 + // Once that ships and we update the package reference we can drop this workaround. + var innerType = fs is ScopedFileSystem sf ? sf.InnerType : fs.GetType(); + if (innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + { + var innerTemp = fs.Path.GetTempPath().TrimEnd( + System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); + if (!string.IsNullOrEmpty(innerTemp) && !roots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) + roots.Add(innerTemp); + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, + AllowedSpecialFolders = AllowedSpecialFolder.Temp + }; + } +} diff --git a/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs new file mode 100644 index 0000000000..3620699bf0 --- /dev/null +++ b/src/Elastic.Documentation/FileSystems/DocumentationWriteFileSystem.cs @@ -0,0 +1,103 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Extensions; +using Nullean.ScopedFileSystem; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Write scope for a documentation set or checkout tree. Sibling of DocumentationFileSystem +/// under ScopedFileSystem — it does not derive from +/// DocumentationFileSystem so that write slots typed to this class cannot silently accept +/// the read aggregate, and vice versa. +/// +/// The write scope intentionally omits .git from : +/// nothing in the build output pipeline should ever write into git repository metadata. +/// +/// +/// +/// Constructs the write scope for a documentation set. +/// +/// The repository checkout root (the directory containing .git). +/// +/// Optional explicit output directory. When it falls outside (e.g. +/// --output /tmp/build), it is added as a second scope root. When , +/// output is assumed to be under /.artifacts and is therefore already covered. +/// +/// +/// The underlying filesystem. Defaults to a new when . +/// Pass a mock in tests. +/// +public class DocumentationWriteFileSystem( + IDirectoryInfo checkout, + IDirectoryInfo? output = null, + IFileSystem? inner = null) + : ScopedFileSystem(inner ?? new FileSystem(), BuildOptions(checkout, output, inner)) +{ + + /// + /// The per-user application data directory for elastic/docs-builder. + /// Same value as Paths.ApplicationData.FullName from the Tooling project, computed here to + /// avoid a circular project reference. + /// + private static string ApplicationDataPath + { + get + { + var localPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localPath)) + localPath = System.IO.Path.GetTempPath(); + return System.IO.Path.Join(localPath, "elastic", "docs-builder"); + } + } + + private static ScopedFileSystemOptions BuildOptions( + IDirectoryInfo checkout, + IDirectoryInfo? output, + IFileSystem? inner) + { + var fs = inner ?? checkout.FileSystem; + var checkoutPath = checkout.FullName; + var roots = new List { checkoutPath }; + + // AppData is disjointness-filtered: on CI each docset checkout lives inside AppData + // (/home/runner/.local/share/elastic/docs-builder/checkouts/current/), so AppData would + // subsume checkoutPath and trigger ValidateRootsAreDisjoint. + var appData = ApplicationDataPath; + if (!IDirectoryInfoExtensions.IsSubPath(appData, checkoutPath, fs) + && !IDirectoryInfoExtensions.IsSubPath(checkoutPath, appData, fs)) + { + roots.Add(appData); + } + + if (output is not null && !IDirectoryInfoExtensions.IsSubPath(output.FullName, checkout.FullName, fs)) + roots.Add(output.FullName); + + // MockFileSystem hardcodes its temp path ("C:\temp" on Windows, unix-ified to "/temp/" + // elsewhere) instead of calling System.IO.Path.GetTempPath(). AllowedSpecialFolder.Temp uses + // the real GetTempPath() (e.g. "/tmp/" on Linux, "C:\Users\\AppData\Local\Temp" on + // Windows), so the two diverge on every OS and scope validation fails for any path created + // via mockFs.Path.GetTempPath(). + // + // Fix tracked upstream: https://github.com/TestableIO/System.IO.Abstractions/pull/1454 + // Once that ships and we update the package reference we can drop this workaround. + var innerType = fs is ScopedFileSystem sf ? sf.InnerType : fs.GetType(); + if (innerType.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase)) + { + var innerTemp = fs.Path.GetTempPath().TrimEnd( + System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar); + if (!string.IsNullOrEmpty(innerTemp) && !roots.Contains(innerTemp, StringComparer.OrdinalIgnoreCase)) + roots.Add(innerTemp); + } + + return new ScopedFileSystemOptions([.. roots]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" }, + AllowedSpecialFolders = AllowedSpecialFolder.Temp + }; + } +} diff --git a/src/Elastic.Documentation/FileSystems/IDocumentationFileSystem.cs b/src/Elastic.Documentation/FileSystems/IDocumentationFileSystem.cs new file mode 100644 index 0000000000..3bb6fe9984 --- /dev/null +++ b/src/Elastic.Documentation/FileSystems/IDocumentationFileSystem.cs @@ -0,0 +1,16 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; + +namespace Elastic.Documentation.FileSystems; + +/// +/// Marker interface for the documentation-set scope: a single checked-out repository +/// anchored to a docset.yml. Only DocumentationFileSystem implements +/// this; declaring it on parameters ensures the compiler rejects an assembler-scope +/// ICheckoutsFileSystem or a changelog-scope IChangelogFileSystem +/// where a docset-anchored read scope is required. +/// +public interface IDocumentationFileSystem : IFileSystem; diff --git a/src/Elastic.Documentation/IDocumentationContext.cs b/src/Elastic.Documentation/IDocumentationContext.cs index 9bc24b4f72..043f1b428c 100644 --- a/src/Elastic.Documentation/IDocumentationContext.cs +++ b/src/Elastic.Documentation/IDocumentationContext.cs @@ -4,15 +4,14 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation; public interface IDocumentationContext { IDiagnosticsCollector Collector { get; } - ScopedFileSystem ReadFileSystem { get; } - ScopedFileSystem WriteFileSystem { get; } + DocumentationWriteFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } IFileInfo ConfigurationPath { get; } BuildType BuildType { get; } @@ -20,6 +19,7 @@ public interface IDocumentationContext public interface IDocumentationSetContext : IDocumentationContext { + IDocumentationFileSystem ReadFileSystem { get; } IDirectoryInfo DocumentationSourceDirectory { get; } GitCheckoutInformation Git { get; } diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index e5d4f08fe4..9853605938 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -239,13 +239,8 @@ private void CopyBrandingResources() private void CopyFileAcrossFileSystems(IFileInfo source, IFileInfo destination) { - if (Context.ReadFileSystem == _writeFileSystem) - Context.ReadFileSystem.File.Copy(source.FullName, destination.FullName, overwrite: true); - else - { - var bytes = Context.ReadFileSystem.File.ReadAllBytes(source.FullName); - _writeFileSystem.File.WriteAllBytes(destination.FullName, bytes); - } + var bytes = Context.ReadFileSystem.File.ReadAllBytes(source.FullName); + _writeFileSystem.File.WriteAllBytes(destination.FullName, bytes); } private void HintUnusedSubstitutionKeys() diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs index d8a732e33d..a67938efa0 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.Export.cs @@ -210,9 +210,9 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, var gitHubRepo = fileContext.BuildContext.Git.GitHubRepository; var branch = fileContext.BuildContext.Git.Branch; if (gitHubRepo is not null - && fileContext.BuildContext.Git != GitCheckoutInformation.Unavailable - && fileContext.BuildContext.DocumentationCheckoutDirectory is { } checkoutDirectory) + && fileContext.BuildContext.Git != GitCheckoutInformation.Unavailable) { + var checkoutDirectory = fileContext.BuildContext.DocumentationCheckoutDirectory; var relativeSourcePath = Path.GetRelativePath( checkoutDirectory.FullName, fileContext.BuildContext.DocumentationSourceDirectory.FullName); diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs index fb779c815f..f3a7c1b07b 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs @@ -164,7 +164,7 @@ BuildContext build private static IFileInfo GetRuleSourcePath(IFileInfo rulePath, BuildContext build) { - var checkoutDir = build.DocumentationCheckoutDirectory ?? build.DocumentationSourceDirectory.Parent!; + var checkoutDir = build.DocumentationCheckoutDirectory; var relative = Path.GetRelativePath(checkoutDir.FullName, rulePath.FullName); var newPath = Path.Join(build.DocumentationSourceDirectory.FullName, relative); var md = Path.ChangeExtension(newPath, ".md"); diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index c587fd11d7..72135f1680 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -94,8 +94,9 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc var gitHubRepo = DocumentationSet.Context.Git.GitHubRepository; var branch = DocumentationSet.Context.Git.Branch; string? editUrl = null; - if (gitHubRepo is not null && DocumentationSet.Context.Git != GitCheckoutInformation.Unavailable && DocumentationSet.Context.DocumentationCheckoutDirectory is { } checkoutDirectory) + if (gitHubRepo is not null && DocumentationSet.Context.Git != GitCheckoutInformation.Unavailable) { + var checkoutDirectory = DocumentationSet.Context.DocumentationCheckoutDirectory; var relativeSourcePath = Path.GetRelativePath(checkoutDirectory.FullName, DocumentationSet.Context.DocumentationSourceDirectory.FullName); var path = UrlPath.Join(relativeSourcePath, markdown.RelativePath); editUrl = $"https://github.com/{gitHubRepo}/edit/{branch}/{path}"; @@ -158,9 +159,9 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc var gitRef = DocumentationSet.Context.Git.Ref; string? gitHubDocsUrl = null; if (gitHubRepo is not null - && !string.IsNullOrEmpty(gitBranch) && gitBranch != "unavailable" - && DocumentationSet.Context.DocumentationCheckoutDirectory is { } docsCheckoutDir) + && !string.IsNullOrEmpty(gitBranch) && gitBranch != "unavailable") { + var docsCheckoutDir = DocumentationSet.Context.DocumentationCheckoutDirectory; var relativeDocsPath = Path.GetRelativePath(docsCheckoutDir.FullName, DocumentationSet.Context.DocumentationSourceDirectory.FullName) .Replace(Path.DirectorySeparatorChar, '/'); gitHubDocsUrl = $"https://github.com/{gitHubRepo}/tree/{gitBranch}/{relativeDocsPath}"; diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 2e6fc5d937..ac670be56b 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -91,7 +91,9 @@ public DocumentationSet( Name = Context.Git != GitCheckoutInformation.Unavailable ? Context.Git.RepositoryName - : Context.DocumentationCheckoutDirectory?.Name ?? $"unknown-{Context.DocumentationSourceDirectory.Name}"; + : Context.DocumentationCheckoutDirectory.Name + ?? Context.DocumentationSourceDirectory.Parent?.Name + ?? Context.DocumentationSourceDirectory.Name; OutputStateFile = OutputDirectory.FileSystem.FileInfo.New(Path.Join(OutputDirectory.FullName, ".doc.state")); LinkReferenceFile = OutputDirectory.FileSystem.FileInfo.New(Path.Join(OutputDirectory.FullName, "links.json")); diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 97f2794f90..9f054ea8a4 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -404,7 +404,7 @@ private void LoadConfiguration() => /// against this same root. /// private IDirectoryInfo ConfigTrustRoot => - Build.DocumentationCheckoutDirectory ?? Build.DocumentationSourceDirectory; + Build.DocumentationCheckoutDirectory; private string? ResolveConfigPath() { diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs index 12f6b1d68f..771a533a39 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvReader.cs @@ -5,17 +5,12 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; using nietras.SeparatedValues; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Myst.Directives.CsvInclude; public static class CsvReader { - public static IEnumerable ReadCsvFile(string filePath, string separator, ScopedFileSystem? fileSystem = null) - { - var fs = fileSystem ?? FileSystemFactory.RealRead; - return ReadWithSep(filePath, separator, fs); - } + public static IEnumerable ReadCsvFile(string filePath, string separator, IFileSystem fileSystem) => ReadWithSep(filePath, separator, fileSystem); private static IEnumerable ReadWithSep(string filePath, string separator, IFileSystem fileSystem) { diff --git a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs index 591a8c8da9..ef8d6d4e84 100644 --- a/src/authoring/Elastic.Documentation.Refactor/FormatService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/FormatService.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Refactor.Formatters; using Elastic.Documentation.Services; @@ -40,7 +41,8 @@ Cancel ctx ) { // Create BuildContext to load the documentation set - var context = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, path, null); + var docFs = DocumentationFileSystem.Resolve(path); + var context = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var set = new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance); var mode = checkOnly ? "Checking" : "Formatting"; diff --git a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs index 91fbaedd56..2c037d0104 100644 --- a/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/MoveFileService.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Services; using Elastic.Markdown.IO; @@ -28,7 +29,8 @@ public async Task Move( Cancel ctx ) { - var context = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, path, null); + var docFs = DocumentationFileSystem.Resolve(path); + var context = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var set = new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance); diff --git a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs index c81f182790..1882bf3f14 100644 --- a/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs +++ b/src/authoring/Elastic.Documentation.Refactor/Tracking/LocalChangesService.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; @@ -24,7 +25,8 @@ public Task ValidateRedirects(IDiagnosticsCollector collector, string? pat { var runningOnCi = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); - var buildContext = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, path, null); + var docFs = DocumentationFileSystem.Resolve(path); + var buildContext = new BuildContext(collector, docFs, configurationContext) { AvailableExporters = ExportOptions.MetadataOnly }; var redirectFile = new RedirectFile(buildContext); if (!redirectFile.Source.Exists) { diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index 4ea6feaa51..6cdd7f54ff 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -13,10 +13,10 @@ using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; @@ -56,7 +56,7 @@ public record AmendBundleArguments /// public partial class ChangelogBundleAmendService( ILoggerFactory logFactory, - ScopedFileSystem? fileSystem = null, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null) : IService { /// @@ -65,9 +65,9 @@ public partial class ChangelogBundleAmendService( private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; [GeneratedRegex(@"\.amend-(\d+)\.ya?ml$", RegexOptions.IgnoreCase)] diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index e48b560106..c9b9e65bda 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -15,10 +15,10 @@ using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; @@ -165,8 +165,8 @@ public record BundlePlanResult /// public partial class ChangelogBundlingService( ILoggerFactory logFactory, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, - ScopedFileSystem? fileSystem = null, IGitHubReleaseService? releaseService = null, CdnChangelogEntryFetcher? entryFetcher = null, IGitHubPrService? prService = null, @@ -174,13 +174,13 @@ public partial class ChangelogBundlingService( : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly CdnChangelogEntryFetcher _entryFetcher = entryFetcher ?? new CdnChangelogEntryFetcher(logFactory); private readonly IGitHubPrService _prService = prService ?? new GitHubPrService(logFactory); private readonly IGitHubCommitRangeService _commitRangeService = commitRangeService ?? new GitHubCommitRangeService(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; // Defaults applied when sourcing CDN entries and the org/branch are not otherwise resolvable. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs index d73b0089d2..a39a9c4a8b 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogRemoveService.cs @@ -8,9 +8,9 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; @@ -60,16 +60,16 @@ public record ChangelogRemoveArguments /// public class ChangelogRemoveService( ILoggerFactory logFactory, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, - ScopedFileSystem? fileSystem = null, IGitHubReleaseService? releaseService = null) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; public async Task RemoveChangelogs(IDiagnosticsCollector collector, ChangelogRemoveArguments input, Cancel ctx) diff --git a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs index ccedc482d4..004fb1b175 100644 --- a/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/ProfileFilterResolver.cs @@ -8,10 +8,10 @@ using Elastic.Changelog.GitHub; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; @@ -83,7 +83,7 @@ public static partial class ProfileFilterResolver string profileName, string? profileArgument, ChangelogConfiguration? config, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, ILogger? logger, Cancel ctx, string? profileReport = null, @@ -201,7 +201,7 @@ public static partial class ProfileFilterResolver string profileArgument, string profileReport, BundleProfile profile, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, ILogger? logger, Cancel ctx) { @@ -282,7 +282,7 @@ public static partial class ProfileFilterResolver internal static async Task ResolveListFileAsync( IDiagnosticsCollector collector, string filePath, - ScopedFileSystem fileSystem, + IChangelogFileSystem fileSystem, Cancel ctx) { var content = await fileSystem.File.ReadAllTextAsync(filePath, ctx); @@ -356,7 +356,7 @@ public static partial class ProfileFilterResolver : new ListFileResult(null, lines, null); } - private static ProfileArgumentType DetectLocalFileType(ScopedFileSystem fileSystem, string path) => + private static ProfileArgumentType DetectLocalFileType(IChangelogFileSystem fileSystem, string path) => fileSystem.Path.GetExtension(path).ToLowerInvariant() is ".html" or ".htm" ? ProfileArgumentType.PromotionReportFile : ProfileArgumentType.UrlListFile; diff --git a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs index 12e9091ac6..9a2c03e1c0 100644 --- a/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs +++ b/src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs @@ -7,18 +7,18 @@ using System.Text.RegularExpressions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Bundling; /// /// Parser for promotion report HTML files to extract PR lists /// -public partial class PromotionReportParser(ILoggerFactory logFactory, ScopedFileSystem? fileSystem = null) +public partial class PromotionReportParser(ILoggerFactory logFactory, IChangelogFileSystem fileSystem) { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private static readonly string[] AllowedHosts = ["github.com", "buildkite.com"]; diff --git a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs index a0397fca24..7a435eb8d6 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogCreationService.cs @@ -9,9 +9,9 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Creation; @@ -71,17 +71,17 @@ public record CreateChangelogArguments public class ChangelogCreationService( ILoggerFactory logFactory, IConfigurationContext configurationContext, +IChangelogFileSystem fileSystem, IGitHubPrService? githubPrService = null, -ScopedFileSystem? fileSystem = null, IEnvironmentVariables? env = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); private readonly CreateChangelogArgumentsValidator _validator = new(configurationContext); private readonly PrInfoProcessor _prProcessor = new(githubPrService, logFactory.CreateLogger()); private readonly IssueInfoProcessor _issueProcessor = new(githubPrService, logFactory.CreateLogger()); - private readonly ChangelogFileWriter _fileWriter = new(fileSystem ?? FileSystemFactory.RealWrite, logFactory.CreateLogger()); + private readonly ChangelogFileWriter _fileWriter = new(fileSystem, logFactory.CreateLogger()); private readonly ProductInferService _productInferService = new( configurationContext.ProductsConfiguration); diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index 012fee7fd8..7ea408b3ca 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -10,6 +10,7 @@ using Elastic.Changelog.GitHub; using Elastic.Changelog.Utilities; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -20,11 +21,11 @@ public class ChangelogArtifactEvaluationService( ILoggerFactory logFactory, IGitHubPrService gitHubPrService, ICoreService coreService, - IFileSystem? fileSystem = null + IRunnerTempFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly IRunnerTempFileSystem _fileSystem = fileSystem; public async Task EvaluateArtifact(IDiagnosticsCollector collector, EvaluateArtifactArguments input, Cancel ctx) { diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs index e0177b142b..5a33e224fb 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrBodyReader.cs @@ -7,6 +7,7 @@ using System.Security; using System.Text; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Evaluation; @@ -22,7 +23,7 @@ public static class ChangelogPrBodyReader public static async Task ReadAsync( string? prBodyFile, IDiagnosticsCollector collector, - IFileSystem fileSystem, + IRunnerTempFileSystem fileSystem, CancellationToken ct) { if (string.IsNullOrWhiteSpace(prBodyFile)) diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index 2b1c78eda7..b572df850a 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -11,10 +11,10 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Evaluation; @@ -24,12 +24,12 @@ public class ChangelogPrEvaluationService( IConfigurationContext configurationContext, IGitHubPrService gitHubPrService, ICoreService coreService, - ScopedFileSystem? fileSystem = null + IRunnerTempFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); + private readonly IRunnerTempFileSystem _fileSystem = fileSystem; + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrArguments input, Cancel ctx) { diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs index 9fbd1c8cff..0773c20ce6 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -20,7 +21,7 @@ public class ChangelogPrepareArtifactService( ILoggerFactory logFactory, IConfigurationContext configurationContext, ICoreService coreService, - IFileSystem? fileSystem = null + IRunnerTempFileSystem fileSystem ) : IService { /// @@ -29,8 +30,8 @@ public class ChangelogPrepareArtifactService( private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? new FileSystem()); + private readonly IRunnerTempFileSystem _fileSystem = fileSystem; + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); public async Task PrepareArtifact(IDiagnosticsCollector collector, PrepareArtifactArguments input, Cancel ctx) { diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 1784f98dcb..99cb0750ba 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -13,10 +13,10 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.GithubRelease; @@ -80,9 +80,9 @@ public record CreateChangelogsFromReleaseArguments public class GitHubReleaseChangelogService( ILoggerFactory logFactory, IConfigurationContext configurationContext, + IChangelogFileSystem fileSystem, IGitHubReleaseService? releaseService = null, IGitHubPrService? prService = null, - ScopedFileSystem? fileSystem = null, ChangelogBundlingService? bundlingService = null ) : IService { @@ -92,11 +92,11 @@ public class GitHubReleaseChangelogService( private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; - private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); + private readonly IChangelogFileSystem _fileSystem = fileSystem; + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory); private readonly IGitHubPrService _prService = prService ?? new GitHubPrService(logFactory); - private readonly ChangelogBundlingService _bundlingService = bundlingService ?? new ChangelogBundlingService(logFactory, configurationContext, fileSystem); + private readonly ChangelogBundlingService _bundlingService = bundlingService ?? new ChangelogBundlingService(logFactory, fileSystem, configurationContext); public async Task CreateChangelogsFromRelease( IDiagnosticsCollector collector, diff --git a/src/services/Elastic.Changelog/Migration/WebMigrationService.cs b/src/services/Elastic.Changelog/Migration/WebMigrationService.cs index 5b9aae036e..5f25cdf725 100644 --- a/src/services/Elastic.Changelog/Migration/WebMigrationService.cs +++ b/src/services/Elastic.Changelog/Migration/WebMigrationService.cs @@ -11,7 +11,6 @@ using Amazon.S3; using Amazon.S3.Model; using Elastic.Documentation; -using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; @@ -48,7 +47,7 @@ public sealed record MigrationKeyResult(string Key, string Outcome, string? ETag /// public class WebMigrationService( ILoggerFactory logFactory, - ScopedFileSystem? fileSystem = null, + ScopedFileSystem fileSystem, IAmazonS3? s3Client = null, HttpMessageHandler? httpMessageHandler = null ) : IService @@ -59,7 +58,7 @@ public class WebMigrationService( private const string OutcomeFailed = "failed"; private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealWrite; + private readonly IFileSystem _fileSystem = fileSystem; /// Per-key results of the most recent run; exposed for tests. internal IReadOnlyList LastResults { get; private set; } = []; diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs index 5e609cd243..64281dedba 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderer.cs @@ -5,15 +5,15 @@ using System.IO.Abstractions; using Elastic.Changelog.Rendering.Asciidoc; using Elastic.Changelog.Rendering.Markdown; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Rendering; /// /// Coordinates rendering of changelog output to different formats. /// -public class ChangelogRenderer(ScopedFileSystem fileSystem, ILogger logger) +public class ChangelogRenderer(IChangelogFileSystem fileSystem, ILogger logger) { /// /// Renders changelog output based on the specified file type. diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index cf83389001..b647abe7e4 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -9,12 +9,12 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; -using Nullean.ScopedFileSystem; using YamlDotNet.Core; namespace Elastic.Changelog.Rendering; @@ -70,12 +70,12 @@ public enum ChangelogFileType /// public class ChangelogRenderingService( ILoggerFactory logFactory, - IConfigurationContext? configurationContext = null, - ScopedFileSystem? fileSystem = null + IChangelogFileSystem fileSystem, + IConfigurationContext? configurationContext = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly ScopedFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealWrite; + private readonly IChangelogFileSystem _fileSystem = fileSystem; public async Task RenderChangelogs( IDiagnosticsCollector collector, diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs index 1eae9745d4..46a3a235ff 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/BreakingChangesMarkdownRenderer.cs @@ -5,8 +5,8 @@ using System.IO.Abstractions; using System.Text; using Elastic.Documentation; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -15,7 +15,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the breaking-changes.md changelog file /// -public class BreakingChangesMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class BreakingChangesMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "breaking-changes.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs index 9d0e40cfdd..a518d4d092 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogGfmRenderer.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.IO.Abstractions; using System.Text; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -15,7 +15,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for generating clean GitHub Flavored Markdown in a single changelog.md file /// -public class ChangelogGfmRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class ChangelogGfmRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "changelog.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs index c57d7b1162..5b6e54c3fa 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/ChangelogMarkdownRenderer.cs @@ -4,14 +4,14 @@ using System.Collections.Generic; using System.IO.Abstractions; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Rendering.Markdown; /// /// Coordinates rendering of all markdown changelog files. /// -public class ChangelogMarkdownRenderer(ScopedFileSystem fileSystem) +public class ChangelogMarkdownRenderer(IChangelogFileSystem fileSystem) { /// /// Renders all markdown changelog files (index, breaking changes, deprecations, known issues, highlights). diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs index 43843594d8..55c98cfe92 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/DeprecationsMarkdownRenderer.cs @@ -4,8 +4,8 @@ using System.IO.Abstractions; using System.Text; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -14,7 +14,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the deprecations.md changelog file /// -public class DeprecationsMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class DeprecationsMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "deprecations.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs index 22d39044b9..404ffd3864 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/HighlightsMarkdownRenderer.cs @@ -4,8 +4,8 @@ using System.IO.Abstractions; using System.Text; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; namespace Elastic.Changelog.Rendering.Markdown; @@ -13,7 +13,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the highlights.md changelog file /// -public class HighlightsMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class HighlightsMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "highlights.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 318e36e884..dea2a32d79 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.IO.Abstractions; using System.Text; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -15,7 +15,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the index.md changelog file containing features, enhancements, fixes, docs, regressions, and other changes /// -public class IndexMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class IndexMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "index.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs index b84e4945e7..1872a78c6a 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/KnownIssuesMarkdownRenderer.cs @@ -4,8 +4,8 @@ using System.IO.Abstractions; using System.Text; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; using static System.Globalization.CultureInfo; using static Elastic.Documentation.ReleaseNotes.ChangelogEntryType; @@ -14,7 +14,7 @@ namespace Elastic.Changelog.Rendering.Markdown; /// /// Renderer for the known-issues.md changelog file /// -public class KnownIssuesMarkdownRenderer(ScopedFileSystem fileSystem) : MarkdownRendererBase(fileSystem) +public class KnownIssuesMarkdownRenderer(IChangelogFileSystem fileSystem) : MarkdownRendererBase(fileSystem) { /// public override string OutputFileName => "known-issues.md"; diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs index d545d6e502..c9b9529330 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/MarkdownRendererBase.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.IO.Abstractions; using System.Text; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Rendering.Markdown; @@ -24,9 +24,9 @@ public record PrIssueLinkOptions( /// /// Abstract base class for changelog markdown renderers /// -public abstract class MarkdownRendererBase(ScopedFileSystem fileSystem) : IChangelogMarkdownRenderer +public abstract class MarkdownRendererBase(IChangelogFileSystem fileSystem) : IChangelogMarkdownRenderer { - protected ScopedFileSystem FileSystem { get; } = fileSystem; + protected IChangelogFileSystem FileSystem { get; } = fileSystem; /// public abstract string OutputFileName { get; } diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index b859ebb156..33b3f149b8 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -9,10 +9,10 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Integrations.S3; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Uploading; @@ -61,15 +61,15 @@ public record ChangelogUploadArguments public class ChangelogUploadService( ILoggerFactory logFactory, + IChangelogFileSystem fileSystem, IConfigurationContext? configurationContext = null, - ScopedFileSystem? fileSystem = null, IAmazonS3? s3Client = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); - private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; + private readonly IChangelogFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null - ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) + ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem) : null; public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx) diff --git a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs index 2970240090..b28196b311 100644 --- a/src/services/Elastic.Documentation.Assembler/AssembleContext.cs +++ b/src/services/Elastic.Documentation.Assembler/AssembleContext.cs @@ -11,14 +11,14 @@ using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Assembler; public class AssembleContext : IDocumentationConfigurationContext, IDocsSyncContext { - public ScopedFileSystem ReadFileSystem { get; } - public ScopedFileSystem WriteFileSystem { get; } + public CheckoutsFileSystem ReadFileSystem { get; } + public DocumentationWriteFileSystem WriteFileSystem { get; } public IDiagnosticsCollector Collector { get; } @@ -66,15 +66,14 @@ public AssembleContext( IConfigurationContext configurationContext, string environment, IDiagnosticsCollector collector, - ScopedFileSystem readFileSystem, - ScopedFileSystem writeFileSystem, - string? checkoutDirectory, - string? output + CheckoutsFileSystem fileSystem, + string? checkoutDirectory = null, + string? output = null ) { Collector = collector; - ReadFileSystem = readFileSystem; - WriteFileSystem = writeFileSystem; + ReadFileSystem = fileSystem; + WriteFileSystem = fileSystem.Write; Configuration = configuration; ConfigurationFileProvider = configurationContext.ConfigurationFileProvider; @@ -95,8 +94,8 @@ public AssembleContext( var contentSource = Environment.ContentSource.ToStringFast(true); var defaultCheckoutDirectory = Path.Join(Paths.ApplicationData.FullName, "checkouts", contentSource); CheckoutDirectory = checkoutDirectory is null - ? FileSystemFactory.AppData.DirectoryInfo.New(defaultCheckoutDirectory) - : ReadFileSystem.DirectoryInfo.New(checkoutDirectory); + ? fileSystem.DirectoryInfo.New(defaultCheckoutDirectory) + : fileSystem.DirectoryInfo.New(checkoutDirectory); var defaultOutputDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); OutputDirectory = WriteFileSystem.DirectoryInfo.New(output ?? defaultOutputDirectory); diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs index 5534fd7c98..610d47272b 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerBuildService.cs @@ -12,11 +12,11 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LegacyDocs; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Building; @@ -34,8 +34,7 @@ IEnvironmentVariables environmentVariables public async Task BuildAll( IDiagnosticsCollector collector, AssemblerBuildOptions options, - ScopedFileSystem readFs, - ScopedFileSystem writeFs, + CheckoutsFileSystem fileSystem, Cancel ctx ) { @@ -62,7 +61,7 @@ Cancel ctx _logger.LogInformation("Creating assemble context"); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, readFs, writeFs, null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fileSystem); // --assume-build is not allowed on CI: it could serve stale content from a previous/cached build // CI builds must always produce fresh, reproducible output @@ -73,7 +72,7 @@ Cancel ctx if (assumeBuild.GetValueOrDefault(false)) { var indexHtmlPath = Path.Join(assembleContext.OutputDirectory.FullName, "docs", "index.html"); - if (assembleContext.OutputDirectory.Exists && readFs.File.Exists(indexHtmlPath)) + if (assembleContext.OutputDirectory.Exists && fileSystem.File.Exists(indexHtmlPath)) { _logger.LogInformation("Assuming build already exists (--assume-build). Found index.html at {Path}. Skipping build.", indexHtmlPath); return true; @@ -106,7 +105,7 @@ Cancel ctx var assembleSources = await AssembleSources.AssembleAsync(logFactory, assembleContext, checkouts, configurationContext, exporters, ctx); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; - var siteNavigationFile = SiteNavigationFile.Deserialize(await readFs.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); + var siteNavigationFile = SiteNavigationFile.Deserialize(await fileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, ctx)); var documentationSets = assembleSources.AssembleSets.Values.Select(s => s.DocumentationSet.Navigation).ToArray(); var navigation = new SiteNavigation(siteNavigationFile, assembleContext, documentationSets, assembleContext.Environment.PathPrefix); @@ -128,7 +127,7 @@ Cancel ctx await cloner.WriteLinkRegistrySnapshot(checkoutResult.LinkRegistrySnapshot, ctx); var redirectsPath = Path.Join(assembleContext.OutputDirectory.FullName, "redirects.json"); - if (writeFs.File.Exists(redirectsPath)) + if (assembleContext.WriteFileSystem.File.Exists(redirectsPath)) await githubActionsService.SetOutputAsync("redirects-artifact-path", redirectsPath); if (exporters.Contains(Exporter.Html)) diff --git a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs index 3e532d189f..7542c7bfd5 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/AssemblerSitemapService.cs @@ -7,12 +7,12 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Search; using Elastic.Documentation.Search.Contract; using Elastic.Documentation.Services; using Elastic.Markdown.Exporters.Elasticsearch; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Building; @@ -27,7 +27,7 @@ ICoreService githubActionsService public async Task GenerateSitemapAsync( IDiagnosticsCollector collector, - ScopedFileSystem fileSystem, + CheckoutsFileSystem fileSystem, ElasticsearchIndexOptions es, string? environment = null, Cancel ctx = default @@ -38,10 +38,7 @@ public async Task GenerateSitemapAsync( _logger.LogInformation("Generating sitemap from ES index for environment {Environment}", environment); - var assembleContext = new AssembleContext( - assemblyConfiguration, configurationContext, environment, collector, - fileSystem, fileSystem, null, null - ); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fileSystem); var cfg = configurationContext.Endpoints.Elasticsearch; await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs index 7de85a5a8d..300e51a061 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs @@ -7,11 +7,11 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.ContentSources; @@ -20,7 +20,7 @@ public class RepositoryBuildMatchingService( AssemblyConfiguration configuration, IConfigurationContext configurationContext, ICoreService githubActionsService, - ScopedFileSystem fileSystem + CheckoutsFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -71,7 +71,7 @@ public async Task ShouldBuild(IDiagnosticsCollector collector, string? rep var linkRegistry = await GetRegistryWithRetry(linkIndexProvider, ctx); var alreadyPublishing = linkRegistry.Repositories.ContainsKey(repositoryName); _logger.LogInformation("'{Repository}' (registry key: '{RepositoryName}') publishing to link registry: {PublishState} ", repo, repositoryName, alreadyPublishing); - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); var product = assembleContext.ProductsConfiguration.GetProductByRepositoryName(repo); var matches = assembleContext.Configuration.Match(logFactory, repo, refName, product, alreadyPublishing); if (matches is { Current: null, Next: null, Edge: null, Speculative: false }) diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs index 8f11b65b68..45dc7bb2b7 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryPublishValidationService.cs @@ -7,10 +7,10 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.ContentSources; @@ -18,7 +18,7 @@ public class RepositoryPublishValidationService( ILoggerFactory logFactory, AssemblyConfiguration configuration, IConfigurationContext configurationContext, - ScopedFileSystem fileSystem + CheckoutsFileSystem fileSystem ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); @@ -27,7 +27,7 @@ ScopedFileSystem fileSystem public async Task ValidatePublishStatus(IDiagnosticsCollector collector, Cancel ctx) { // environment does not matter to check the configuration, defaulting to dev - var context = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var context = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); ILinkIndexReader linkIndexReader = Aws3LinkIndexReader.CreateAnonymous(); var fetcher = new AssemblerCrossLinkFetcher(logFactory, context.Configuration, context.Environment, linkIndexReader); var links = await fetcher.FetchLinkRegistry(ctx); diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs index a7c4395cef..36de6f119c 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerAiEnrichService.cs @@ -6,12 +6,12 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Indexing; using Elastic.Documentation.Services; using Elastic.Ingest.Elasticsearch.Enrichment; using Elastic.Markdown.Exporters.Elasticsearch; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Indexing; @@ -34,8 +34,7 @@ ICoreService githubActionsService /// public async Task AiEnrich( IDiagnosticsCollector collector, - ScopedFileSystem readFs, - ScopedFileSystem writeFs, + CheckoutsFileSystem fileSystem, ElasticsearchIndexOptions es, string? environment, bool bootstrapOnly, @@ -43,12 +42,12 @@ Cancel ctx ) { var cfg = configurationContext.Endpoints.Elasticsearch; - await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, readFs, ctx); + await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); var githubEnvironmentInput = githubActionsService.GetInput("environment"); environment ??= !string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"; - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, readFs, writeFs, null, null); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fileSystem); using var exporter = new ElasticsearchMarkdownExporter(logFactory, collector, assembleContext.Endpoints, assembleContext); if (!exporter.AiEnrichmentEnabled) diff --git a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs index 02af5d5e7d..a77c7347a2 100644 --- a/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs +++ b/src/services/Elastic.Documentation.Assembler/Indexing/AssemblerIndexService.cs @@ -8,8 +8,8 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; using static Elastic.Documentation.Exporter; namespace Elastic.Documentation.Assembler.Indexing; @@ -27,15 +27,14 @@ IEnvironmentVariables environmentVariables /// Index assembled documentation to Elasticsearch. public async Task Index( IDiagnosticsCollector collector, - ScopedFileSystem readFs, - ScopedFileSystem writeFs, + CheckoutsFileSystem fileSystem, ElasticsearchIndexOptions es, string? environment = null, Cancel ctx = default ) { var cfg = _configurationContext.Endpoints.Elasticsearch; - await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, readFs, ctx); + await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); return await BuildAll(collector, new AssemblerBuildOptions { @@ -45,6 +44,6 @@ public async Task Index( ShowHints = false, Exporters = new HashSet { Elasticsearch }, AssumeBuild = false - }, readFs, writeFs, ctx); + }, fileSystem, ctx); } } diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs index 4ddc90eeba..31f1eda6a2 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs @@ -2,11 +2,13 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Assembler.Sourcing; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; @@ -51,17 +53,14 @@ IReadOnlySet availableExporters Branch = checkout.Repository.GetBranch(env.ContentSource) }; - var buildContext = new BuildContext( - context.Collector, - context.ReadFileSystem, - context.WriteFileSystem, - configurationContext, - availableExporters, - path, - output, - gitConfiguration - ) + var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions { + Output = output, + Git = gitConfiguration, + }); + var buildContext = new BuildContext(context.Collector, docFs, configurationContext) + { + AvailableExporters = availableExporters, UrlPathPrefix = env.PathPrefix, Force = true, AllowIndexing = env.AllowIndexing, diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs index 18c621c8ad..42a9df636e 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationService.cs @@ -7,9 +7,9 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Assembler.Navigation; @@ -17,12 +17,12 @@ public class GlobalNavigationService( ILoggerFactory logFactory, AssemblyConfiguration configuration, IConfigurationContext configurationContext, - ScopedFileSystem fileSystem + CheckoutsFileSystem fileSystem ) : IService { public async Task Validate(IDiagnosticsCollector collector, Cancel ctx) { - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); var namespaceChecker = new NavigationPrefixChecker(logFactory, assembleContext); var navigationFileInfo = assembleContext.ConfigurationFileProvider.NavigationFile; @@ -40,7 +40,7 @@ public async Task Validate(IDiagnosticsCollector collector, Cancel ctx) public async Task ValidateLocalLinkReference(IDiagnosticsCollector collector, string? file, Cancel ctx) { file ??= ".artifacts/docs/html/links.json"; - var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); + var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem); var root = fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); var repository = GitCheckoutInformationFactory.Create(root, fileSystem, logFactory.CreateLogger(nameof(GitCheckoutInformation))).RepositoryName diff --git a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs index f3522d25c1..4927842002 100644 --- a/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs +++ b/src/services/Elastic.Documentation.Assembler/Sourcing/AssemblerCloneService.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -25,8 +26,8 @@ public async Task CloneAll(IDiagnosticsCollector collector, AssemblerClone var githubEnvironmentInput = githubActionsService.GetInput("environment"); var environment = options.Environment ?? (!string.IsNullOrEmpty(githubEnvironmentInput) ? githubEnvironmentInput : "dev"); - var fs = FileSystemFactory.RealRead; - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs, fs, null, null); + var cfs = CheckoutsFileSystem.FromWorkingDirectory(); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, cfs); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); _ = await cloner.CloneAll(options.FetchLatest ?? false, options.AssumeCloned ?? false, ctx); diff --git a/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs b/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs index c93e39c568..7ef483a422 100644 --- a/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs +++ b/src/services/Elastic.Documentation.Deploying/Synchronization/IDocsSyncContext.cs @@ -4,7 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Deploying.Synchronization; @@ -14,8 +14,8 @@ namespace Elastic.Documentation.Deploying.Synchronization; /// public interface IDocsSyncContext { - ScopedFileSystem ReadFileSystem { get; } - ScopedFileSystem WriteFileSystem { get; } + CheckoutsFileSystem ReadFileSystem { get; } + DocumentationWriteFileSystem WriteFileSystem { get; } IDirectoryInfo OutputDirectory { get; } IDiagnosticsCollector Collector { get; } diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index f2422aa2b4..67951bf718 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; @@ -23,7 +24,6 @@ using Elastic.Markdown.Page; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; -using static System.StringComparison; namespace Elastic.Documentation.Isolated; @@ -46,9 +46,8 @@ public bool IsStrict(bool? strict) public async Task Build( IDiagnosticsCollector collector, - ScopedFileSystem fileSystem, IsolatedBuildOptions options, - ScopedFileSystem? writeFileSystem = null, + IFileSystem? writeFileSystem = null, Cancel ctx = default ) { @@ -86,8 +85,14 @@ public async Task Build( try { - context = new BuildContext(collector, fileSystem, writeFileSystem ?? fileSystem, configurationContext, exporters, path, output) + var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions { + Output = options.Output?.FullName, + InnerWrite = writeFileSystem + }); + context = new BuildContext(collector, docFs, configurationContext) + { + AvailableExporters = exporters, UrlPathPrefix = pathPrefix, Force = force ?? false, AllowIndexing = allowIndexing ?? false, @@ -97,19 +102,21 @@ public async Task Build( // On CI, we are running on a merge commit which may have changes against an older // docs folder (this can happen on out-of-date PR's). // At some point in the future we can remove this try catch - catch (Exception e) when (runningOnCi && e.Message.StartsWith("Can not locate docset.yml file in", OrdinalIgnoreCase)) + catch (DocumentationPathException e) when (runningOnCi) { // Derive the default output from `path` so it stays within the write FS scope. // Using Paths.WorkingDirectoryRoot would be wrong when --path points to a different repo. var rootFolder = !string.IsNullOrWhiteSpace(path) ? path : Paths.WorkingDirectoryRoot.FullName; - var writeFs = writeFileSystem ?? fileSystem; - var outputDirectory = !string.IsNullOrWhiteSpace(output) - ? writeFs.DirectoryInfo.New(output) - : writeFs.DirectoryInfo.New(Path.Join(rootFolder, ".artifacts/docs/html")); + var fallbackFs = writeFileSystem ?? new FileSystem(); + var outputDirectory = fallbackFs.DirectoryInfo.New(output ?? Path.Join(rootFolder, ".artifacts/docs/html")); // we temporarily do not error when pointed to a non-documentation folder. - _ = writeFs.Directory.CreateDirectory(outputDirectory.FullName); + _ = fallbackFs.Directory.CreateDirectory(outputDirectory.FullName); - _logger.LogInformation("Skipping build as we are running on a merge commit and the docs folder is out of date and has no docset.yml. {Message}", + // Surfaced as a warning (not swallowed at Information level) so that when the underlying + // cause is a real bug — not the stale-merge-commit case this catch was written for — + // the --git-dir remedy in e.Message actually reaches whoever is reading the failed run, + // rather than being buried above a later, unrelated artifact-upload failure. + _logger.LogWarning("Skipping build on CI: {Message} If the docs folder is not actually out of date on a stale merge commit, this indicates a real path-resolution issue.", e.Message); await githubActionsService.SetOutputAsync("skip", "true"); @@ -128,7 +135,7 @@ public async Task Build( else { using var codexReader = context.Configuration.Registry != DocSetRegistry.Public - ? new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData) + ? new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), new ApplicationDataFileSystem()) : null; var crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher( diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs index 8f615612d2..2771b8f9ba 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedIndexService.cs @@ -33,7 +33,7 @@ public async Task Index( var cfg = _configurationContext.Endpoints.Elasticsearch; await ElasticsearchEndpointConfigurator.ApplyAsync(cfg, es, collector, fileSystem, ctx); - return await Build(collector, fileSystem, new IsolatedBuildOptions + return await Build(collector, new IsolatedBuildOptions { Path = path != null ? new DirectoryInfo(path) : null, MetadataOnly = true, diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs index b93e9d3d67..09094129f4 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerAiEnrichCommand.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -43,11 +44,10 @@ public async Task AiEnrich( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.RealRead; - var writeFs = FileSystemFactory.RealWrite; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerAiEnrichService(logFactory, configuration, configurationContext, githubActionsService); serviceInvoker.AddCommand(service, - async (s, col, ctx) => await s.AiEnrich(col, readFs, writeFs, es, environment, bootstrapOnly, ctx) + async (s, col, ctx) => await s.AiEnrich(col, fs, es, environment, bootstrapOnly, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs index f89094b7f8..63927b844c 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerCommands.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -62,11 +63,10 @@ public async Task Assemble( static async (s, col, opts, ctx) => await s.CloneAll(col, opts, ctx) ); - var readFs = FileSystemFactory.RealRead; - var writeFs = FileSystemFactory.RealWrite; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var buildService = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(buildService, (buildOptions, readFs, writeFs), buildOptions.Strict ?? false, - static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, state.readFs, state.writeFs, ctx) + serviceInvoker.AddCommand(buildService, (buildOptions, fs), buildOptions.Strict ?? false, + static async (s, col, state, ctx) => await s.BuildAll(col, state.buildOptions, state.fs, ctx) ); var result = await serviceInvoker.InvokeAsync(ct); @@ -146,11 +146,10 @@ public async Task Build( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.RealRead; - var writeFs = FileSystemFactory.RealWrite; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerBuildService(logFactory, assemblyConfiguration, configurationContext, githubActionsService, environmentVariables); - serviceInvoker.AddCommand(service, (options, readFs, writeFs), options.Strict ?? false, - static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.readFs, state.writeFs, ctx) + serviceInvoker.AddCommand(service, (options, fs), options.Strict ?? false, + static async (s, col, state, ctx) => await s.BuildAll(col, state.options, state.fs, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs index 26412a93cc..c384c647be 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerIndexCommand.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -42,11 +43,10 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.RealRead; - var writeFs = FileSystemFactory.RealWrite; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerIndexService(logFactory, configuration, configurationContext, githubActionsService, environmentVariables); serviceInvoker.AddCommand(service, - async (s, col, ctx) => await s.Index(col, readFs, writeFs, es, environment, ctx) + async (s, col, ctx) => await s.Index(col, fs, es, environment, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs index d0e2bbc825..0f0407d18f 100644 --- a/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs +++ b/src/tooling/docs-builder/Commands/Assembler/AssemblerSitemapCommand.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -41,7 +42,7 @@ public async Task Sitemap( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealWrite; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new AssemblerSitemapService(logFactory, configuration, configurationContext, githubActionsService); serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.GenerateSitemapAsync(col, fs, es, environment, ctx) diff --git a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs index e617afd6d5..88491515c4 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ConfigurationCommands.cs @@ -2,12 +2,12 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.IO.Abstractions; using Elastic.Documentation; using Elastic.Documentation.Assembler.Configuration; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -34,8 +34,7 @@ public async Task Init(string? gitRef = null, bool local = false, Cancellat { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; - var service = new ConfigurationCloneService(logFactory, assemblyConfiguration, fs); + var service = new ConfigurationCloneService(logFactory, assemblyConfiguration, CheckoutsFileSystem.FromWorkingDirectory()); serviceInvoker.AddCommand(service, (gitRef, local), static async (s, collector, state, ctx) => await s.InitConfigurationToApplicationData(collector, state.gitRef, state.local, ctx)); return await serviceInvoker.InvokeAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs index 9cfe6be0e3..c19dbdd7d2 100644 --- a/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/ContentSourceCommands.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -30,7 +31,7 @@ public async Task Validate(CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new RepositoryPublishValidationService(logFactory, configuration, configurationContext, fs); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.ValidatePublishStatus(collector, ctx)); @@ -46,7 +47,7 @@ public async Task Match([Argument] string? repository = null, [Argument] st { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var service = new RepositoryBuildMatchingService(logFactory, configuration, configurationContext, githubActionsService, fs); serviceInvoker.AddCommand(service, (repository, branchOrTag), static async (s, collector, state, ctx) => diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index ed1b452197..30d3f661a0 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Deploying; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -43,7 +44,8 @@ public async Task Plan(string environment, string s3BucketName, [ExpandUser { await using var serviceInvoker = new ServiceInvoker(collector); - var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, FileSystemFactory.RealRead, FileSystemFactory.RealWrite, null, null); + var fs = CheckoutsFileSystem.FromWorkingDirectory(); + var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs); var service = new IncrementalDeployService(logFactory, githubActionsService); serviceInvoker.AddCommand(service, (context, s3BucketName, @out, deleteThreshold), static async (s, collector, state, ctx) => await s.Plan(collector, state.context, state.s3BucketName, state.@out?.FullName ?? "", state.deleteThreshold, [], ctx) @@ -64,7 +66,8 @@ public async Task Apply(string environment, string s3BucketName, [Existing, { await using var serviceInvoker = new ServiceInvoker(collector); - var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, FileSystemFactory.RealRead, FileSystemFactory.RealWrite, null, null); + var fs = CheckoutsFileSystem.FromWorkingDirectory(); + var context = new AssembleContext(assemblyConfiguration, configurationContext, environment, collector, fs); var service = new IncrementalDeployService(logFactory, githubActionsService); serviceInvoker.AddCommand(service, (context, s3BucketName, planFile), static async (s, collector, state, ctx) => await s.Apply(collector, state.context, state.s3BucketName, state.planFile.FullName, ctx) @@ -81,8 +84,7 @@ public async Task UpdateRedirects(string environment, [Existing, ExpandUser { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealRead; - var service = new DeployUpdateRedirectsService(logFactory, fs); + var service = new DeployUpdateRedirectsService(logFactory, CheckoutsFileSystem.FromWorkingDirectory()); serviceInvoker.AddCommand(service, (environment, redirectsFile), static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, ctx: ctx) ); diff --git a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs index 7f1f03820e..9a3e877e26 100644 --- a/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/NavigationCommands.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -28,7 +29,7 @@ IConfigurationContext configurationContext public async Task Validate(CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); + var service = new GlobalNavigationService(logFactory, configuration, configurationContext, CheckoutsFileSystem.FromWorkingDirectory()); serviceInvoker.AddCommand(service, static async (s, collector, ctx) => await s.Validate(collector, ctx)); return await serviceInvoker.InvokeAsync(ct); } @@ -39,7 +40,7 @@ public async Task Validate(CancellationToken ct = default) public async Task ValidateLinkReference([Argument, Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? file = null, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new GlobalNavigationService(logFactory, configuration, configurationContext, FileSystemFactory.RealRead); + var service = new GlobalNavigationService(logFactory, configuration, configurationContext, CheckoutsFileSystem.FromWorkingDirectory()); serviceInvoker.AddCommand(service, file, static async (s, collector, file, ctx) => await s.ValidateLocalLinkReference(collector, file?.FullName, ctx)); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 409057eb2b..1d4cd6df3d 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -25,6 +25,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ReleaseNotes; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -48,7 +49,7 @@ IEnvironmentVariables environmentVariables [GeneratedRegex(@"^( *output_directory:\s*).+$", RegexOptions.Multiline)] private static partial Regex BundleOutputDirectoryRegex(); - private readonly IFileSystem _fileSystem = FileSystemFactory.RealRead; + private readonly ChangelogFileSystem _fileSystem = ChangelogFileSystem.FromWorkingDirectory(); private readonly ILogger _logger = logFactory.CreateLogger(); /// Create changelog.yml and the changelog/releases directory structure. /// @@ -357,7 +358,7 @@ public async Task Add( var repoArg = resolvedRepo.Contains('/') ? resolvedRepo : $"{resolvedOwner}/{resolvedRepo}"; IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); IGitHubPrService prService = new GitHubPrService(logFactory); - var releaseChangelogService = new GitHubReleaseChangelogService(logFactory, configurationContext, releaseService, prService); + var releaseChangelogService = new GitHubReleaseChangelogService(logFactory, configurationContext, _fileSystem, releaseService, prService); var releaseInput = new CreateChangelogsFromReleaseArguments { @@ -377,7 +378,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c } IGitHubPrService githubPrService = new GitHubPrService(logFactory); - var service = new ChangelogCreationService(logFactory, configurationContext, githubPrService, env: SystemEnvironmentVariables.Instance); + var service = new ChangelogCreationService(logFactory, configurationContext, _fileSystem, githubPrService, env: SystemEnvironmentVariables.Instance); // Parse PRs: promotion report (--report), or comma-separated values and file paths (--prs) string[]? parsedPrs = null; @@ -388,7 +389,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c !reportSource.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) reportSource = NormalizePath(reportSource); - var reportParser = new PromotionReportParser(logFactory, null); + var reportParser = new PromotionReportParser(logFactory, _fileSystem); parsedPrs = await reportParser.ParseReportToPrUrlsAsync(collector, reportSource, ctx); if (parsedPrs == null) { @@ -615,7 +616,7 @@ public async Task Bundle( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogBundlingService(logFactory, configurationContext); + var service = new ChangelogBundlingService(logFactory, _fileSystem, configurationContext); var isProfileMode = !string.IsNullOrWhiteSpace(profile); var isGitRefMode = !string.IsNullOrWhiteSpace(startGitRef) || !string.IsNullOrWhiteSpace(endGitRef); @@ -1008,7 +1009,7 @@ public async Task Remove( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogRemoveService(logFactory, configurationContext); + var service = new ChangelogRemoveService(logFactory, _fileSystem, configurationContext); var isProfileMode = !string.IsNullOrWhiteSpace(profile); @@ -1224,7 +1225,7 @@ public async Task Render( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogRenderingService(logFactory, configurationContext); + var service = new ChangelogRenderingService(logFactory, _fileSystem, configurationContext); var allFeatureIds = ExpandCommaSeparated(hideFeatures); @@ -1297,7 +1298,7 @@ public async Task GhRelease( IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); IGitHubPrService prService = new GitHubPrService(logFactory); - var service = new GitHubReleaseChangelogService(logFactory, configurationContext, releaseService, prService); + var service = new GitHubReleaseChangelogService(logFactory, configurationContext, _fileSystem, releaseService, prService); // Validate release date format if provided if (!string.IsNullOrWhiteSpace(releaseDate) && !DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out _)) @@ -1348,7 +1349,7 @@ public async Task BundleAmend( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogBundleAmendService(logFactory, configurationContext: configurationContext); + var service = new ChangelogBundleAmendService(logFactory, _fileSystem, configurationContext: configurationContext); var normalizedAddFiles = add != null ? ExpandCommaSeparated(add).Select(NormalizePath).ToList() @@ -1425,7 +1426,7 @@ public async Task EvaluatePr( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var fileSystem = FileSystemFactory.RealReadForRunnerTemp(environmentVariables); + var fileSystem = RunnerTempFileSystem.ForEvaluatePr(environmentVariables); IGitHubPrService prService = new GitHubPrService(logFactory); var service = new ChangelogPrEvaluationService(logFactory, configurationContext, prService, githubActionsService, fileSystem); @@ -1515,7 +1516,7 @@ public async Task PrepareArtifact( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealGitRootForPathWrite(null, outputDir); + var fs = RunnerTempFileSystem.ForPrepareArtifact(stagingDir, outputDir); var service = new ChangelogPrepareArtifactService(logFactory, configurationContext, githubActionsService, fs); var args = new PrepareArtifactArguments @@ -1564,7 +1565,7 @@ public async Task EvaluateArtifact( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealGitRootForPathWrite(null, metadata); + var fs = RunnerTempFileSystem.ForEvaluateArtifact(metadata); IGitHubPrService prService = new GitHubPrService(logFactory); var service = new ChangelogArtifactEvaluationService(logFactory, prService, githubActionsService, fs); @@ -1691,7 +1692,7 @@ public async Task Upload( var (resolvedRepo, resolvedOwner, resolvedBranch) = await ResolveUploadRepoOwnerBranch(repo, owner, branch, resolvedConfig, resolvedDirectory, ctx); await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ChangelogUploadService(logFactory, configurationContext); + var service = new ChangelogUploadService(logFactory, _fileSystem, configurationContext); var args = new ChangelogUploadArguments { ArtifactType = parsedArtifactType, @@ -1797,7 +1798,7 @@ public async Task MigrateFromWeb( return 1; } - var service = new WebMigrationService(logFactory, FileSystemFactory.RealWrite); + var service = new WebMigrationService(logFactory, CheckoutsFileSystem.FromWorkingDirectory().Write); var args = new MigrateFromWebArguments { Products = ExpandCommaSeparated(products), diff --git a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs index cb11145b51..1bc343111f 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexCommands.cs @@ -12,6 +12,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Isolated; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Services; @@ -58,14 +59,11 @@ public async Task CloneAndBuild( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); - var writeFs = FileSystemFactory.RealGitRootForPathWrite(null, output?.FullName); - - var configFile = readFs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) + var fs = new CodexFileSystem(config.FullName, output?.FullName); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, writeFs, null, output?.FullName); + var codexContext = new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs, null, output?.FullName); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -80,12 +78,12 @@ public async Task CloneAndBuild( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, readFs), strict, + serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, async (s, col, state, c) => { if (state.cloneResult == null) return false; - var result = await s.BuildAll(state.codexContext, state.cloneResult, state.readFs, c); + var result = await s.BuildAll(state.codexContext, state.cloneResult, state.fs, c); return result.DocumentationSets.Count > 0; }); @@ -116,13 +114,11 @@ public async Task Clone( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); - - var configFile = readFs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) + var fs = new CodexFileSystem(config.FullName); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, FileSystemFactory.RealWrite, null, null); + var codexContext = new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs); using var linkIndexReader = new GitLinkIndexReader(environment); var cloneService = new CodexCloneService(logFactory, linkIndexReader); @@ -150,14 +146,11 @@ public async Task Build( CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); - var writeFs = FileSystemFactory.RealGitRootForPathWrite(null, output?.FullName); - - var configFile = readFs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out _)) + var fs = new CodexFileSystem(config.FullName, output?.FullName); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out _)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, writeFs, null, output?.FullName); + var codexContext = new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs, null, output?.FullName); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); if (cloneResult == null || cloneResult.Checkouts.Count == 0) @@ -168,10 +161,10 @@ public async Task Build( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var buildService = new CodexBuildService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, readFs), strict, + serviceInvoker.AddCommand(buildService, (codexContext, cloneResult, fs), strict, async (s, col, state, c) => { - var result = await s.BuildAll(state.codexContext, state.cloneResult, state.readFs, c); + var result = await s.BuildAll(state.codexContext, state.cloneResult, state.fs, c); return result.DocumentationSets.Count > 0; }); @@ -186,8 +179,7 @@ public async Task Build( [NoOptionsInjection] public async Task Serve(int port = 4000, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, CancellationToken ct = default) { - var fs = FileSystemFactory.RealRead; - var servePath = path?.FullName ?? fs.Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); + var servePath = path?.FullName ?? Path.Join(Environment.CurrentDirectory, ".artifacts", "codex", "docs"); var host = new StaticWebHost(port, servePath); await host.RunAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs index 72f770940a..95cb649d94 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexIndexCommand.cs @@ -5,13 +5,13 @@ using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using Actions.Core.Services; - using Elastic.Codex; using Elastic.Codex.Indexing; using Elastic.Codex.Sourcing; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -44,12 +44,11 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); - var configFile = readFs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig, out var environment)) + var fs = new CodexFileSystem(config.FullName); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig, out var environment)) return 1; - var codexContext = new CodexContext(codexConfig, configFile, collector, readFs, FileSystemFactory.RealWrite, null, null); + var codexContext = new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs); var cloneResult = await CodexCloneService.DiscoverCheckouts(codexContext, logFactory, ct); @@ -61,9 +60,9 @@ public async Task Index( var isolatedBuildService = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); var service = new CodexIndexService(logFactory, configurationContext, isolatedBuildService); - serviceInvoker.AddCommand(service, (codexContext, cloneResult, readFs, es), + serviceInvoker.AddCommand(service, (codexContext, cloneResult, fs, es), static async (s, col, state, c) => - await s.Index(state.codexContext, state.cloneResult, state.readFs, state.es, c) + await s.Index(state.codexContext, state.cloneResult, state.fs, state.es, c) ); return await serviceInvoker.InvokeAsync(ct); diff --git a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs index cbef92df6d..ad319adf44 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexSyncCommand.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.ComponentModel.DataAnnotations; +using System.IO.Abstractions; using Actions.Core.Services; using Elastic.Codex; using Elastic.Documentation; @@ -10,6 +11,7 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Deploying; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -85,10 +87,9 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex private (CodexContext context, IncrementalDeployService service) LoadContext(FileInfo config) { - var fs = FileSystemFactory.RealRead; - var configFile = fs.FileInfo.New(config.FullName); - var codexConfig = CodexConfiguration.Load(configFile); - return (new CodexContext(codexConfig, configFile, collector, fs, FileSystemFactory.RealWrite, null, null), + var fs = new CodexFileSystem(config.FullName); + var codexConfig = CodexConfiguration.Load(fs.ConfigurationFile); + return (new CodexContext(codexConfig, fs.ConfigurationFile, collector, fs), new IncrementalDeployService(logFactory, githubActionsService)); } } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index b5027a11c5..048dd20595 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Assembler.Deploying; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.Argh; @@ -35,9 +36,8 @@ public async Task UpdateRedirects( { await using var serviceInvoker = new ServiceInvoker(collector); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(new FileSystem(), [Paths.FindGitRoot(config.FullName)]); - var configFile = readFs.FileInfo.New(config.FullName); - if (!CodexConfigurationLoader.TryLoad(configFile, config.FullName, collector, out var codexConfig)) + var fs = new CodexFileSystem(config.FullName); + if (!CodexConfigurationLoader.TryLoad(fs.ConfigurationFile, config.FullName, collector, out var codexConfig)) return 1; var resolvedEnvironment = environment @@ -45,7 +45,7 @@ public async Task UpdateRedirects( ?? Environment.GetEnvironmentVariable("ENVIRONMENT") ?? "internal"; - var service = new DeployUpdateRedirectsService(logFactory, readFs); + var service = new DeployUpdateRedirectsService(logFactory, fs); serviceInvoker.AddCommand(service, (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"), static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, c) ); diff --git a/src/tooling/docs-builder/Commands/DiffCommands.cs b/src/tooling/docs-builder/Commands/DiffCommands.cs index e6196a3ca4..6a7f0d216f 100644 --- a/src/tooling/docs-builder/Commands/DiffCommands.cs +++ b/src/tooling/docs-builder/Commands/DiffCommands.cs @@ -6,6 +6,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Refactor.Tracking; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -32,7 +33,7 @@ public async Task Validate(string? path = null, CancellationToken ct = defa await using var serviceInvoker = new ServiceInvoker(collector); var service = new LocalChangeTrackingService(logFactory, configurationContext); - var fs = FileSystemFactory.RealGitRootForPath(path); + var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); serviceInvoker.AddCommand(service, (path, fs), async static (s, collector, state, _) => await s.ValidateRedirects(collector, state.path, state.fs) diff --git a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs index e208eea64f..0429771102 100644 --- a/src/tooling/docs-builder/Commands/InboundLinkCommands.cs +++ b/src/tooling/docs-builder/Commands/InboundLinkCommands.cs @@ -7,6 +7,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.InboundLinks; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -24,7 +25,7 @@ namespace Documentation.Builder.Commands; /// internal sealed class InboundLinkCommands(ILoggerFactory logFactory, IDiagnosticsCollector collector) { - private readonly LinkIndexService _linkIndexService = new(logFactory, FileSystemFactory.RealRead); + private readonly LinkIndexService _linkIndexService = new(logFactory, CheckoutsFileSystem.FromWorkingDirectory()); /// Validate all cross-links across every published links.json in the registry. [NoOptionsInjection] diff --git a/src/tooling/docs-builder/Commands/IndexCommand.cs b/src/tooling/docs-builder/Commands/IndexCommand.cs index 34e1489d10..35bfad9f4d 100644 --- a/src/tooling/docs-builder/Commands/IndexCommand.cs +++ b/src/tooling/docs-builder/Commands/IndexCommand.cs @@ -6,6 +6,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Isolated; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -40,7 +41,7 @@ public async Task Index( ) { await using var serviceInvoker = new ServiceInvoker(collector); - var fs = FileSystemFactory.RealGitRootForPath(path); + var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); var service = new IsolatedIndexService(logFactory, configurationContext, githubActionsService, environmentVariables); serviceInvoker.AddCommand(service, async (s, col, ctx) => await s.Index(col, fs, es, path, ctx) diff --git a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs index cc9d478428..32c61c2227 100644 --- a/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs +++ b/src/tooling/docs-builder/Commands/IsolatedBuildCommand.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using Actions.Core.Services; using Elastic.Documentation; using Elastic.Documentation.Configuration; @@ -42,12 +43,11 @@ public async Task Build( await using var serviceInvoker = new ServiceInvoker(collector); var service = new IsolatedBuildService(logFactory, configurationContext, githubActionsService, environmentVariables); - var readFs = inMemory ? FileSystemFactory.InMemory() : FileSystemFactory.RealGitRootForPath(options.Path?.FullName); - var writeFs = inMemory ? null : FileSystemFactory.RealGitRootForPathWrite(options.Path?.FullName, options.Output?.FullName); + IFileSystem? writeFs = inMemory ? new MockFileSystem() : null; var strictCommand = service.IsStrict(options.Strict); - serviceInvoker.AddCommand(service, (options, readFs, writeFs), strictCommand, - static async (s, col, state, ctx) => await s.Build(col, state.readFs, state.options, state.writeFs, ctx) + serviceInvoker.AddCommand(service, (options, writeFs), strictCommand, + static async (s, col, state, ctx) => await s.Build(col, state.options, state.writeFs, ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/MoveCommand.cs b/src/tooling/docs-builder/Commands/MoveCommand.cs index 2f5ac7a901..a347fc2f27 100644 --- a/src/tooling/docs-builder/Commands/MoveCommand.cs +++ b/src/tooling/docs-builder/Commands/MoveCommand.cs @@ -6,6 +6,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Refactor; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -40,7 +41,7 @@ public async Task Move( await using var serviceInvoker = new ServiceInvoker(collector); var service = new MoveFileService(logFactory, configurationContext); - var fs = FileSystemFactory.RealGitRootForPath(path); + var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); serviceInvoker.AddCommand(service, (source, target, dryRun, path, fs), async static (s, collector, state, ctx) => await s.Move(collector, state.source, state.target, state.dryRun, state.path, state.fs, ctx) @@ -73,7 +74,7 @@ public async Task Format( await using var serviceInvoker = new ServiceInvoker(collector); var service = new FormatService(logFactory, configurationContext); - var fs = FileSystemFactory.RealGitRootForPath(path); + var fs = DocumentationFileSystem.Resolve(path ?? Paths.WorkingDirectoryRoot.FullName); serviceInvoker.AddCommand(service, (path, check, fs), async static (s, collector, state, ctx) => await s.Format(collector, state.path, state.check, state.fs, ctx) diff --git a/src/tooling/docs-builder/Commands/ServeCommand.cs b/src/tooling/docs-builder/Commands/ServeCommand.cs index 0d2ff3afa3..9c4b759957 100644 --- a/src/tooling/docs-builder/Commands/ServeCommand.cs +++ b/src/tooling/docs-builder/Commands/ServeCommand.cs @@ -25,7 +25,7 @@ internal sealed class ServeCommand(ILoggerFactory logFactory, IConfigurationCont [CommandName("serve")] public async Task Serve(GlobalCliOptions _, [Existing, ExpandUserProfile, RejectSymbolicLinks] DirectoryInfo? path = null, int port = 3000, bool watch = false, CancellationToken ct = default) { - var host = new DocumentationWebHost(logFactory, path?.FullName, port, FileSystemFactory.RealGitRootForPath(path?.FullName), FileSystemFactory.InMemoryForPath(path?.FullName), configurationContext, watch); + var host = new DocumentationWebHost(logFactory, path?.FullName, port, configurationContext, watch); await host.RunAsync(ct); _logger.LogInformation("Find your documentation at http://localhost:{Port}/{Path}", port, host.GeneratorState.Generator.DocumentationSet.FirstInterestingUrl.TrimStart('/') diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 57d80c0baa..2dee21bcc7 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.Net; using Nullean.ScopedFileSystem; using System.Runtime.InteropServices; @@ -15,6 +16,7 @@ using Elastic.Documentation.Api; #endif using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.ServiceDefaults; using Elastic.Documentation.Site.FileProviders; using Elastic.Markdown.IO; @@ -44,13 +46,10 @@ public class DocumentationWebHost public DocumentationWebHost(ILoggerFactory logFactory, string? path, int port, - ScopedFileSystem readFs, - ScopedFileSystem writeFs, IConfigurationContext configurationContext, bool isWatchBuild ) { - _writeFileSystem = writeFs; var builder = WebApplication.CreateSlimBuilder(); _ = builder.AddDocumentationServiceDefaults(); @@ -71,7 +70,9 @@ bool isWatchBuild var hostUrl = $"http://localhost:{port}"; _hostedService = collector; - Context = new BuildContext(collector, readFs, writeFs, configurationContext, ExportOptions.Default, path, null) + var docFs = DocumentationFileSystem.Resolve(path, new DocumentationScopeOptions { InnerWrite = new MockFileSystem() }); + _writeFileSystem = docFs.Write; + Context = new BuildContext(collector, docFs, configurationContext) { CanonicalBaseUrl = new Uri(hostUrl), }; diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs index d882cd195e..c1d2463259 100644 --- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs +++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.Text.Json.Serialization; using System.Threading.Channels; using Actions.Core; @@ -14,7 +15,6 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Isolated; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Documentation.Builder.Http; @@ -62,7 +62,7 @@ public class InMemoryBuildState(ILoggerFactory loggerFactory, IConfigurationCont // Initialized lazily on first ExecuteBuildAsync so we can scope it to the source path. // Exposed so the serve host can read pagefind index files written by the background build. private string? _writeFsPath; - public ScopedFileSystem? WriteFileSystem { get; private set; } + public IFileSystem? WriteFileSystem { get; private set; } // Broadcast: maintain list of connected client channels private readonly Lock _clientsLock = new(); @@ -164,10 +164,9 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) // Create a diagnostics collector that streams to our channel var streamingCollector = new StreamingDiagnosticsCollector(_loggerFactory, this); - var readFs = FileSystemFactory.RealGitRootForPath(sourcePath); if (WriteFileSystem is null || _writeFsPath != sourcePath) { - WriteFileSystem = FileSystemFactory.InMemoryForPath(sourcePath); + WriteFileSystem = new MockFileSystem(); _writeFsPath = sourcePath; } var service = new IsolatedBuildService(_loggerFactory, _configurationContext, new NullCoreService(), SystemEnvironmentVariables.Instance); @@ -176,7 +175,6 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) _ = await service.Build( streamingCollector, - readFs, new IsolatedBuildOptions { Path = new DirectoryInfo(sourcePath), diff --git a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs index 43776a17e4..1097b5723e 100644 --- a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs +++ b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs @@ -49,8 +49,7 @@ public async Task StartAsync(Cancel cancellationToken) _serviceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Await the live-reload generator so the server can serve pages immediately. - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName - ?? ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; await ReloadableGenerator.ReloadAsync(cancellationToken); // Start the build loop; only shutdownCt (Ctrl+C / app exit) can cancel a running build. @@ -58,13 +57,7 @@ public async Task StartAsync(Cancel cancellationToken) _backgroundBuildTask = InMemoryBuildState.RunAsync(_serviceCts.Token); InMemoryBuildState.ScheduleBuild(sourcePath); - // ReSharper disable once RedundantAssignment - var directory = ReloadableGenerator.Generator.DocumentationSet.SourceDirectory.FullName; -#if DEBUG - // Fall back to source directory when there is no separate checkout directory (e.g. when serving the project's own docs from a worktree) - directory = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName - ?? ReloadableGenerator.Generator.DocumentationSet.SourceDirectory.FullName; -#endif + var directory = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; Logger.LogInformation("Start file watch on: {Directory}", directory); var watcher = new FileSystemWatcher(directory) { @@ -108,8 +101,7 @@ private void Reload(bool reloadConfiguration = false) // Schedule a validation build after every reload — both content edits and structural changes. // The build loop coalesces rapid triggers: a new request while a build runs queues one more. - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName - ?? ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory.FullName; InMemoryBuildState.ScheduleBuild(sourcePath); }, token); } diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 6ed55dd6bb..7e667d6600 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.LinkIndex; using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown; @@ -47,7 +48,7 @@ bool isWatchBuild ApiPath = context.WriteFileSystem.DirectoryInfo.New(Path.Join(outputPath.FullName, "api")); if (context.Configuration.Registry != DocSetRegistry.Public) - _codexReader = new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData); + _codexReader = new GitLinkIndexReader(context.Configuration.Registry.ToStringFast(true), new ApplicationDataFileSystem()); _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); // we pass NoopCrossLinkResolver.Instance here because `ReloadAsync` will always be called when the is started. @@ -80,7 +81,7 @@ public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true) _context.ReloadConfiguration(); (_codexReader as IDisposable)?.Dispose(); _codexReader = _context.Configuration.Registry != DocSetRegistry.Public - ? new GitLinkIndexReader(_context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData) + ? new GitLinkIndexReader(_context.Configuration.Registry.ToStringFast(true), new ApplicationDataFileSystem()) : null; _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(_logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); } diff --git a/src/tooling/docs-builder/Http/StaticWebHost.cs b/src/tooling/docs-builder/Http/StaticWebHost.cs index a0d99a67b8..9b2638242e 100644 --- a/src/tooling/docs-builder/Http/StaticWebHost.cs +++ b/src/tooling/docs-builder/Http/StaticWebHost.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Api; #endif using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Extensions; using Elastic.Documentation.ServiceDefaults; using Microsoft.AspNetCore.Builder; @@ -25,7 +26,7 @@ public class StaticWebHost public StaticWebHost(int port, string? path) { _contentRoot = path ?? Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); - var fs = FileSystemFactory.RealGitRootForPath(_contentRoot); + var fs = CheckoutsFileSystem.FromWorkingDirectory(); var dir = fs.DirectoryInfo.New(_contentRoot); if (!dir.Exists) throw new Exception($"Can not serve empty directory: {_contentRoot}"); diff --git a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs index 35670eadd2..cdf10b745b 100644 --- a/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs +++ b/src/tooling/docs-builder/Middleware/CheckForUpdatesMiddleware.cs @@ -6,6 +6,7 @@ using System.Reflection; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; using Nullean.Argh.Middleware; @@ -15,7 +16,7 @@ namespace Documentation.Builder.Middleware; internal sealed class CheckForUpdatesMiddleware(ILogger logger) : ICommandMiddleware { // Only accesses ApplicationData — no workspace access needed - private static readonly IFileSystem Fs = FileSystemFactory.AppData; + private static readonly ApplicationDataFileSystem Fs = new(); private readonly IFileInfo _stateFile = Fs.FileInfo.New(Path.Join(Paths.ApplicationData.FullName, "docs-build-check.state")); private readonly ILogger _logger = logger; diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 6008c930fa..0c7ba73650 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -8,8 +8,8 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.IntegrationTests; @@ -26,11 +26,11 @@ public PublicOnlyAssemblerConfigurationTests() FileSystem.Path.Join(Paths.GetSolutionDirectory()!.FullName, ".artifacts", "checkouts") ); Collector = new DiagnosticsCollector([]); - var configurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, FileSystem, skipPrivateRepositories: true); + var configurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(FileSystem), skipPrivateRepositories: true); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem, configurationFileProvider: configurationFileProvider); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs, CheckoutDirectory.FullName, null); } [Fact] @@ -66,8 +66,8 @@ public AssemblerConfigurationTests(DocumentationFixture fixture, ITestOutputHelp Collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); + var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs2, CheckoutDirectory.FullName, null); } [Fact] diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs index 5f90a5d00d..096528a084 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/DocsSyncTests.cs @@ -13,11 +13,11 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Integrations.S3; using Elastic.Documentation.ServiceDefaults.Telemetry; using FakeItEasy; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; using OpenTelemetry; using OpenTelemetry.Trace; @@ -46,9 +46,8 @@ public async Task TestPlan() var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs, scopedWriteFs, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); A.CallTo(() => mockS3Client.ListObjectsV2Async(A._, A._)) .Returns(new ListObjectsV2Response { @@ -189,9 +188,8 @@ bool valid var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs2 = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs2 = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs2, scopedWriteFs2, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); + var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2, null, Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly")); var s3Objects = new List(); foreach (var i in Enumerable.Range(0, remoteFiles)) @@ -241,9 +239,8 @@ public async Task TestApply() var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var checkoutDirectory = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "assembly"); - var scopedFs3 = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var scopedWriteFs3 = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs3, scopedWriteFs3, null, checkoutDirectory); + var assembleFs3 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs3); var plan = new SyncPlan { RemoteListingCompleted = true, diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs index 5facecbe6a..b48d0f139c 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/IncrementalDeployRoundTripTests.cs @@ -16,10 +16,10 @@ using Elastic.Documentation.Deploying; using Elastic.Documentation.Deploying.Synchronization; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Integrations.S3; using FakeItEasy; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.IntegrationTests; @@ -47,9 +47,8 @@ public async Task AssemblerRoundTrip() var configurationContext = TestHelpers.CreateConfigurationContext(fs); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new DiagnosticsCollector([]); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fs); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFs, scopedWriteFs, null, outputDir); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs, null, outputDir); await RunRoundTrip(fs, s3, xfer, gh, svc, context, outputDir); } @@ -60,13 +59,12 @@ public async Task CodexRoundTrip() var outputDir = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts", "codex", "docs"); var (fs, s3, xfer, gh, svc) = Arrange(outputDir); var collector = new DiagnosticsCollector([]); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fs); + var codexFs = CheckoutsFileSystem.FromWorkingDirectory(fs); // CodexContext only stores configurationPath — it never reads from it — // so we can point to any path without adding it to the mock FS. var codexConfig = new CodexConfiguration { Environment = "dev" }; var configFile = fs.FileInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); - var context = new CodexContext(codexConfig, configFile, collector, scopedFs, scopedWriteFs, null, outputDir); + var context = new CodexContext(codexConfig, configFile, collector, codexFs, null, outputDir); await RunRoundTrip(fs, s3, xfer, gh, svc, context, outputDir); } @@ -227,11 +225,10 @@ public async Task ExcludedRemoteObjectsAreNotDeleted() var svc = new IncrementalDeployService(new LoggerFactory(), gh, s3, xfer, etagCalculator); var collector = new DiagnosticsCollector([]); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var scopedWriteFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fs); + var codexFs2 = CheckoutsFileSystem.FromWorkingDirectory(fs); var codexConfig = new CodexConfiguration { Environment = "dev" }; var configFile = fs.FileInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); - var context = new CodexContext(codexConfig, configFile, collector, scopedFs, scopedWriteFs, null, outputDir); + var context = new CodexContext(codexConfig, configFile, collector, codexFs2, null, outputDir); var planPath = Path.Join(outputDir, "sync-plan.json"); var planOk = await svc.Plan( diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs index 5f34d33929..6b0050e00b 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated.Leaf; @@ -21,7 +22,6 @@ using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Nullean.ScopedFileSystem; using RazorSlices; namespace Elastic.Documentation.IntegrationTests; @@ -46,7 +46,9 @@ public async Task AssertRealNavigation() var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fs), FileSystemFactory.ScopeCurrentWorkingDirectory(new MockFileSystem()), null, null); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, + assembleFs); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs index 00e9624f6a..61f5c36780 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/NavigationRootTests.cs @@ -13,6 +13,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; @@ -21,7 +22,6 @@ using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Nullean.ScopedFileSystem; using RazorSlices; namespace Elastic.Documentation.IntegrationTests; @@ -46,7 +46,9 @@ public async Task AssertRealNavigation() var assemblyConfiguration = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); var collector = new TestDiagnosticsCollector(TestContext.Current.TestOutputHelper); var fs = new FileSystem(); - var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fs), FileSystemFactory.ScopeCurrentWorkingDirectory(new MockFileSystem()), null, null); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(fs); + var assembleContext = new AssembleContext(assemblyConfiguration, configurationContext, "dev", collector, + assembleFs); var logFactory = new TestLoggerFactory(TestContext.Current.TestOutputHelper); var cloner = new AssemblerRepositorySourcer(logFactory, assembleContext); var checkoutResult = cloner.GetAll(); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs index f3cb9a098c..3afa2bfdc1 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/SiteNavigationTests.cs @@ -11,11 +11,11 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Assembler; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.IntegrationTests; @@ -44,8 +44,8 @@ public SiteNavigationTests(DocumentationFixture fixture, ITestOutputHelper outpu Collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem); - Context = new AssembleContext(config, configurationContext, "dev", Collector, scopedFs, scopedFs, CheckoutDirectory.FullName, null); + var assembleFs = CheckoutsFileSystem.FromWorkingDirectory(FileSystem); + Context = new AssembleContext(config, configurationContext, "dev", Collector, assembleFs, CheckoutDirectory.FullName, null); } private Checkout CreateCheckout(IFileSystem fs, Repository repository) @@ -98,8 +98,8 @@ public async Task ReadAllPathPrefixes() var fileSystem = new FileSystem(); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem); - var context = new AssembleContext(config, configurationContext, "dev", collector, scopedFileSystem, scopedFileSystem, null, null); + var assembleFs2 = CheckoutsFileSystem.FromWorkingDirectory(fileSystem); + var context = new AssembleContext(config, configurationContext, "dev", collector, assembleFs2); var navigationFileInfo = configurationContext.ConfigurationFileProvider.NavigationFile; var siteNavigationFile = SiteNavigationFile.Deserialize(await FileSystem.File.ReadAllTextAsync(navigationFileInfo.FullName, TestContext.Current.CancellationToken)); @@ -191,8 +191,8 @@ public async Task UriResolving() var fs = new FileSystem(); var configurationContext = TestHelpers.CreateConfigurationContext(fs); var config = AssemblyConfiguration.Create(configurationContext.ConfigurationFileProvider); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, scopedFs, scopedFs, null, null); + var assembleFs3 = CheckoutsFileSystem.FromWorkingDirectory(fs); + var assembleContext = new AssembleContext(config, configurationContext, "prod", collector, assembleFs3); var repos = assembleContext.Configuration.AvailableRepositories .Where(kv => !kv.Value.Skip) .Select(kv => kv.Value) diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs index 7098a012de..26952c33a3 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/TestHelpers.cs @@ -10,6 +10,7 @@ using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging.Abstractions; @@ -24,7 +25,7 @@ public static IConfigurationContext CreateConfigurationContext( ProductsConfiguration? productsConfiguration = null ) { - configurationFileProvider ??= new ConfigurationFileProvider(NullLoggerFactory.Instance, fileSystem, skipPrivateRepositories: true); + configurationFileProvider ??= new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem), skipPrivateRepositories: true); versionsConfiguration ??= new VersionsConfiguration { VersioningSystems = new Dictionary diff --git a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs index e0152cfc1a..d448f4559e 100644 --- a/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs +++ b/tests-integration/Search.IntegrationTests/SearchRelevanceTests.cs @@ -8,6 +8,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Search; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Search; using Elastic.Documentation.Search.Common; using Elastic.Documentation.Search.Contract; @@ -245,7 +246,7 @@ public async Task ExplainTopResultAndExpectedAsyncReturnsDetailedScoring() private static (NavigationSearchService Gateway, ElasticsearchClientAccessor ClientAccessor) CreateFindPageGateway() { var endpoints = ElasticsearchEndpointFactory.Create(buildType: "assembler"); - var configProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new FileSystem(), configurationSource: ConfigurationSource.Embedded); + var configProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(), configurationSource: ConfigurationSource.Embedded); var searchConfig = configProvider.CreateSearchConfiguration(); var clientAccessor = new ElasticsearchClientAccessor(endpoints, searchConfig); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs b/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs index d683d47382..3bbca387f2 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiExplorerFixture.cs @@ -8,10 +8,10 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -40,10 +40,10 @@ public sealed class ApiExplorerFixture : IAsyncLifetime public async ValueTask InitializeAsync() { - var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - // RealGitRootForPath(null) rather than RealRead: it adds the main repo's .git dir as a scope - // root when the checkout is a git worktree, which BuildContext needs to read git information. - Context = new BuildContext(new DiagnosticsCollector([]), FileSystemFactory.RealGitRootForPath(null), configurationContext); + var realFs = new FileSystem(); + var configurationContext = TestHelpers.CreateConfigurationContext(realFs); + var invocation = realFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + Context = new BuildContext(new DiagnosticsCollector([]), DocumentationFileSystem.Resolve(invocation), configurationContext); var fs = new FileSystem(); var path = fs.Path.Combine(AppContext.BaseDirectory, "TestData", "api-explorer-fixture.json"); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs index fbe83eb50d..a85fb3ade7 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiMarkdownIntraApiLinkTests.cs @@ -11,9 +11,9 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Site.FileProviders; using Microsoft.OpenApi; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -37,7 +37,7 @@ public void Render_RewritesGroupAndOperationLinksAgainstCurrentApiBase() var renderer = new CapturingRenderer(); var collector = new DiagnosticsCollector([]); var fs = new FileSystem(); - var context = new BuildContext(collector, FileSystemFactory.RealGitRootForPath(null), TestHelpers.CreateConfigurationContext(fs)); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), TestHelpers.CreateConfigurationContext(fs)); var renderContext = new ApiRenderContext(context, new OpenApiDocument(), new StaticFileContentHashProvider(new EmbeddedOrPhysicalFileProvider(context))) { NavigationHtml = string.Empty, diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index b7d7c416b1..e10b977137 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -13,9 +13,9 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Site; using Elastic.Documentation.Site.FileProviders; -using Nullean.ScopedFileSystem; using RazorSlices; namespace Elastic.ApiExplorer.Tests; @@ -28,7 +28,7 @@ public async Task Render_MarksOnlyCurrentVersionSelected() var fs = new FileSystem(); var context = new BuildContext( new DiagnosticsCollector([]), - FileSystemFactory.RealGitRootForPath(null), + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), TestHelpers.CreateConfigurationContext(fs)); var navigationItem = new LandingNavigationItem("/api/doc/elasticsearch/v9/").Index; var model = new ApiLayoutViewModel diff --git a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs index fd3a8cb620..2a11d229a1 100644 --- a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs @@ -10,8 +10,8 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -25,7 +25,7 @@ public class DashboardOpenApiNavigationTests public async Task CreateNavigation_SingleTagOpenApiSpec_HasSidebarItems() { var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(new DiagnosticsCollector([]), FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(new DiagnosticsCollector([]), DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); var fs = new FileSystem(); var path = fs.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "dashboard-openapi.json"); var fi = fs.FileInfo.New(path); diff --git a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs index 6f41961b19..8fbbce8e50 100644 --- a/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/KibanaApiMarkdownNavigationTests.cs @@ -14,11 +14,11 @@ using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; using Elastic.Documentation.Site.Navigation; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -54,7 +54,7 @@ private static (LandingNavigationItem navigation, SimpleMarkdownNavigationItem i var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext(collector, FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); var doc = OpenApiReader.Instance.ReadAsync(specFile).GetAwaiter().GetResult(); doc.Should().NotBeNull("OpenAPI document should load successfully"); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs index 63cf07b041..6bad9da9f8 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs @@ -13,10 +13,10 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -30,10 +30,9 @@ private static BuildContext CreateContext( ProductsConfiguration? productsConfiguration = null, GitCheckoutInformation? git = null) { - var fs = FileSystemFactory.RealGitRootForPath(null); - return new BuildContext(collector, fs, fs, - TestHelpers.CreateConfigurationContext(new FileSystem(), versionsConfiguration, productsConfiguration), - ExportOptions.Default, null, null, gitCheckoutInformation: git); + return new BuildContext(collector, + DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), new DocumentationScopeOptions { Git = git }), + TestHelpers.CreateConfigurationContext(new FileSystem(), versionsConfiguration, productsConfiguration)); } private static ResolvedApiConfiguration ApiConfig(Product product, IFileInfo? localSpecFile = null) => new() diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs index 61fce88b2f..7eff1c5823 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs @@ -17,10 +17,10 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -34,9 +34,9 @@ private static BuildContext CreateContext( ProductsConfiguration? productsConfiguration = null, GitCheckoutInformation? git = null) { - var fs = FileSystemFactory.RealGitRootForPath(null); - return new BuildContext(collector, fs, fs, TestHelpers.CreateConfigurationContext(new FileSystem(), versionsConfiguration, productsConfiguration), - ExportOptions.Default, null, null, gitCheckoutInformation: git); + return new BuildContext(collector, + DocumentationFileSystem.Resolve(new FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), new DocumentationScopeOptions { Git = git }), + TestHelpers.CreateConfigurationContext(new FileSystem(), versionsConfiguration, productsConfiguration)); } private static ResolvedApiConfiguration ApiConfig( @@ -217,13 +217,17 @@ private static BuildContext CreateGenerateContext( var fs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); fs.AddDirectory(Path.Join(repoRoot, ".git")); fs.AddFile(configPath, new MockFileData(docsetYaml)); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fs); var configurationContext = TestHelpers.CreateConfigurationContext(fs, versionsConfiguration, productsConfiguration); - return new BuildContext(collector, readFs, writeFs, configurationContext, - ExportOptions.Default, source: repoRoot, output: outputRoot, gitCheckoutInformation: git, - configurationFile: readFs.FileInfo.New(configPath)); + return new BuildContext(collector, + DocumentationFileSystem.Resolve(repoRoot, new DocumentationScopeOptions + { + ConfigurationFile = configPath, + Output = outputRoot, + Git = git, + Inner = fs + }), + configurationContext); } private static OpenApiGenerator CreateGenerator(BuildContext context, VersionIndexClient versionIndexClient, IOpenApiSpecificationReader reader) => diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs index 1445f941bc..88672a7060 100644 --- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs @@ -11,8 +11,8 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -52,7 +52,7 @@ public async Task Navigation() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); var openApiDocument = await OpenApiReader.Instance.ReadAsync(LocalSpecFile()); diff --git a/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs b/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs index f04ea80d2f..a8016491c6 100644 --- a/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/TagMetadataTests.cs @@ -12,10 +12,10 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; -using Nullean.ScopedFileSystem; namespace Elastic.ApiExplorer.Tests; @@ -285,7 +285,7 @@ public async Task ApiTag_StableNavigationIds_UsesCanonicalTagName() { var collector = new DiagnosticsCollector([]); var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); - var context = new BuildContext(collector, FileSystemFactory.RealGitRootForPath(null), configurationContext); + var context = new BuildContext(collector, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), configurationContext); var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); diff --git a/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs b/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs index 450c7b2a22..3b03b19924 100644 --- a/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs +++ b/tests/Elastic.ApiExplorer.Tests/TestHelpers.cs @@ -14,6 +14,7 @@ using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging.Abstractions; @@ -51,7 +52,7 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS { Elasticsearch = ElasticsearchEndpoint.Default, }, - ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, fileSystem), + ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem)), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, LegacyUrlMappings = new LegacyUrlMappingConfiguration { Mappings = [] }, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs index 14f8c4ec8c..0faa715f48 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleAmendTests.cs @@ -20,7 +20,7 @@ public class BundleAmendTests : ChangelogTestBase public BundleAmendTests(ITestOutputHelper output) : base(output) { Service = new(LoggerFactory, FileSystem); - BundleService = new(LoggerFactory, null, FileSystem); + BundleService = new(LoggerFactory, FileSystem); _changelogDir = CreateChangelogDir(); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs index 49cacb9969..63b770eae0 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs @@ -74,7 +74,7 @@ public async Task OptionMode_RepoResolvable_SourcesAllEntriesFromRepoPoolOnCdn() // Under the artifact-root layout the CDN entry pool is keyed by the authoring repo, not the // target product. A resolvable repo (here via --repo) is what enables CDN sourcing. var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments @@ -103,7 +103,7 @@ public async Task OptionMode_OwnerAndBranchOverride_SourcesFromThatPoolOnCdn() { // Explicit owner/branch select a specific pool; the branch is stored verbatim (dots kept). var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments @@ -128,7 +128,7 @@ public async Task OptionMode_OwnerFromCombinedRepo_SourcesFromThatPool() // When --repo is given in owner/repo form and no explicit owner is set, the owner segment must be // taken from the repo prefix (not defaulted to elastic), so the CDN pool path stays correct. var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments @@ -166,7 +166,7 @@ await FileSystem.File.WriteAllTextAsync( await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments { Config = configPath, Output = output, All = true }; @@ -202,7 +202,7 @@ await FileSystem.File.WriteAllTextAsync( await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var handler = RegistryHandler(); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, Fetcher(Output, handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(Output, handler)); var output = OutputPath(); var input = new BundleChangelogsArguments @@ -226,7 +226,7 @@ public async Task RegistryFailure_FailsBundle() { var fetcher = new CdnChangelogEntryFetcher(new TestLoggerFactory(Output), new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)), sleep: (_, _) => Task.CompletedTask); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, fetcher); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, fetcher); var input = new BundleChangelogsArguments { @@ -256,7 +256,7 @@ public async Task EntryMissingAfterRetries_FailsBundle() return new HttpResponseMessage(HttpStatusCode.NotFound); // 2-bravo.yaml never propagates }); var fetcher = new CdnChangelogEntryFetcher(new TestLoggerFactory(Output), handler, maxAttempts: 2, sleep: (_, _) => Task.CompletedTask); - var service = new ChangelogBundlingService(LoggerFactory, null, FileSystem, null, fetcher); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, fetcher); var input = new BundleChangelogsArguments { @@ -301,7 +301,7 @@ public async Task ProfileGitHubRelease_ScopesByOutputProductsAndFiltersByRelease A.CallTo(() => releaseService.FetchReleaseAsync("elastic", "elasticsearch", "9.3.0", TestContext.Current.CancellationToken)) .Returns(new GitHubReleaseInfo { TagName = "v9.3.0", Name = "9.3.0", Body = releaseBody }); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, releaseService, Fetcher()); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, releaseService, Fetcher()); var input = new BundleChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 66e03bd316..8f195f8c52 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -8,6 +8,7 @@ using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Changelog.Tests.Changelogs; @@ -20,8 +21,8 @@ public class BundleChangelogsTests : ChangelogTestBase public BundleChangelogsTests(ITestOutputHelper output) : base(output) { - Service = new(LoggerFactory, null, FileSystem); - ServiceWithConfig = new(LoggerFactory, ConfigurationContext, FileSystem); + Service = new(LoggerFactory, FileSystem); + ServiceWithConfig = new(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = CreateChangelogDir(); } @@ -3262,7 +3263,7 @@ public async Task BundleChangelogs_WithProfileMode_MissingConfig_ReturnsErrorWit currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { @@ -3323,7 +3324,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtCurrentDir_LoadsSucce """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { @@ -3384,7 +3385,7 @@ public async Task BundleChangelogs_WithProfileMode_ConfigAtDocsSubdir_LoadsSucce """; await cwdFs.File.WriteAllTextAsync(Path.Join(root, "changelogs/1755268130-feature.yaml"), changelogContent, TestContext.Current.CancellationToken); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); + var service = new ChangelogBundlingService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new BundleChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs index 75f72a0b64..7ba1ab2e2e 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs @@ -55,7 +55,7 @@ public class BundleFilesFilterTests : ChangelogTestBase public BundleFilesFilterTests(ITestOutputHelper output) : base(output) { - ServiceWithConfig = new(LoggerFactory, ConfigurationContext, FileSystem); + ServiceWithConfig = new(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } @@ -281,7 +281,7 @@ public async Task Bundle_WithFilesAndForceLocal_SourcesLocalEvenWhenRepoResolves var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, fetcher); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments @@ -415,7 +415,7 @@ public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn() private ChangelogBundlingService ServiceWithCdn(StubHandler handler) { var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); - return new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + return new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, fetcher); } private async Task WriteRepoOnlyConfigAsync() @@ -449,7 +449,7 @@ public async Task Bundle_WithForceLocal_SourcesLocalDespiteResolvableRepo() var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); var fetcher = new CdnChangelogEntryFetcher(LoggerFactory, handler, sleep: (_, _) => Task.CompletedTask); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, fetcher); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, fetcher); var output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); var input = new BundleChangelogsArguments diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs index eae12dd31b..a3c2373a27 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs @@ -116,7 +116,7 @@ private ChangelogBundlingService Service( StubHandler handler, IGitHubCommitRangeService rangeService, IGitHubPrService? prService = null) => - new(LoggerFactory, ConfigurationContext, FileSystem, null, Fetcher(handler), + new(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(handler), prService ?? A.Fake(), rangeService); [Fact] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs index 75070688ab..bab8772ab3 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundlePlanTests.cs @@ -15,7 +15,7 @@ public class BundlePlanTests : ChangelogTestBase private ChangelogBundlingService Service { get; } public BundlePlanTests(ITestOutputHelper output) : base(output) => - Service = new(LoggerFactory, ConfigurationContext, FileSystem); + Service = new(LoggerFactory, FileSystem, ConfigurationContext); private async Task CreateConfigAsync(string configContent) { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs index ebf213c17e..72347cffa2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleProfileGitHubReleaseTests.cs @@ -23,7 +23,7 @@ public class BundleProfileGitHubReleaseTests : ChangelogTestBase public BundleProfileGitHubReleaseTests(ITestOutputHelper output) : base(output) { _mockReleaseService = A.Fake(); - _service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, _mockReleaseService); + _service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, _mockReleaseService); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs index e130c3cc5e..557b5423b2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleReleaseVersionTests.cs @@ -26,7 +26,7 @@ public class BundleReleaseVersionTests : ChangelogTestBase public BundleReleaseVersionTests(ITestOutputHelper output) : base(output) { - _bundlingService = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem); + _bundlingService = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs index 9ea8290e30..27ebb30c73 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogRemoveTests.cs @@ -6,6 +6,7 @@ using Elastic.Changelog.Bundling; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Tests.Changelogs; @@ -71,8 +72,8 @@ public class ChangelogRemoveTests : ChangelogTestBase public ChangelogRemoveTests(ITestOutputHelper output) : base(output) { - Service = new ChangelogRemoveService(LoggerFactory, null, FileSystem); - ServiceWithConfig = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, FileSystem); + Service = new ChangelogRemoveService(LoggerFactory, FileSystem); + ServiceWithConfig = new ChangelogRemoveService(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = CreateChangelogDir(); } @@ -457,7 +458,7 @@ public async Task Remove_WithProfileMode_MissingConfig_ReturnsErrorWithAdvice() currentDirectory: "/empty-project" ); cwdFs.Directory.CreateDirectory("/empty-project"); - var service = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, FileSystemFactory.ScopeCurrentWorkingDirectory(cwdFs)); + var service = new ChangelogRemoveService(LoggerFactory, ChangelogFileSystem.FromWorkingDirectory(cwdFs), ConfigurationContext); var input = new ChangelogRemoveArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs index 4a3254c947..94d406e1e2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogTestBase.cs @@ -11,16 +11,17 @@ using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Tests.Changelogs; public abstract class ChangelogTestBase : IDisposable { - protected ScopedFileSystem FileSystem { get; } + protected ChangelogFileSystem FileSystem { get; } + protected RunnerTempFileSystem RunnerTempFileSystem { get; } protected IConfigurationContext ConfigurationContext { get; } protected TestDiagnosticsCollector Collector { get; } protected ILoggerFactory LoggerFactory { get; } @@ -30,7 +31,14 @@ protected ChangelogTestBase(ITestOutputHelper output) { Output = output; var mockFileSystem = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - FileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem); + FileSystem = ChangelogFileSystem.FromWorkingDirectory(mockFileSystem); + RunnerTempFileSystem = new RunnerTempFileSystem( + mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), + inner: mockFileSystem); + // ConfigurationFileProvider writes to AppData/config-runtime, which is outside ChangelogFileSystem's + // git-root scope by design. Use a CheckoutsFileSystem (includes AppData) for the config provider only; + // it wraps the same mock so both filesystems share in-memory state. + var configFileSystem = CheckoutsFileSystem.FromWorkingDirectory(mockFileSystem); Collector = new TestDiagnosticsCollector(output); LoggerFactory = new TestLoggerFactory(output); @@ -104,7 +112,7 @@ protected ChangelogTestBase(ITestOutputHelper output) { Elasticsearch = ElasticsearchEndpoint.Default, }, - ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, FileSystem), + ConfigurationFileProvider = new ConfigurationFileProvider(NullLoggerFactory.Instance, configFileSystem), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, SearchConfiguration = new SearchConfiguration { Synonyms = [], Rules = [], DiminishTerms = [] }, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs index a31bc4b37c..32294e20b7 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs @@ -133,7 +133,7 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle() await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); var handler = RepoPoolHandler(); - var service = new ChangelogBundlingService(LoggerFactory, ConfigurationContext, FileSystem, null, Fetcher(handler)); + var service = new ChangelogBundlingService(LoggerFactory, FileSystem, ConfigurationContext, null, Fetcher(handler)); var input = new BundleChangelogsArguments { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs index 1c485eedfb..b679f379df 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/CreateChangelogTestBase.cs @@ -15,7 +15,7 @@ public abstract class CreateChangelogTestBase(ITestOutputHelper output) : Change protected IGitHubPrService MockGitHubService { get; } = A.Fake(); protected ChangelogCreationService CreateService(IEnvironmentVariables? env = null) => - new(LoggerFactory, ConfigurationContext, MockGitHubService, FileSystem, env); + new(LoggerFactory, ConfigurationContext, FileSystem, MockGitHubService, env); protected async Task CreateConfigDirectory(string configContent) { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs index aa50df84f7..6d144f0bf1 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/ReleaseVersionTests.cs @@ -21,7 +21,7 @@ public class ReleaseVersionTests(ITestOutputHelper output) : ChangelogTestBase(o private readonly IGitHubPrService _mockPrService = A.Fake(); private GitHubReleaseChangelogService CreateService() => - new(LoggerFactory, ConfigurationContext, _mockReleaseService, _mockPrService, FileSystem); + new(LoggerFactory, ConfigurationContext, FileSystem, _mockReleaseService, _mockPrService); private string CreateOutputDirectory() => FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs index f64aeebf19..f0dcdefd2d 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/RemoveReleaseVersionTests.cs @@ -25,7 +25,7 @@ public class RemoveReleaseVersionTests : ChangelogTestBase public RemoveReleaseVersionTests(ITestOutputHelper output) : base(output) { - _removeService = new ChangelogRemoveService(LoggerFactory, ConfigurationContext, FileSystem); + _removeService = new ChangelogRemoveService(LoggerFactory, FileSystem, ConfigurationContext); _changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(_changelogDir); } diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/RenderChangelogTestBase.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/RenderChangelogTestBase.cs index 684b0d9a92..4d9b4a93b6 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Render/RenderChangelogTestBase.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/RenderChangelogTestBase.cs @@ -11,5 +11,5 @@ public abstract class RenderChangelogTestBase : ChangelogTestBase protected ChangelogRenderingService Service { get; } protected RenderChangelogTestBase(ITestOutputHelper output) : base(output) => - Service = new ChangelogRenderingService(LoggerFactory, ConfigurationContext, FileSystem); + Service = new ChangelogRenderingService(LoggerFactory, FileSystem, ConfigurationContext); } diff --git a/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs b/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs index ab38e30eb0..e207d26e98 100644 --- a/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/CIEnrichmentTests.cs @@ -45,7 +45,7 @@ private static IEnvironmentVariables FakeLocalEnv() } private ChangelogCreationService CreateServiceWithEnv(IEnvironmentVariables env) => - new(LoggerFactory, ConfigurationContext, env: env); + new(LoggerFactory, ConfigurationContext, FileSystem, env: env); [Fact] public void EnrichFromCI_NotInCI_ReturnsUnchanged() @@ -167,7 +167,7 @@ public void EnrichFromCI_InCI_TitleOnly_EnrichesWithoutPr() [Fact] public void EnrichFromCI_NullEnv_ReturnsUnchanged() { - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem); var input = DefaultInput() with { Title = "original" }; var result = service.EnrichFromCI(input); diff --git a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs index 60480bbc4f..043d536f0f 100644 --- a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs @@ -11,6 +11,7 @@ using Elastic.Changelog.Utilities; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using FakeItEasy; namespace Elastic.Changelog.Tests.Creation; @@ -83,7 +84,7 @@ public async Task CreateChangelog_CIWithProducts_SkipsPrFetchAndSucceeds() products: "cloud-hosted, cloud-serverless" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], @@ -126,7 +127,7 @@ public async Task CreateChangelog_CIWithoutProducts_FallsBackToPrFetchForProduct repo: "cloud" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], @@ -169,7 +170,7 @@ public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithP repo: "cloud" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], @@ -189,24 +190,16 @@ public async Task CreateChangelog_CIWithoutProducts_NoPrProductLabels_FailsWithP } /// - /// When --output points to a temp directory (e.g. /tmp/changelog-staging in CI), - /// the service must use a write-scoped filesystem that allows temp paths. - /// Regression test for ScopedFileSystemException on temp output. + /// When --output points to a subdirectory of the repo root, the service writes changelog files there. /// [Fact] - public async Task CreateChangelog_TempOutputDirectory_Succeeds() + public async Task CreateChangelog_OutputSubdirectory_Succeeds() { - var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(mockFs); - var configPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"); - writeFs.Directory.CreateDirectory(writeFs.Path.GetDirectoryName(configPath)!); - await writeFs.File.WriteAllTextAsync(configPath, ConfigWithProductLabels, TestContext.Current.CancellationToken); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + await FileSystem.File.WriteAllTextAsync(configPath, ConfigWithProductLabels, TestContext.Current.CancellationToken); - // Use the real system temp path so AllowedSpecialFolder.Temp matches cross-platform. - // MockFileSystem's GetTempPath() returns a hardcoded "C:\temp" that diverges from the - // real temp on Windows CI (D:\Temp), causing scope validation to fail. - var tempOutput = Path.Join(Path.GetTempPath(), "changelog-staging"); + var output = Path.Join(Paths.WorkingDirectoryRoot.FullName, "changelog-staging"); var env = FakeCIEnv( prNumber: "1044", @@ -217,12 +210,12 @@ public async Task CreateChangelog_TempOutputDirectory_Succeeds() products: "elasticsearch" ); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, writeFs, env); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, env); var input = new CreateChangelogArguments { Products = [], Config = configPath, - Output = tempOutput, + Output = output, Concise = true }; @@ -230,8 +223,8 @@ public async Task CreateChangelog_TempOutputDirectory_Succeeds() result.Should().BeTrue(); Collector.Errors.Should().Be(0); - writeFs.Directory.Exists(tempOutput).Should().BeTrue(); - writeFs.Directory.GetFiles(tempOutput, "*.yaml").Should().NotBeEmpty(); + FileSystem.Directory.Exists(output).Should().BeTrue(); + FileSystem.Directory.GetFiles(output, "*.yaml").Should().NotBeEmpty(); } [Fact] @@ -241,7 +234,7 @@ public async Task CreateChangelog_OutputDoesNotContainBom() var tempOutput = Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); FileSystem.Directory.CreateDirectory(tempOutput); - var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, null); + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, FileSystem, _mockGitHub, null); var input = new CreateChangelogArguments { Title = "Test BOM handling", diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs index 7d662e4c8c..6148df8634 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs @@ -24,7 +24,7 @@ public class ChangelogArtifactEvaluationServiceTests(ITestOutputHelper output) : private static readonly string MetadataFilePath = Path.Join(Root, "artifact/metadata.json"); private ChangelogArtifactEvaluationService CreateService() => - new(LoggerFactory, _mockGitHub, _mockCore, FileSystem); + new(LoggerFactory, _mockGitHub, _mockCore, RunnerTempFileSystem); private static EvaluateArtifactArguments DefaultArgs() => new() diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs index 3c8c29e9c9..32043275d0 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrBodyReaderTests.cs @@ -7,6 +7,7 @@ using Elastic.Changelog.Evaluation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; namespace Elastic.Changelog.Tests.Evaluation; @@ -19,8 +20,9 @@ public async Task ReadAsync_PrBodyFileUnderWorkingDir_ReadsBody() var mockFs = CreateMockFileSystem(); mockFs.AddFile(bodyPath, new MockFileData("Release Notes: adds billing metadata")); var collector = new TestDiagnosticsCollector(output); + var fs = CreateRunnerTempFs(mockFs); - var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, mockFs, TestContext.Current.CancellationToken); + var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, fs, TestContext.Current.CancellationToken); result.Should().Be("Release Notes: adds billing metadata"); collector.Diagnostics.Should().BeEmpty(); @@ -30,7 +32,8 @@ public async Task ReadAsync_PrBodyFileUnderWorkingDir_ReadsBody() public async Task ReadAsync_PrBodyFileMissing_EmitsWarning() { var bodyPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "missing-pr-body.md"); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(CreateMockFileSystem()); + var mockFs = CreateMockFileSystem(); + var scopedFs = CreateRunnerTempFs(mockFs); var collector = new TestDiagnosticsCollector(output); var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, scopedFs, TestContext.Current.CancellationToken); @@ -48,7 +51,8 @@ public async Task ReadAsync_PrBodyFileOutsideScope_EmitsWarning() var bodyPath = Path.Join(runnerTemp, "changelog-pr-body.md"); var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); mockFs.AddFile(bodyPath, new MockFileData("Release Notes: something important")); - var scopedFs = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs); + // Deliberately do NOT add runnerTemp to ciPaths — file should be outside scope + var scopedFs = CreateRunnerTempFs(mockFs); var collector = new TestDiagnosticsCollector(output); var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, scopedFs, TestContext.Current.CancellationToken); @@ -67,8 +71,9 @@ public async Task ReadAsync_PrBodyFileExceedsMaxSize_TruncatesAndHints() var mockFs = CreateMockFileSystem(); mockFs.AddFile(bodyPath, new MockFileData(largeContent)); var collector = new TestDiagnosticsCollector(output); + var fs = CreateRunnerTempFs(mockFs); - var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, mockFs, TestContext.Current.CancellationToken); + var result = await ChangelogPrBodyReader.ReadAsync(bodyPath, collector, fs, TestContext.Current.CancellationToken); result.Should().NotBeNull(); result.Length.Should().Be(ChangelogPrBodyReader.MaxPrBodyFileBytes); @@ -77,4 +82,7 @@ public async Task ReadAsync_PrBodyFileExceedsMaxSize_TruncatesAndHints() private static MockFileSystem CreateMockFileSystem() => new(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); + + private static RunnerTempFileSystem CreateRunnerTempFs(MockFileSystem inner) => + new(inner.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: inner); } diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs index 1419ddf75e..da29e64f33 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs @@ -67,7 +67,7 @@ public ChangelogPrEvaluationServiceTests(ITestOutputHelper output) : base(output } private ChangelogPrEvaluationService CreateService() => - new(LoggerFactory, ConfigurationContext, _mockGitHub, _mockCore, FileSystem); + new(LoggerFactory, ConfigurationContext, _mockGitHub, _mockCore, RunnerTempFileSystem); private EvaluatePrArguments DefaultArgs( string eventAction = "opened", diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs index 464bcaf16c..bff6350b26 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -43,7 +43,7 @@ public class ChangelogPrepareArtifactServiceTests(ITestOutputHelper output) : Ch """; private ChangelogPrepareArtifactService CreateService() => - new(LoggerFactory, ConfigurationContext, _mockCore, FileSystem); + new(LoggerFactory, ConfigurationContext, _mockCore, RunnerTempFileSystem); private PrepareArtifactArguments DefaultArgs( string evaluateStatus = "proceed", diff --git a/tests/Elastic.Changelog.Tests/Migration/WebMigrationServiceTests.cs b/tests/Elastic.Changelog.Tests/Migration/WebMigrationServiceTests.cs index b6cf414155..ffce519f42 100644 --- a/tests/Elastic.Changelog.Tests/Migration/WebMigrationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Migration/WebMigrationServiceTests.cs @@ -11,6 +11,7 @@ using AwesomeAssertions; using Elastic.Changelog.Migration; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; using Nullean.ScopedFileSystem; @@ -36,7 +37,7 @@ public WebMigrationServiceTests(ITestOutputHelper output) { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - _fileSystem = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(_mockFileSystem); + _fileSystem = CheckoutsFileSystem.FromWorkingDirectory(_mockFileSystem).Write; _collector = new TestDiagnosticsCollector(output); _httpHandler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { diff --git a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs index a4e5c1cef3..975b0ba88f 100644 --- a/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs @@ -11,9 +11,9 @@ using Elastic.Changelog.Tests.Changelogs; using Elastic.Changelog.Uploading; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using FakeItEasy; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; namespace Elastic.Changelog.Tests.Uploading; @@ -21,7 +21,7 @@ namespace Elastic.Changelog.Tests.Uploading; public class ChangelogUploadServiceTests { private readonly MockFileSystem _mockFileSystem; - private readonly ScopedFileSystem _fileSystem; + private readonly ChangelogFileSystem _fileSystem; private readonly IAmazonS3 _s3Client = A.Fake(); private readonly ChangelogUploadService _service; private readonly TestDiagnosticsCollector _collector; @@ -33,7 +33,7 @@ public ChangelogUploadServiceTests(ITestOutputHelper output) { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - _fileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(_mockFileSystem); + _fileSystem = ChangelogFileSystem.FromWorkingDirectory(_mockFileSystem); _service = new ChangelogUploadService(NullLoggerFactory.Instance, fileSystem: _fileSystem, s3Client: _s3Client); _collector = new TestDiagnosticsCollector(output); _changelogDir = _mockFileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog"); diff --git a/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs b/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs index 804502ebca..b9a7f84465 100644 --- a/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs +++ b/tests/Elastic.Documentation.Build.Tests/TestHelpers.cs @@ -15,6 +15,7 @@ using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using FakeItEasy; using Microsoft.Extensions.Logging; @@ -71,7 +72,7 @@ public static IConfigurationContext CreateConfigurationContext( { Elasticsearch = ElasticsearchEndpoint.Default, }, - ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(null), fileSystem), + ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem(fileSystem)), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, SearchConfiguration = search, diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index cb4f942077..14bb6a8923 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -11,8 +11,8 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; -using Nullean.ScopedFileSystem; using YamlDotNet.Core; using YamlDotNet.Serialization; @@ -639,8 +639,10 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve( + documentationSourceDirectory, + new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configurationPath.FullName }); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new(fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs similarity index 55% rename from tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs rename to tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs index bcef22ddf7..a3da9685d6 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/FileSystemFactoryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CheckoutsFileSystemTests.cs @@ -5,20 +5,14 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Configuration.Tests; -sealed file class StubEnv(string? runnerTemp) : IEnvironmentVariables -{ - public string? GetEnvironmentVariable(string name) => - name == "RUNNER_TEMP" ? runnerTemp : null; - public bool IsRunningOnCI => runnerTemp is not null; -} - -public class FileSystemFactoryTests +public class CheckoutsFileSystemTests { [Fact] - public void ScopeCurrentWorkingDirectory_NestedExtensionRoot_DoesNotThrow() + public void NestedExtensionRoot_DoesNotThrow() { var workingRoot = Paths.WorkingDirectoryRoot.FullName; var nestedConfigDir = Path.Join(workingRoot, "environments", "internal"); @@ -28,16 +22,17 @@ public void ScopeCurrentWorkingDirectory_NestedExtensionRoot_DoesNotThrow() { configPath, new MockFileData("environment: internal") } }); - var act = () => FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [nestedConfigDir]); + var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), inner: mockFs, extraRoots: [nestedConfigDir]); act.Should().NotThrow(); - var scoped = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [nestedConfigDir]); + var scoped = act(); scoped.File.Exists(configPath).Should().BeTrue(); } [Fact] - public void ScopeCurrentWorkingDirectory_ExternalExtensionRoot_AllowsReadingExternalConfig() + public void ExternalExtensionRoot_AllowsReadingExternalConfig() { + var workingRoot = Paths.WorkingDirectoryRoot.FullName; var externalRoot = Path.Join(Path.GetTempPath(), $"external-codex-{Guid.NewGuid():N}"); var configPath = Path.Join(externalRoot, "codex.yml"); var mockFs = new MockFileSystem(new Dictionary @@ -45,13 +40,13 @@ public void ScopeCurrentWorkingDirectory_ExternalExtensionRoot_AllowsReadingExte { configPath, new MockFileData("environment: internal") } }); - var scoped = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [externalRoot]); + var scoped = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), inner: mockFs, extraRoots: [externalRoot]); scoped.File.Exists(configPath).Should().BeTrue(); } [Fact] - public void ScopeCurrentWorkingDirectory_AncestorExtensionRoot_DoesNotThrow() + public void AncestorExtensionRoot_DoesNotThrow() { // An ancestor of the working root would produce overlapping roots, the same class of // crash fixed in a96ef869 / 3c5f9703 for Codex nested paths. @@ -59,45 +54,30 @@ public void ScopeCurrentWorkingDirectory_AncestorExtensionRoot_DoesNotThrow() var ancestor = Path.GetDirectoryName(workingRoot)!; var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = workingRoot }); - var act = () => FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [ancestor]); + var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(workingRoot), inner: mockFs, extraRoots: [ancestor]); act.Should().NotThrow(); var scoped = act(); - // The scoped filesystem must still allow reads within the working root. var fileInWorkingRoot = Path.Join(workingRoot, "some.yml"); mockFs.AddFile(fileInWorkingRoot, new MockFileData("test")); scoped.File.Exists(fileInWorkingRoot).Should().BeTrue(); } [Fact] - public void RealReadForRunnerTemp_RunnerTempUnset_FallsBackToRealRead() - { - var env = new StubEnv(runnerTemp: null); - - var result = FileSystemFactory.RealReadForRunnerTemp(env); - - result.Should().BeSameAs(FileSystemFactory.RealRead); - } - - [Fact] - public void RealReadForRunnerTemp_RunnerTempSibling_AllowsReadingStagedFile() + public void ExtraRunnerTempRoot_AllowsReadingStagedFile() { - // Simulate the GitHub Actions hosted runner layout: RUNNER_TEMP and the - // checkout root are sibling directories. A file staged by the action in - // RUNNER_TEMP must be readable, which the plain RealRead scope denies. + // Simulate the GitHub Actions hosted runner layout: RUNNER_TEMP and the checkout root + // are sibling directories. A file staged in RUNNER_TEMP must be readable, which a + // plain working-dir scope denies. var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); - var env = new StubEnv(runnerTemp: tempDir); var stagedFile = Path.Join(tempDir, "changelog-pr-body.md"); var mockFs = new MockFileSystem(new Dictionary { { stagedFile, new MockFileData("Release Notes: fix memory leak") } }, new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var scoped = FileSystemFactory.RealReadForRunnerTemp(env); - // We call the overload that accepts inner FS, reusing the factory helper - // that RealReadForRunnerTemp delegates to. - var scopedMock = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs, [tempDir]); + var scoped = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: mockFs, extraRoots: [tempDir]); - scopedMock.File.Exists(stagedFile).Should().BeTrue(); + scoped.File.Exists(stagedFile).Should().BeTrue(); } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs new file mode 100644 index 0000000000..9acb0e0caf --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/CiCheckoutLayoutTests.cs @@ -0,0 +1,173 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; + +namespace Elastic.Documentation.Configuration.Tests; + +/// +/// Regression tests for the GitHub Actions hosted-runner layout where each documentation +/// set checkout lives inside the ApplicationData folder: +/// ~/.local/share/elastic/docs-builder/checkouts/current/<repo> +/// +/// Before the fix, all three filesystem types unconditionally added ApplicationData as a +/// second scope root alongside the checkout. When checkout ⊂ AppData, the two roots form +/// a parent–child pair and ValidateRootsAreDisjoint throws with "Scope roots must +/// be disjoint". Now each type skips AppData when checkout is a sub-path of it (or vice +/// versa). +/// +/// +public class CiCheckoutLayoutTests +{ + /// + /// Constructs the simulated CI checkout path: a directory nested inside the real + /// ApplicationData folder, matching the layout the assembler uses on hosted runners. + /// + private static string CiCheckoutRoot => + Path.Join(Paths.ApplicationData.FullName, "checkouts", "current", "apm-server"); + + // ----------------------------------------------------------------------- + // CheckoutsFileSystem + // ----------------------------------------------------------------------- + + [Fact] + public void CheckoutsFileSystem_CheckoutInsideAppData_DoesNotThrow() + { + var checkoutRoot = CiCheckoutRoot; + var mockFs = new MockFileSystem(new Dictionary + { + { Path.Join(checkoutRoot, "readme.md"), new MockFileData("hello") } + }); + + var act = () => new CheckoutsFileSystem(mockFs.DirectoryInfo.New(checkoutRoot), inner: mockFs); + + act.Should().NotThrow(); + } + + [Fact] + public void CheckoutsFileSystem_CheckoutInsideAppData_ReadsFilesUnderCheckout() + { + var checkoutRoot = CiCheckoutRoot; + var filePath = Path.Join(checkoutRoot, "readme.md"); + var mockFs = new MockFileSystem(new Dictionary + { + { filePath, new MockFileData("hello") } + }); + + var fs = new CheckoutsFileSystem(mockFs.DirectoryInfo.New(checkoutRoot), inner: mockFs); + + fs.File.Exists(filePath).Should().BeTrue(); + } + + // ----------------------------------------------------------------------- + // DocumentationWriteFileSystem + // ----------------------------------------------------------------------- + + [Fact] + public void DocumentationWriteFileSystem_CheckoutInsideAppData_DoesNotThrow() + { + var checkoutRoot = CiCheckoutRoot; + var mockFs = new MockFileSystem(); + + var act = () => new DocumentationWriteFileSystem( + mockFs.DirectoryInfo.New(checkoutRoot), + inner: mockFs); + + act.Should().NotThrow(); + } + + [Fact] + public void DocumentationWriteFileSystem_CheckoutInsideAppData_WritesFilesUnderCheckout() + { + var checkoutRoot = CiCheckoutRoot; + var outputPath = Path.Join(checkoutRoot, ".artifacts", "docs", "html"); + var mockFs = new MockFileSystem(); + + var writeFs = new DocumentationWriteFileSystem( + mockFs.DirectoryInfo.New(checkoutRoot), + inner: mockFs); + + var act = () => writeFs.Directory.CreateDirectory(outputPath); + act.Should().NotThrow(); + } + + // ----------------------------------------------------------------------- + // DocumentationFileSystem (read + write via Resolve) + // ----------------------------------------------------------------------- + + [Fact] + public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_DoesNotThrow() + { + var checkoutRoot = CiCheckoutRoot; + var docsPath = Path.Join(checkoutRoot, "docs"); + var mockFs = BuildDocsetFs(checkoutRoot, docsPath); + + var act = () => DocumentationFileSystem.Resolve( + mockFs.DirectoryInfo.New(docsPath), + new DocumentationScopeOptions { Inner = mockFs }); + + act.Should().NotThrow(); + } + + [Fact] + public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_CheckoutResolvedCorrectly() + { + var checkoutRoot = CiCheckoutRoot; + var docsPath = Path.Join(checkoutRoot, "docs"); + var mockFs = BuildDocsetFs(checkoutRoot, docsPath); + + var docFs = DocumentationFileSystem.Resolve( + mockFs.DirectoryInfo.New(docsPath), + new DocumentationScopeOptions { Inner = mockFs }); + + docFs.Paths.CheckoutDirectory.FullName.Should().Be(checkoutRoot); + } + + [Fact] + public void DocumentationFileSystem_Resolve_CheckoutInsideAppData_WriteDoesNotThrow() + { + var checkoutRoot = CiCheckoutRoot; + var docsPath = Path.Join(checkoutRoot, "docs"); + var mockFs = BuildDocsetFs(checkoutRoot, docsPath); + + var act = () => + { + var docFs = DocumentationFileSystem.Resolve( + mockFs.DirectoryInfo.New(docsPath), + new DocumentationScopeOptions { Inner = mockFs }); + // accessing .Write must not throw either + _ = docFs.Write; + }; + + act.Should().NotThrow(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private static MockFileSystem BuildDocsetFs(string checkoutRoot, string docsPath) + { + var mockFs = new MockFileSystem(); + mockFs.AddDirectory(Path.Join(checkoutRoot, ".git")); + mockFs.AddFile(Path.Join(checkoutRoot, ".git", "HEAD"), + new MockFileData("ref: refs/heads/main\n")); + mockFs.AddFile(Path.Join(checkoutRoot, ".git", "refs", "heads", "main"), + new MockFileData("abc1234\n")); + mockFs.AddFile(Path.Join(checkoutRoot, ".git", "config"), + new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/apm-server.git + [branch "main"] + remote = origin + merge = refs/heads/main + """)); + mockFs.AddFile(Path.Join(docsPath, "docset.yml"), new MockFileData("toc: []\n")); + return mockFs; + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs index f3582b6e3e..1d92535ac1 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs @@ -11,7 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Configuration.Tests; @@ -87,8 +87,9 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs index bb289aa76a..b541ccb920 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs @@ -11,7 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Configuration.Tests; @@ -156,8 +156,9 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs index d86c7f1f90..af6bedef33 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs @@ -11,7 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Configuration.Tests; @@ -105,8 +105,9 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs index b54ec47e48..b2bf2f89e6 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CreateNavigationFileTests.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Documentation.Configuration.Tests; @@ -12,7 +13,7 @@ namespace Elastic.Documentation.Configuration.Tests; public class CreateNavigationFileTests { private static ConfigurationFileProvider CreateProvider(MockFileSystem fileSystem) => - new(NullLoggerFactory.Instance, fileSystem, skipPrivateRepositories: true, ConfigurationSource.Embedded); + new(NullLoggerFactory.Instance, new ConfigurationFileSystem(fileSystem), skipPrivateRepositories: true, ConfigurationSource.Embedded); private static AssemblyConfiguration CreateConfig(params string[] privateRepoNames) { diff --git a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs index b259fdff5a..34bfb84791 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs @@ -11,7 +11,7 @@ using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Diagnostics; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Configuration.Tests; @@ -133,8 +133,9 @@ private sealed class MockDocumentationSetContext( : IDocumentationSetContext { public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => WriteFileSystem; - public ScopedFileSystem WriteFileSystem { get; } = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(fileSystem); + public IDocumentationFileSystem ReadFileSystem { get; } = DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName); + public DocumentationWriteFileSystem WriteFileSystem { get; } = new DocumentationWriteFileSystem( + fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), inner: fileSystem); public IDirectoryInfo OutputDirectory => fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, ".artifacts")); public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs new file mode 100644 index 0000000000..1f67f78ff1 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationPathsResolverTests.cs @@ -0,0 +1,501 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; + +namespace Elastic.Documentation.Configuration.Tests; + +/// +/// Tests for and +/// , covering the six-step bootstrap: +/// invocation → docset anchor → checkout → git directories → git info → output. +/// +public class DocumentationPathsResolverTests +{ + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// + /// Normalises a Unix-style path through the mock filesystem so that assertions work on + /// Windows, where MockFileSystem converts /repoC:\repo. + /// + private static string P(MockFileSystem fs, string unixPath) => + fs.DirectoryInfo.New(unixPath).FullName; + + /// + /// Builds a minimal regular-repo filesystem: + /// /repo/.git/{HEAD,config,refs/...} + + /// /repo/docs/docset.yml + /// + private static MockFileSystem RegularRepo( + string repoRoot = "/repo", + string docsRelative = "docs", + string branch = "main", + string sha = "abc1234", + string remote = "elastic/test-repo") + { + var fs = new MockFileSystem(); + var docsPath = $"{repoRoot}/{docsRelative}"; + fs.AddDirectory($"{repoRoot}/.git"); + fs.AddFile($"{repoRoot}/.git/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); + fs.AddFile($"{repoRoot}/.git/refs/heads/{branch}", new MockFileData($"{sha}\n")); + fs.AddFile($"{repoRoot}/.git/config", new MockFileData($""" + [remote "origin"] + url = https://github.com/{remote}.git + [branch "{branch}"] + remote = origin + merge = refs/heads/{branch} + """)); + fs.AddFile($"{docsPath}/docset.yml", new MockFileData("toc: []\n")); + return fs; + } + + /// + /// Builds a worktree filesystem: + /// /worktree/.git (file) → /main/.git/worktrees/wt → commondir → /main/.git + /// + private static MockFileSystem WorktreeWithCommondir( + string worktreeRoot = "/worktree", + string mainRoot = "/main", + string branch = "topic", + string sha = "fedcba987654", + string remote = "elastic/worktree-repo", + string docsRelative = "docs") + { + var fs = new MockFileSystem(); + var worktreeGitDir = $"{mainRoot}/.git/worktrees/wt"; + var mainGitDir = $"{mainRoot}/.git"; + var docsPath = $"{worktreeRoot}/{docsRelative}"; + + // Worktree: .git is a pointer file + fs.AddFile($"{worktreeRoot}/.git", new MockFileData($"gitdir: {worktreeGitDir}\n")); + + // Worktree-specific gitdir with commondir pointing back to the main .git + fs.AddDirectory(worktreeGitDir); + fs.AddFile($"{worktreeGitDir}/commondir", new MockFileData("../..\n")); // → /main/.git + + // Main .git has the shared objects, config, and HEAD + fs.AddDirectory(mainGitDir); + fs.AddFile($"{mainGitDir}/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); + fs.AddFile($"{mainGitDir}/refs/heads/{branch}", new MockFileData($"{sha}\n")); + fs.AddFile($"{mainGitDir}/config", new MockFileData($""" + [remote "origin"] + url = https://github.com/{remote}.git + [branch "{branch}"] + remote = origin + merge = refs/heads/{branch} + """)); + + fs.AddFile($"{docsPath}/docset.yml", new MockFileData("toc: []\n")); + return fs; + } + + // ----------------------------------------------------------------------- + // Docset scan + checkout convergence + // ----------------------------------------------------------------------- + + [Fact] + public void InvocationAtRepoRoot_ResolvesDocsetInDocsSubfolder() + { + var fs = RegularRepo(); + var invocation = fs.DirectoryInfo.New("/repo"); + + var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); + + paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs")); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo")); + } + + [Fact] + public void InvocationAtDocsSubfolder_ResolvesDocsetAndCheckout() + { + var fs = RegularRepo(); + var invocation = fs.DirectoryInfo.New("/repo/docs"); + + var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); + + paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs")); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo")); + } + + [Fact] + public void PathRepoRoot_And_PathDocsSubfolder_ResolveIdenticalCheckoutAndSource() + { + var fs = RegularRepo(); + + var fromRoot = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/repo"), + new DocumentationScopeOptions { Inner = fs }, fs); + + var fromDocs = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/repo/docs"), + new DocumentationScopeOptions { Inner = fs }, fs); + + fromRoot.CheckoutDirectory.FullName.Should().Be(fromDocs.CheckoutDirectory.FullName, + "--path /repo and --path /repo/docs must converge on the same checkout"); + fromRoot.SourceDirectory.FullName.Should().Be(fromDocs.SourceDirectory.FullName, + "--path /repo and --path /repo/docs must converge on the same source"); + } + + [Fact] + public void AnchorTwoLevelsBelowInvocationRoot_StillResolvesCheckoutAtGitRoot() + { + // Mirrors the `elastic/infra` shape: no `--path` (invocation == repo root), and the only + // docset sits two levels down (`docs/resilience-team/docset.yml`). The recursive scan in + // step 2 finds it fine; without widening `MaxParents` by that same distance, step 3's + // default maxParents=1 can't see the `.git` two levels above the anchor and would either + // throw (real FS) or silently fall back to the wrong directory (mock FS leniency). + var fs = RegularRepo(docsRelative: "docs/resilience-team"); + var invocation = fs.DirectoryInfo.New("/repo"); + + var paths = DocumentationPathsResolver.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }, fs); + + paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs/resilience-team")); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo"), + "the anchor's depth below the invocation root should widen the git-root search, not require --git-dir"); + paths.Git.IsAvailable.Should().BeTrue(); + } + + [Fact] + public void AnchorAtInvocationRoot_UnrelatedAncestorGit_StillOutOfReach() + { + // The depth-widening must stay anchored to the invocation, not become unbounded: when the + // anchor IS the invocation (depth 0), an unrelated repo's .git two levels up must remain + // out of reach, exactly as before this change. + var fs = new MockFileSystem(); + fs.AddDirectory("/parent-repo/.git"); + fs.AddDirectory("/parent-repo/checkout"); + fs.AddFile("/parent-repo/checkout/docs/docset.yml", new MockFileData("toc: []\n")); + + var invocation = fs.DirectoryInfo.New("/parent-repo/checkout/docs"); + var opts = new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }; + + var paths = DocumentationPathsResolver.Resolve(invocation, opts, fs); + + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/parent-repo/checkout/docs"), + "depth-widening is relative to the invocation, so an ancestor repo's .git outside the invocation must not be adopted"); + } + + [Fact] + public void InvocationPath_StoredVerbatim_IndependentOfCheckout() + { + var fs = RegularRepo(); + var invocationDir = fs.DirectoryInfo.New("/repo/docs"); + + var paths = DocumentationPathsResolver.Resolve(invocationDir, new DocumentationScopeOptions { Inner = fs }, fs); + + paths.InvocationPath.FullName.Should().Be(P(fs, "/repo/docs")); + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo")); + } + + // ----------------------------------------------------------------------- + // Git directory resolution + // ----------------------------------------------------------------------- + + [Fact] + public void RegularRepo_GitDirectories_ContainsOneEntry() + { + var fs = RegularRepo(); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/repo"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.GitDirectories.Should().ContainSingle() + .Which.Should().Be(P(fs, "/repo/.git")); + } + + [Fact] + public void RegularRepo_GitInfo_IsResolved() + { + var fs = RegularRepo(branch: "my-branch", sha: "deadbeef1234", remote: "elastic/my-repo"); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/repo"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.Git.IsAvailable.Should().BeTrue(); + paths.Git.Branch.Should().Be("my-branch"); + paths.Git.Ref.Should().Be("deadbeef1234"); + paths.Git.RepositoryName.Should().Be("my-repo"); + } + + // ----------------------------------------------------------------------- + // Git worktree + // ----------------------------------------------------------------------- + + [Fact] + public void Worktree_CheckoutIsWorktreeRoot_NotMainRepo() + { + var fs = WorktreeWithCommondir(); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/worktree")); + } + + [Fact] + public void Worktree_GitDirectories_ContainsPointerAndMainGit() + { + var fs = WorktreeWithCommondir(); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.GitDirectories.Should().HaveCount(2); + paths.GitDirectories.Should().Contain(P(fs, "/worktree/.git"), + "pointer file path must be in scope so the .git file is readable"); + paths.GitDirectories.Should().Contain(P(fs, "/main/.git"), + "resolved commondir target must be included so config/HEAD are readable"); + } + + [Fact] + public void Worktree_GitInfo_ResolvedFromMainDotGit() + { + var fs = WorktreeWithCommondir(branch: "topic", sha: "fedcba987654", remote: "elastic/worktree-repo"); + + var paths = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree"), + new DocumentationScopeOptions { Inner = fs }, fs); + + paths.Git.IsAvailable.Should().BeTrue(); + paths.Git.Branch.Should().Be("topic"); + paths.Git.Ref.Should().Be("fedcba987654"); + paths.Git.RepositoryName.Should().Be("worktree-repo"); + } + + [Fact] + public void Worktree_InvocationAtDocsSubfolder_ResolvesIdenticallyToWorktreeRoot() + { + var fs = WorktreeWithCommondir(); + + var fromWorktreeRoot = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree"), + new DocumentationScopeOptions { Inner = fs }, fs); + + var fromDocs = DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/worktree/docs"), + new DocumentationScopeOptions { Inner = fs }, fs); + + fromWorktreeRoot.CheckoutDirectory.FullName.Should().Be(fromDocs.CheckoutDirectory.FullName, + "worktree: --path /worktree and --path /worktree/docs must resolve to the same checkout"); + } + + // ----------------------------------------------------------------------- + // Explicit --git-dir override + // ----------------------------------------------------------------------- + + [Fact] + public void ExplicitGitDir_CheckoutIsGitDirParent() + { + // Layout: docset at /project/docs/, .git at /repo/.git (out-of-tree) + var fs = new MockFileSystem(); + fs.AddDirectory("/repo/.git"); + fs.AddFile("/repo/.git/HEAD", new MockFileData("ref: refs/heads/main\n")); + fs.AddFile("/repo/.git/refs/heads/main", new MockFileData("cafe1234\n")); + fs.AddFile("/repo/.git/config", new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/override-test.git + [branch "main"] + remote = origin + merge = refs/heads/main + """)); + fs.AddFile("/project/docs/docset.yml", new MockFileData("toc: []\n")); + + var opts = new DocumentationScopeOptions + { + Inner = fs, + GitDir = "/repo/.git" + }; + + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); + + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo"), + "--git-dir /repo/.git → checkout = /repo/.git.Parent = /repo"); + } + + [Fact] + public void ExplicitGitDir_GitInfo_ResolvedFromOverriddenGitDir() + { + var fs = new MockFileSystem(); + fs.AddDirectory("/repo/.git"); + fs.AddFile("/repo/.git/HEAD", new MockFileData("ref: refs/heads/main\n")); + fs.AddFile("/repo/.git/refs/heads/main", new MockFileData("aabbcc99\n")); + fs.AddFile("/repo/.git/config", new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/override-repo.git + [branch "main"] + remote = origin + merge = refs/heads/main + """)); + fs.AddFile("/project/docs/docset.yml", new MockFileData("toc: []\n")); + + var opts = new DocumentationScopeOptions + { + Inner = fs, + GitDir = "/repo/.git" + }; + + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project/docs"), opts, fs); + + paths.Git.IsAvailable.Should().BeTrue(); + paths.Git.Branch.Should().Be("main"); + paths.Git.Ref.Should().Be("aabbcc99"); + paths.Git.RepositoryName.Should().Be("override-repo"); + } + + // ----------------------------------------------------------------------- + // Mock filesystem — no git layout (graceful fallback) + // ----------------------------------------------------------------------- + + [Fact] + public void MockFsWithoutGit_DoesNotThrow_CheckoutFallsBackToSource() + { + var fs = new MockFileSystem(); + fs.AddFile("/repo/docs/docset.yml", new MockFileData("toc: []\n")); + + var opts = new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }; + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); + + paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo/docs"), + "mock FS fallback: no .git → checkout = source directory"); + paths.GitDirectories.Should().BeEmpty(); + } + + [Fact] + public void MockFsWithoutGit_GitOverride_IsPreservedVerbatim() + { + var fs = new MockFileSystem(); + fs.AddFile("/repo/docs/docset.yml", new MockFileData("toc: []\n")); + + var opts = new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }; + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); + + paths.Git.IsAvailable.Should().BeFalse(); + paths.Git.Should().Be(GitCheckoutInformation.Unavailable); + } + + // ----------------------------------------------------------------------- + // Output directory default + // ----------------------------------------------------------------------- + + [Fact] + public void Output_DefaultsToCheckoutArtifacts() + { + var fs = RegularRepo(); + var opts = new DocumentationScopeOptions { Inner = fs }; + + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); + + // Default output is checkout/.artifacts/docs/html — NOT the invocation path. + paths.OutputDirectory.FullName.Should().StartWith(P(fs, "/repo/.artifacts")); + } + + [Fact] + public void Output_InvocationAtDocsSubfolder_StillAnchorsToCheckout() + { + // Regression: before this fix, --path /repo/docs/ wrote to /repo/docs/.artifacts + // instead of /repo/.artifacts. Checkout is /repo, so artifacts belong there. + var fs = RegularRepo(); + var opts = new DocumentationScopeOptions { Inner = fs }; + + var fromRoot = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); + var fromDocs = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo/docs"), opts, fs); + + fromRoot.OutputDirectory.FullName.Should().Be(fromDocs.OutputDirectory.FullName, + "--path /repo and --path /repo/docs must produce the same default output directory"); + } + + [Fact] + public void Output_ExplicitOverride_IsRespected() + { + var fs = RegularRepo(); + var opts = new DocumentationScopeOptions + { + Inner = fs, + Output = "/custom/output" + }; + + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/repo"), opts, fs); + + paths.OutputDirectory.FullName.Should().Be(P(fs, "/custom/output")); + } + + // ----------------------------------------------------------------------- + // ConfigurationFile option (pre-discovered docset) + // ----------------------------------------------------------------------- + + [Fact] + public void PreDiscoveredConfigFile_SkipsDocsetScan() + { + // Docset is at /project/docs/docset.yml, but invocation is the project root + var fs = RegularRepo(repoRoot: "/project"); + var docsetFile = fs.FileInfo.New("/project/docs/docset.yml"); + + var opts = new DocumentationScopeOptions { Inner = fs, ConfigurationFile = docsetFile.FullName }; + var paths = DocumentationPathsResolver.Resolve(fs.DirectoryInfo.New("/project"), opts, fs); + + paths.SourceDirectory.FullName.Should().Be(P(fs, "/project/docs")); + paths.ConfigurationPath.FullName.Should().Be(P(fs, "/project/docs/docset.yml")); + } + + // ----------------------------------------------------------------------- + // No docset found → throws + // ----------------------------------------------------------------------- + + [Fact] + public void NoDocset_Throws_DocumentationPathException() + { + var fs = new MockFileSystem(); + fs.AddDirectory("/empty"); + + var act = () => DocumentationPathsResolver.Resolve( + fs.DirectoryInfo.New("/empty"), + new DocumentationScopeOptions { Inner = fs, Git = GitCheckoutInformation.Unavailable }, + fs); + + act.Should().Throw() + .WithMessage("*docset.yml*"); + } + + // ----------------------------------------------------------------------- + // DocumentationFileSystem.Resolve — integration (same scenarios via the public API) + // ----------------------------------------------------------------------- + + [Fact] + public void DocumentationFileSystem_Resolve_RegularRepo_ExposesResolvedPaths() + { + var fs = RegularRepo(branch: "feature", sha: "001122", remote: "elastic/docs-builder"); + var invocation = fs.DirectoryInfo.New("/repo"); + + var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }); + + docFs.Paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/repo")); + docFs.Paths.SourceDirectory.FullName.Should().Be(P(fs, "/repo/docs")); + docFs.Paths.Git.Branch.Should().Be("feature"); + docFs.Paths.Git.RepositoryName.Should().Be("docs-builder"); + } + + [Fact] + public void DocumentationFileSystem_Resolve_Worktree_ExposesMainGitInfo() + { + var fs = WorktreeWithCommondir(branch: "topic", sha: "99aabb", remote: "elastic/worktree-test"); + var invocation = fs.DirectoryInfo.New("/worktree"); + + var docFs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fs }); + + docFs.Paths.CheckoutDirectory.FullName.Should().Be(P(fs, "/worktree")); + docFs.Paths.Git.Branch.Should().Be("topic"); + docFs.Paths.Git.Ref.Should().Be("99aabb"); + docFs.Paths.Git.RepositoryName.Should().Be("worktree-test"); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/FindGitRootTests.cs b/tests/Elastic.Documentation.Configuration.Tests/FindGitRootTests.cs index 65ec14db9a..63b4d586f9 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/FindGitRootTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/FindGitRootTests.cs @@ -17,12 +17,11 @@ public void DocsAtRoot_FindsGitRoot() fs.AddFile("/repo/docset.yml", new("toc: []")); var start = fs.DirectoryInfo.New("/repo"); - var expected = start.FullName; - var result = Paths.FindGitRoot(start, ceiling: start); + var result = Paths.FindGitRoot(start); result.Should().NotBeNull(); - result.FullName.Should().Be(expected); + result.FullName.Should().Be(start.FullName); } [Fact] @@ -32,13 +31,14 @@ public void DocsInDocsFolder_FindsGitRoot() fs.AddDirectory("/repo/.git"); fs.AddFile("/repo/docs/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/repo"); var start = fs.DirectoryInfo.New("/repo/docs"); + var expected = fs.DirectoryInfo.New("/repo"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // Default maxParents=1: docset is one level below checkout, so .git is found at depth 1 + var result = Paths.FindGitRoot(start); result.Should().NotBeNull(); - result.FullName.Should().Be(ceiling.FullName); + result.FullName.Should().Be(expected.FullName); } [Fact] @@ -48,17 +48,18 @@ public void DocsNestedTwoLevels_FindsGitRoot() fs.AddDirectory("/repo/.git"); fs.AddFile("/repo/docs/resilience-team/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/repo"); var start = fs.DirectoryInfo.New("/repo/docs/resilience-team"); + var expected = fs.DirectoryInfo.New("/repo"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // maxParents=2: docset is two levels below checkout + var result = Paths.FindGitRoot(start, maxParents: 2); result.Should().NotBeNull(); - result.FullName.Should().Be(ceiling.FullName); + result.FullName.Should().Be(expected.FullName); } [Fact] - public void DocsNestedTwoLevels_WithoutCeiling_ReturnsNull() + public void DocsNestedTwoLevels_WithDefaultMaxParents_ReturnsNull() { var fs = new MockFileSystem(); fs.AddDirectory("/repo/.git"); @@ -66,58 +67,60 @@ public void DocsNestedTwoLevels_WithoutCeiling_ReturnsNull() var start = fs.DirectoryInfo.New("/repo/docs/resilience-team"); + // Default maxParents=1 cannot reach .git at depth 2 var result = Paths.FindGitRoot(start); result.Should().BeNull(); } [Fact] - public void CeilingPreventsEscapingToParentRepo() + public void BoundPreventsEscapingToParentRepo() { var fs = new MockFileSystem(); fs.AddDirectory("/parent-repo/.git"); fs.AddDirectory("/parent-repo/checkout"); fs.AddFile("/parent-repo/checkout/docs/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/parent-repo/checkout"); var start = fs.DirectoryInfo.New("/parent-repo/checkout/docs"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // maxParents=1: checks checkout/docs (depth 0) and checkout (depth 1), neither has .git + var result = Paths.FindGitRoot(start); - result.Should().BeNull("the .git is above the ceiling and must not be reached"); + result.Should().BeNull("the .git is two levels above the anchor, beyond maxParents"); } [Fact] - public void CeilingPreventsEscapingToParentRepo_DeeplyNested() + public void BoundPreventsEscapingToParentRepo_DeeplyNested() { var fs = new MockFileSystem(); fs.AddDirectory("/workspace/projects/other-repo/.git"); fs.AddDirectory("/workspace/projects/other-repo/subrepo/docs/team"); fs.AddFile("/workspace/projects/other-repo/subrepo/docs/team/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/workspace/projects/other-repo/subrepo"); var start = fs.DirectoryInfo.New("/workspace/projects/other-repo/subrepo/docs/team"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // Default maxParents=1: cannot reach .git three levels up + var result = Paths.FindGitRoot(start); - result.Should().BeNull("the .git belongs to a parent repo outside the ceiling"); + result.Should().BeNull("the .git belongs to a parent repo outside the allowed depth"); } [Fact] - public void GitRootInsideCeiling_IsAccepted() + public void GitRootDeepAboveAnchor_AcceptedWithLargeMaxParents() { var fs = new MockFileSystem(); fs.AddDirectory("/workspace/.git"); fs.AddDirectory("/workspace/docs/a/b/c"); fs.AddFile("/workspace/docs/a/b/c/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/workspace"); var start = fs.DirectoryInfo.New("/workspace/docs/a/b/c"); + var expected = fs.DirectoryInfo.New("/workspace"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + // Must pass maxParents=4 since the anchor is 4 levels below the checkout + var result = Paths.FindGitRoot(start, maxParents: 4); result.Should().NotBeNull(); - result.FullName.Should().Be(ceiling.FullName); + result.FullName.Should().Be(expected.FullName); } [Fact] @@ -127,27 +130,44 @@ public void NoGitDirectory_ReturnsNull() fs.AddDirectory("/repo/docs"); fs.AddFile("/repo/docs/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/repo"); var start = fs.DirectoryInfo.New("/repo/docs"); - var result = Paths.FindGitRoot(start, ceiling: ceiling); + var result = Paths.FindGitRoot(start); result.Should().BeNull(); } [Fact] - public void WorktreeGitFile_InsideCeiling_IsAccepted() + public void WorktreeGitFile_FindsGitRoot() { var fs = new MockFileSystem(); fs.AddFile("/repo/.git", new("gitdir: /main/.git/worktrees/repo")); fs.AddFile("/repo/docs/team/docset.yml", new("toc: []")); - var ceiling = fs.DirectoryInfo.New("/repo"); var start = fs.DirectoryInfo.New("/repo/docs/team"); + var expected = fs.DirectoryInfo.New("/repo"); + + // .git file (worktree pointer) counts the same as a .git dir; depth 2 needs maxParents=2 + var result = Paths.FindGitRoot(start, maxParents: 2); + + result.Should().NotBeNull(); + result.FullName.Should().Be(expected.FullName); + } - var result = Paths.FindGitRoot(start, ceiling: ceiling); + [Fact] + public void WorktreeGitFile_OneLevel_FindsGitRoot() + { + var fs = new MockFileSystem(); + fs.AddFile("/repo/.git", new("gitdir: /main/.git/worktrees/repo")); + fs.AddFile("/repo/docs/docset.yml", new("toc: []")); + + var start = fs.DirectoryInfo.New("/repo/docs"); + var expected = fs.DirectoryInfo.New("/repo"); + + // Docset one level below worktree root: found with default maxParents=1 + var result = Paths.FindGitRoot(start); result.Should().NotBeNull(); - result.FullName.Should().Be(ceiling.FullName); + result.FullName.Should().Be(expected.FullName); } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs new file mode 100644 index 0000000000..17df8c3656 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/GitCheckoutResolutionTests.cs @@ -0,0 +1,234 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; + +namespace Elastic.Documentation.Configuration.Tests; + +/// +/// Tests for driven through +/// a seeded with various git layouts. +/// Previously impossible because the mock short-circuit returned canned data for any MockFileSystem; +/// the inverted short-circuit now attempts real resolution first and only falls back to canned data +/// when resolution yields nothing. +/// +public class GitCheckoutResolutionTests +{ + private static MockFileSystem BuildFs(string root, string? branch = "main", string? sha = null, string? remote = null, bool worktree = false, string? worktreeGitDir = null) + { + var fs = new MockFileSystem(); + sha ??= "abc1234def5678"; + remote ??= "elastic/test-repo"; + + if (!worktree) + { + fs.AddDirectory($"{root}/.git"); + // HEAD + if (branch is not null) + fs.AddFile($"{root}/.git/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); + else + fs.AddFile($"{root}/.git/HEAD", new MockFileData($"{sha}\n")); // detached + // ref file + if (branch is not null) + fs.AddFile($"{root}/.git/refs/heads/{branch}", new MockFileData($"{sha}\n")); + // config + fs.AddFile($"{root}/.git/config", new MockFileData($""" + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/{remote}.git + [branch "{branch ?? "main"}"] + remote = origin + merge = refs/heads/{branch ?? "main"} + """)); + } + else + { + // Worktree: .git is a file pointing to the real git dir + var realGitDir = worktreeGitDir ?? $"/main-repo/.git/worktrees/branch"; + fs.AddFile($"{root}/.git", new MockFileData($"gitdir: {realGitDir}\n")); + // The real .git directory + fs.AddDirectory(realGitDir); + fs.AddFile($"{realGitDir}/HEAD", new MockFileData($"ref: refs/heads/{branch}\n")); + fs.AddFile($"{realGitDir}/refs/heads/{branch}", new MockFileData($"{sha}\n")); + fs.AddFile($"{realGitDir}/config", new MockFileData($""" + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/{remote}.git + [branch "{branch}"] + remote = origin + merge = refs/heads/{branch} + """)); + } + + return fs; + } + + [Fact] + public void RegularRepo_ReturnsGitInfo() + { + var fs = BuildFs("/repo", branch: "feature/my-branch", sha: "deadbeef1234"); + + var checkout = fs.DirectoryInfo.New("/repo"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/repo"), inner: fs); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue(); + result.Branch.Should().Be("feature/my-branch"); + result.Ref.Should().Be("deadbeef1234"); + // Remote is the full config url with .git suffix stripped; RepositoryName is the last segment + result.Remote.Should().EndWith("/elastic/test-repo"); + result.RepositoryName.Should().Be("test-repo"); + } + + [Fact] + public void RegularRepo_DetachedHead_NeverReturnsRandomGuid() + { + var fs = BuildFs("/repo", branch: null, sha: "cafebabe9876"); + + var checkout = fs.DirectoryInfo.New("/repo"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/repo"), inner: fs); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + // The production code reads GITHUB_PR_REF_NAME ?? GITHUB_REF_NAME ?? "detached/head". + // Mirror that lookup so the test passes both locally and on CI (where GITHUB_REF_NAME=3789/merge). + var expectedBranch = Environment.GetEnvironmentVariable("GITHUB_PR_REF_NAME") + ?? Environment.GetEnvironmentVariable("GITHUB_REF_NAME") + ?? "detached/head"; + result.IsAvailable.Should().BeTrue(); + result.Ref.Should().Be("cafebabe9876", "detached HEAD must use the actual SHA, never a random GUID"); + result.Branch.Should().Be(expectedBranch); + } + + [Fact] + public void WorktreeWithAbsoluteGitDir_ResolvesViaMainRepo() + { + var sha = "1a2b3c4d5e6f"; + var fs = BuildFs("/worktree", branch: "my-feature", sha: sha, remote: "elastic/worktree-repo", + worktree: true, worktreeGitDir: "/main-repo/.git/worktrees/my-feature"); + + // Scope must cover both the worktree dir and the main .git + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/worktree"), inner: fs); + var extended = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/main-repo/.git/worktrees/my-feature"]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }); + + var checkout = fs.DirectoryInfo.New("/worktree"); + var result = GitCheckoutInformationFactory.Create(checkout, extended); + + result.IsAvailable.Should().BeTrue(); + result.Branch.Should().Be("my-feature"); + result.Ref.Should().Be(sha); + result.RepositoryName.Should().Be("worktree-repo"); + } + + [Fact] + public void WorktreeWithRelativeGitDir_ResolvesAgainstGitFileDirectory() + { + var fs = new MockFileSystem(); + // .git file with relative gitdir — resolved relative to /worktree, NOT process CWD + fs.AddFile("/worktree/.git", new MockFileData("gitdir: ../.git/worktrees/branch\n")); + fs.AddDirectory("/.git/worktrees/branch"); + fs.AddFile("/.git/worktrees/branch/HEAD", new MockFileData("ref: refs/heads/feature\n")); + fs.AddFile("/.git/worktrees/branch/refs/heads/feature", new MockFileData("aabbccdd\n")); + fs.AddFile("/.git/worktrees/branch/config", new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/relative-test.git + [branch "feature"] + remote = origin + merge = refs/heads/feature + """)); + + var checkout = fs.DirectoryInfo.New("/worktree"); + var scoped = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/.git"]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue(); + result.Branch.Should().Be("feature"); + result.Ref.Should().Be("aabbccdd"); + result.RepositoryName.Should().Be("relative-test"); + } + + [Fact] + public void WorktreeWithCommondir_ResolvesViaCommondir() + { + var fs = new MockFileSystem(); + // .git file → worktree-specific dir → commondir → shared .git + fs.AddFile("/worktree/.git", new MockFileData("gitdir: /main/.git/worktrees/wt\n")); + fs.AddDirectory("/main/.git/worktrees/wt"); + fs.AddFile("/main/.git/worktrees/wt/commondir", new MockFileData("../..\n")); // points to /main/.git + // The shared .git directory + fs.AddDirectory("/main/.git"); + fs.AddFile("/main/.git/HEAD", new MockFileData("ref: refs/heads/topic\n")); + fs.AddFile("/main/.git/refs/heads/topic", new MockFileData("fedcba987654\n")); + fs.AddFile("/main/.git/config", new MockFileData(""" + [remote "origin"] + url = https://github.com/elastic/commondir-test.git + [branch "topic"] + remote = origin + merge = refs/heads/topic + """)); + + var checkout = fs.DirectoryInfo.New("/worktree"); + var scoped = new Nullean.ScopedFileSystem.ScopedFileSystem(fs, + new Nullean.ScopedFileSystem.ScopedFileSystemOptions(["/worktree", "/main/.git"]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git" } + }); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue(); + result.Branch.Should().Be("topic"); + result.Ref.Should().Be("fedcba987654"); + result.RepositoryName.Should().Be("commondir-test"); + } + + [Fact] + public void WorktreeMissingGitDir_ReturnsUnavailable() + { + var fs = new MockFileSystem(); + fs.AddFile("/worktree/.git", new MockFileData("gitdir: /nonexistent/.git/worktrees/wt\n")); + + var checkout = fs.DirectoryInfo.New("/worktree"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/worktree"), inner: fs); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeFalse("a worktree pointer to a missing gitdir must yield Unavailable"); + } + + [Fact] + public void MockWithNoGitLayout_ReturnsCannedTestData() + { + // Back-compat: tests that seed no .git at all must continue to receive the canned + // test instance rather than Unavailable, so existing test suites need no churn. + var fs = new MockFileSystem(); + var checkout = fs.DirectoryInfo.New("/some/path"); + var scoped = new CheckoutsFileSystem(fs.DirectoryInfo.New("/some/path"), inner: fs); + + var result = GitCheckoutInformationFactory.Create(checkout, scoped); + + result.IsAvailable.Should().BeTrue("mock with no layout falls back to canned data"); + result.Branch.Should().Be("test-e35fcb27-5f60-4e"); + result.RepositoryName.Should().Be("docs-builder"); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs index dcaf7ef439..9984c43381 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Microsoft.Extensions.Logging.Abstractions; namespace Elastic.Documentation.Configuration.Tests; @@ -107,8 +108,7 @@ public void GetProductByRepositoryName_WorksForProductsWithDisabledFeatures() private static ProductsConfiguration LoadActualProductsConfiguration() { - var fileSystem = new FileSystem(); - var provider = new ConfigurationFileProvider(new NullLoggerFactory(), fileSystem); + var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem()); var versionsConfig = provider.CreateVersionConfiguration(); return provider.CreateProducts(versionsConfig); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs b/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs index 4ea6c22fd2..f3aefa5047 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/VersionInferenceTests.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Configuration.Inference; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging.Abstractions; @@ -498,7 +499,7 @@ public void IsVersionlessCorrectlyIdentifiesAllVersionlessSystemsFromActualConfi var versionsPath = fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "versions.yml"); File.Exists(versionsPath).Should().BeTrue($"Expected versions file to exist at {versionsPath}"); - var provider = new ConfigurationFileProvider(new NullLoggerFactory(), fileSystem); + var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem()); var versionsConfig = provider.CreateVersionConfiguration(); // Verify all expected versionless systems are marked as versionless @@ -530,7 +531,7 @@ public void AllVersioningSystemsInConfigAreAccountedFor() var versionsPath = fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "versions.yml"); File.Exists(versionsPath).Should().BeTrue($"Expected versions file to exist at {versionsPath}"); - var provider = new ConfigurationFileProvider(new NullLoggerFactory(), fileSystem); + var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem()); var versionsConfig = provider.CreateVersionConfiguration(); // Count how many are versionless vs versioned diff --git a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs index bd9cb950e3..9ba1bfd67a 100644 --- a/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs @@ -9,7 +9,6 @@ using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; using Elastic.Markdown.Tests.Inline; -using Nullean.ScopedFileSystem; using Xunit; namespace Elastic.Markdown.Tests.Assembler; @@ -24,7 +23,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -59,7 +58,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -87,7 +86,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = BuildType.Assembler @@ -117,7 +116,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -146,7 +145,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -185,7 +184,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler @@ -213,7 +212,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs/platform/elasticsearch", BuildType = BuildType.Assembler diff --git a/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs b/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs index 22ff9a961d..8c640eff1d 100644 --- a/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs +++ b/tests/Elastic.Markdown.Tests/BrandingCopyTests.cs @@ -18,7 +18,7 @@ public async Task CopyBrandingResources_SeparateFileSystems_DoesNotThrow() { var logger = new TestLoggerFactory(output); - var readFs = new MockFileSystem(new Dictionary + var fs = new MockFileSystem(new Dictionary { { "docs/docset.yml", //language=yaml @@ -36,16 +36,9 @@ public async Task CopyBrandingResources_SeparateFileSystems_DoesNotThrow() CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var writeFs = new MockFileSystem(new MockFileSystemOptions - { - CurrentDirectory = Paths.WorkingDirectoryRoot.FullName - }); - await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); - var configurationContext = TestHelpers.CreateConfigurationContext(readFs); - var readScoped = FileSystemFactory.ScopeCurrentWorkingDirectory(readFs); - var writeScoped = FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(writeFs); - var context = new BuildContext(collector, readScoped, writeScoped, configurationContext, ExportOptions.Default); + var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fs), configurationContext); var linkResolver = new TestCrossLinkResolver(); var set = new DocumentationSet(context, logger, linkResolver); @@ -55,6 +48,6 @@ public async Task CopyBrandingResources_SeparateFileSystems_DoesNotThrow() await collector.StopAsync(TestContext.Current.CancellationToken); var outputStaticDir = Path.Join(set.OutputDirectory.FullName, "_static"); - writeFs.File.Exists(Path.Join(outputStaticDir, "logo.svg")).Should().BeTrue(); + fs.File.Exists(Path.Join(outputStaticDir, "logo.svg")).Should().BeTrue(); } } diff --git a/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs b/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs index 61c6ed9e09..fa708156ce 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextConfigurationFileTests.cs @@ -7,7 +7,7 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using Xunit; namespace Elastic.Markdown.Tests; @@ -33,21 +33,19 @@ public void ExplicitConfigurationFile_OverridesDefaultDiscovery() fs.AddFile(publicDocsetPath, new MockFileData("toc: []\n")); fs.AddFile(internalDocsetPath, new MockFileData("registry: internal\ntoc: []\n")); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var docFs = DocumentationFileSystem.Resolve( + fs.DirectoryInfo.New(repoPath), + new DocumentationScopeOptions + { + Inner = fs, + ConfigurationFile = internalDocsetPath, + Output = Path.Join(root, "codex-configuration-file-test-out") + }); - var context = new BuildContext( - collector, - readFs, - writeFs, - configurationContext, - ExportOptions.Default, - source: repoPath, - output: Path.Combine(root, "codex-configuration-file-test-out"), - configurationFile: readFs.FileInfo.New(internalDocsetPath)); + var context = new BuildContext(collector, docFs, configurationContext); context.ConfigurationPath.FullName.Should().Be(internalDocsetPath); context.DocumentationSourceDirectory.FullName.Should().Be(Path.Combine(repoPath, "docs-dev")); @@ -67,20 +65,18 @@ public void NoExplicitConfigurationFile_FallsBackToDefaultDiscovery() fs.AddDirectory(Path.Combine(repoPath, ".git")); fs.AddFile(publicDocsetPath, new MockFileData("toc: []\n")); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var docFs = DocumentationFileSystem.Resolve( + fs.DirectoryInfo.New(repoPath), + new DocumentationScopeOptions + { + Inner = fs, + Output = Path.Join(root, "codex-configuration-file-fallback-test-out") + }); - var context = new BuildContext( - collector, - readFs, - writeFs, - configurationContext, - ExportOptions.Default, - source: repoPath, - output: Path.Combine(root, "codex-configuration-file-fallback-test-out")); + var context = new BuildContext(collector, docFs, configurationContext); context.ConfigurationPath.FullName.Should().Be(publicDocsetPath); } diff --git a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs index d37f0b32a5..a69f2a8ba1 100644 --- a/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/BuildContextDocumentationCheckoutDirectoryTests.cs @@ -7,15 +7,23 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; using Xunit; namespace Elastic.Markdown.Tests; /// -/// Regression: Codex must pass the repository clone root as source -/// so FindGitRoot(..., ceiling: rootFolder) can see .git under the ceiling (#3115). -/// Prior bug used DocsDirectory only, capping the ceiling at the docs subtree. +/// Tests that resolves correctly +/// for various --path / source combinations. +/// +/// Key invariant: --path repo/ and --path repo/docs/ must resolve to the +/// same CheckoutDirectory because the docset scan converges first (both land on +/// repo/docs/ as the anchor), and FindGitRoot then walks at most one parent. +/// +/// +/// Regression guard (#3115): Codex passes the repository clone root as source so that +/// FindGitRoot can see .git within the default maxParents range. +/// /// public class BuildContextDocumentationCheckoutDirectoryTests(ITestOutputHelper output) { @@ -28,26 +36,24 @@ public void SourceAsRepositoryRoot_SetsDocumentationCheckoutDirectory() fs.AddDirectory(Path.Combine(repoPath, ".git")); fs.AddFile(Path.Combine(repoPath, "docs", "docset.yml"), new MockFileData("toc: []\n")); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext( - collector, - readFs, - writeFs, - configurationContext, - ExportOptions.Default, - source: repoPath, - output: Path.Combine(root, "codex-checkout-dir-test-out")); + var docFs = DocumentationFileSystem.Resolve( + fs.DirectoryInfo.New(repoPath), + new DocumentationScopeOptions + { + Inner = fs, + Output = Path.Join(root, "codex-checkout-dir-test-out") + }); + var context = new BuildContext(collector, docFs, configurationContext); Assert.NotNull(context.DocumentationCheckoutDirectory); context.DocumentationCheckoutDirectory.FullName.Should().Be(repoPath); } [Fact] - public void SourceAsDocsSubtreeOnly_LeavesDocumentationCheckoutDirectoryNull() + public void SourceAsDocsSubtree_ResolvesCheckoutFromParent() { var root = Paths.WorkingDirectoryRoot.FullName; var repoPath = Path.Combine(root, "codex-docs-only-test"); @@ -56,20 +62,55 @@ public void SourceAsDocsSubtreeOnly_LeavesDocumentationCheckoutDirectoryNull() fs.AddDirectory(Path.Combine(repoPath, ".git")); fs.AddFile(Path.Combine(docsPath, "docset.yml"), new MockFileData("toc: []\n")); - var readFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); - var writeFs = FileSystemFactory.ScopeCurrentWorkingDirectory(fs); var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fs); - var context = new BuildContext( - collector, - readFs, - writeFs, - configurationContext, - ExportOptions.Default, - source: docsPath, - output: Path.Combine(root, "codex-docs-only-test-out")); + var docFs = DocumentationFileSystem.Resolve( + fs.DirectoryInfo.New(docsPath), + new DocumentationScopeOptions + { + Inner = fs, + Output = Path.Join(root, "codex-docs-only-test-out") + }); + var context = new BuildContext(collector, docFs, configurationContext); - context.DocumentationCheckoutDirectory.Should().BeNull(); + // --path repo/docs/ now resolves the same checkout as --path repo/: + // the docset scan anchors at repo/docs/, FindGitRoot walks one parent to repo/.git + Assert.NotNull(context.DocumentationCheckoutDirectory); + context.DocumentationCheckoutDirectory.FullName.Should().Be(repoPath); + } + + [Fact] + public void PathAndDocsSubfolder_ResolveIdenticalCheckout() + { + var root = Paths.WorkingDirectoryRoot.FullName; + var repoPath = Path.Combine(root, "codex-equiv-test"); + var docsPath = Path.Combine(repoPath, "docs"); + var fs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = root }); + fs.AddDirectory(Path.Combine(repoPath, ".git")); + fs.AddFile(Path.Combine(docsPath, "docset.yml"), new MockFileData("toc: []\n")); + + var collector = new TestDiagnosticsCollector(output); + _ = collector.StartAsync(TestContext.Current.CancellationToken); + var configurationContext = TestHelpers.CreateConfigurationContext(fs); + var opts = new DocumentationScopeOptions + { + Inner = fs, + Output = Path.Combine(root, "codex-equiv-test-out") + }; + + var fsFromRepoRoot = DocumentationFileSystem.Resolve(fs.DirectoryInfo.New(repoPath), opts); + var fsFromDocsFolder = DocumentationFileSystem.Resolve(fs.DirectoryInfo.New(docsPath), opts); + var contextFromRepoRoot = new BuildContext(collector, fsFromRepoRoot, configurationContext); + var contextFromDocsFolder = new BuildContext(collector, fsFromDocsFolder, configurationContext); + + contextFromRepoRoot.DocumentationCheckoutDirectory.Should().NotBeNull(); + contextFromDocsFolder.DocumentationCheckoutDirectory.Should().NotBeNull(); + contextFromRepoRoot.DocumentationCheckoutDirectory.FullName + .Should().Be(contextFromDocsFolder.DocumentationCheckoutDirectory.FullName, + "--path repo/ and --path repo/docs/ must resolve to the same CheckoutDirectory"); + contextFromRepoRoot.DocumentationSourceDirectory.FullName + .Should().Be(contextFromDocsFolder.DocumentationSourceDirectory.FullName, + "--path repo/ and --path repo/docs/ must resolve to the same SourceDirectory"); } } diff --git a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs index 36b838c9de..18e7469627 100644 --- a/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs +++ b/tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs @@ -9,7 +9,6 @@ using Elastic.Documentation.Links.CrossLinks; using Elastic.Markdown.IO; using Elastic.Markdown.Tests.Inline; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Codex; @@ -20,7 +19,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/r/codex-environments", BuildType = BuildType.Codex @@ -58,7 +57,7 @@ protected override BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = BuildType.Isolated diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index cc0fc2c822..acb5c13fe7 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -5,6 +5,7 @@ using System.Collections.Frozen; using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Documentation.ReleaseNotes; @@ -559,6 +560,9 @@ public class ChangelogCdnInferredProductUnavailableTests(ITestOutputHelper outpu ::: """) { + // Force Unavailable so InferCdnProductFromRepository() returns null — the "could not be inferred" path. + protected override GitCheckoutInformation? GetGitCheckoutInformation() => GitCheckoutInformation.Unavailable; + [Fact] public void EmitsErrorWhenProductCannotBeInferred() { diff --git a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs index 0307f5ff3d..d705229689 100644 --- a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation.Configuration; +using Elastic.Documentation.FileSystems; using Elastic.Markdown.Myst.Directives.CsvInclude; namespace Elastic.Markdown.Tests.Directives; @@ -38,7 +39,7 @@ public CsvIncludeTests(ITestOutputHelper output) : base(output, [Fact] public void ParsesCsvDataCorrectly() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(4); csvData[0].Should().BeEquivalentTo(["Name", "Age", "City"]); csvData[1].Should().BeEquivalentTo(["John Doe", "30", "New York"]); @@ -72,7 +73,7 @@ public CsvIncludeWithOptionsTests(ITestOutputHelper output) : base(output, [Fact] public void ParsesWithCustomSeparator() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Age", "City"]); csvData[1].Should().BeEquivalentTo(["John Doe", "30", "New York"]); @@ -94,7 +95,7 @@ public CsvIncludeWithQuotesTests(ITestOutputHelper output) : base(output, [Fact] public void HandlesQuotedFieldsWithCommas() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Description", "Location"]); csvData[1].Should().BeEquivalentTo(["John Doe", "Software Engineer, Senior", "New York"]); @@ -116,7 +117,7 @@ public CsvIncludeWithEscapedQuotesTests(ITestOutputHelper output) : base(output, [Fact] public void HandlesEscapedQuotes() { - var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem)).ToList(); + var csvData = CsvReader.ReadCsvFile(Block!.CsvFilePath!, Block.Separator, CheckoutsFileSystem.FromWorkingDirectory(FileSystem)).ToList(); csvData.Should().HaveCount(3); csvData[0].Should().BeEquivalentTo(["Name", "Description"]); csvData[1].Should().BeEquivalentTo(["John Doe", "He said \"Hello World\" today"]); diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 28c57c3d8d..6add618421 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -10,7 +10,6 @@ using Elastic.Markdown.Myst.Directives; using JetBrains.Annotations; using Markdig.Syntax; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Directives; @@ -70,12 +69,12 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") var root = FileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs/")); // ReSharper disable once VirtualMemberCallInConstructor FileSystem.GenerateDocSetYaml(root, products: GetDocsetProducts(), extraYaml: GetDocsetExtraYaml()); - Collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); // ReSharper disable once VirtualMemberCallInConstructor var environment = GetEnvironment(); - var context = new BuildContext(Collector, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem), configurationContext, environment); + // ReSharper disable once VirtualMemberCallInConstructor + var context = new BuildContext(Collector, TestHelpers.CreateDocumentationFileSystem(FileSystem, root, GetGitCheckoutInformation()), configurationContext, environment); var linkResolver = new TestCrossLinkResolver(); // ReSharper disable once VirtualMemberCallInConstructor Set = new DocumentationSet(context, logger, linkResolver, GetReleaseNotesResolver()); @@ -86,6 +85,12 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } + /// + /// Override to supply an explicit for the build context. + /// Returns by default (factory produces canned test data). + /// + protected virtual GitCheckoutInformation? GetGitCheckoutInformation() => null; + /// /// Override to specify products for the docset configuration. /// Returns null by default (no products configured). diff --git a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs index 95dcc9dbea..5331eb018e 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs @@ -2,15 +2,14 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using AwesomeAssertions; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.FileSystems; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.DocSet; @@ -19,16 +18,16 @@ public class NavigationTestsBase : IAsyncLifetime protected NavigationTestsBase(ITestOutputHelper output) { LoggerFactory = new TestLoggerFactory(output); - var mockWriteFs = new MockFileSystem(new MockFileSystemOptions //use in memory mock fs to test generation + var mockWriteFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - ReadFileSystem = FileSystemFactory.RealGitRootForPath(null); - WriteFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockWriteFs); - var collector = new TestDiagnosticsCollector(output); - var configurationContext = TestHelpers.CreateConfigurationContext(ReadFileSystem); var docsTestsPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs-tests"); - var context = new BuildContext(collector, ReadFileSystem, WriteFileSystem, configurationContext, ExportOptions.Default, source: docsTestsPath) + var invocation = new System.IO.Abstractions.FileSystem().DirectoryInfo.New(docsTestsPath); + FileSystem = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { InnerWrite = mockWriteFs }); + var collector = new TestDiagnosticsCollector(output); + var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem.Read); + var context = new BuildContext(collector, FileSystem, configurationContext) { Force = false, UrlPathPrefix = null @@ -43,8 +42,7 @@ protected NavigationTestsBase(ITestOutputHelper output) protected ILoggerFactory LoggerFactory { get; } - protected ScopedFileSystem ReadFileSystem { get; set; } - protected ScopedFileSystem WriteFileSystem { get; set; } + protected DocumentationFileSystem FileSystem { get; } protected DocumentationSet Set { get; } protected DocumentationGenerator Generator { get; } protected ConfigurationFile? Configuration { get; set; } diff --git a/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs b/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs index 9cc7745dd9..0285d0ce59 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/ReportIssueUrlTests.cs @@ -7,9 +7,9 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Markdown.IO; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.DocSet; @@ -36,12 +36,12 @@ public ReportIssueUrlTests(ITestOutputHelper output) { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName }); - var readFileSystem = FileSystemFactory.RealGitRootForPath(null); - var writeFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockWriteFs); + var invocation = new System.IO.Abstractions.FileSystem().DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var fs = DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { InnerWrite = mockWriteFs }); var collector = new TestDiagnosticsCollector(output); - var configurationContext = TestHelpers.CreateConfigurationContext(readFileSystem); + var configurationContext = TestHelpers.CreateConfigurationContext(fs.Read); - var context = new BuildContext(collector, readFileSystem, writeFileSystem, configurationContext, ExportOptions.Default) + var context = new BuildContext(collector, fs, configurationContext) { Force = false, UrlPathPrefix = UrlPathPrefix, diff --git a/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs b/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs index 21ccbf2707..823e27ddc0 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/RepositoryLinksTests.cs @@ -34,8 +34,8 @@ public class GitCheckoutInformationTests(ITestOutputHelper output) : NavigationT [Fact] public void Create() { - var root = ReadFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); - var git = GitCheckoutInformationFactory.Create(root, ReadFileSystem, LoggerFactory.CreateLogger(nameof(GitCheckoutInformation))); + var root = FileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + var git = GitCheckoutInformationFactory.Create(root, FileSystem.Read, LoggerFactory.CreateLogger(nameof(GitCheckoutInformation))); git.Should().NotBeNull(); git.Branch.Should().NotBeNullOrWhiteSpace(); diff --git a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs index 07b4a2cd26..5eb70a966b 100644 --- a/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/ImagePathResolutionTests.cs @@ -15,7 +15,6 @@ using Elastic.Markdown.Myst; using Elastic.Markdown.Myst.InlineParsers; using Elastic.Markdown.Tests; -using Nullean.ScopedFileSystem; using Xunit; namespace Elastic.Markdown.Tests.Inline; @@ -93,7 +92,7 @@ private async Task ResolveUrlForBuildMode(string relativeAssetPath, Bu _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var buildContext = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + var buildContext = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs", BuildType = buildType diff --git a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs index a42ee0ea4e..bbc860da2e 100644 --- a/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Inline/InlneBaseTests.cs @@ -10,7 +10,6 @@ using JetBrains.Annotations; using Markdig.Syntax; using Markdig.Syntax.Inlines; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests.Inline; @@ -133,7 +132,7 @@ protected virtual BuildContext CreateBuildContext( TestDiagnosticsCollector collector, MockFileSystem fileSystem, IConfigurationContext configurationContext) => - new(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext) + new(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) { UrlPathPrefix = "/docs" }; diff --git a/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs b/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs index 4307bed63c..ba81aeb26f 100644 --- a/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs +++ b/tests/Elastic.Markdown.Tests/MissingTocFileTests.cs @@ -7,7 +7,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests; @@ -36,7 +35,7 @@ public void TocReferencesMissingFile_DoesNotThrow_AndEmitsClearError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); var act = () => _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); diff --git a/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs b/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs index 6d4e9221fc..682c5815cf 100644 --- a/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs +++ b/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs @@ -18,7 +18,7 @@ public async Task RelativeLinks() var workingDirectory = Set.Configuration.SourceFile.DirectoryName; Directory.SetCurrentDirectory(workingDirectory!); - var mover = new Move(LoggerFactory, ReadFileSystem, WriteFileSystem, Set); + var mover = new Move(LoggerFactory, FileSystem.Read, FileSystem.Write, Set); await mover.Execute("mover/first-page.md", "new-folder/hello-world.md", true, TestContext.Current.CancellationToken); mover.Changes.Should().HaveCount(1); @@ -47,7 +47,7 @@ public async Task MoveToFolder() var workingDirectory = Set.Configuration.SourceFile.DirectoryName; Directory.SetCurrentDirectory(workingDirectory!); - var mover = new Move(LoggerFactory, ReadFileSystem, WriteFileSystem, Set); + var mover = new Move(LoggerFactory, FileSystem.Read, FileSystem.Write, Set); await mover.Execute("mover/first-page.md", "new-folder", true, TestContext.Current.CancellationToken); mover.Changes.Should().HaveCount(1); @@ -75,7 +75,7 @@ public async Task MoveFolderToFolder() var workingDirectory = Set.Configuration.SourceFile.DirectoryName; Directory.SetCurrentDirectory(workingDirectory!); - var mover = new Move(LoggerFactory, ReadFileSystem, WriteFileSystem, Set); + var mover = new Move(LoggerFactory, FileSystem.Read, FileSystem.Write, Set); await mover.Execute("mover", "new-folder", true, TestContext.Current.CancellationToken); mover.Changes.Should().HaveCount(2); diff --git a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs index d6dfa1295c..fab4e2cedc 100644 --- a/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs +++ b/tests/Elastic.Markdown.Tests/OutputDirectoryTests.cs @@ -7,7 +7,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests; @@ -33,7 +32,7 @@ public async Task CreatesDefaultOutputDirectory() }); await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); var linkResolver = new TestCrossLinkResolver(); var set = new DocumentationSet(context, logger, linkResolver); var generator = new DocumentationGenerator(set, logger); @@ -66,7 +65,7 @@ public void FilesWithSnippetsInNameNotTreatedAsSnippets() }); var collector = new TestDiagnosticsCollector(output); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); var linkResolver = new TestCrossLinkResolver(); var set = new DocumentationSet(context, logger, linkResolver); diff --git a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs index 4c29e609e7..20cf1f4b74 100644 --- a/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs +++ b/tests/Elastic.Markdown.Tests/RootIndexValidationTests.cs @@ -8,7 +8,6 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.IO; -using Nullean.ScopedFileSystem; namespace Elastic.Markdown.Tests; @@ -34,7 +33,7 @@ public void InternalRegistry_MissingIndexMd_EmitsError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Errors.Should().BeGreaterThan(0); @@ -65,7 +64,7 @@ public void InternalRegistry_WithIndexMd_NoError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Diagnostics @@ -93,7 +92,7 @@ public void PublicRegistry_MissingIndexMd_NoError() var collector = new TestDiagnosticsCollector(output); _ = collector.StartAsync(TestContext.Current.CancellationToken); var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); - var context = new BuildContext(collector, FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), configurationContext); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext); _ = new DocumentationSet(context, logger, new TestCrossLinkResolver()); collector.Diagnostics diff --git a/tests/Elastic.Markdown.Tests/TestHelpers.cs b/tests/Elastic.Markdown.Tests/TestHelpers.cs index 2aa37abeab..07296fc9c4 100644 --- a/tests/Elastic.Markdown.Tests/TestHelpers.cs +++ b/tests/Elastic.Markdown.Tests/TestHelpers.cs @@ -4,18 +4,39 @@ using System.Collections.Frozen; using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.LegacyUrlMappings; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Configuration.Search; using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Versions; namespace Elastic.Markdown.Tests; public static class TestHelpers { + /// + /// Resolves a over for tests + /// that only need a scoped FS without real git data. Ensures a stub .git directory exists + /// at the working-directory root (no config file) so + /// GitCheckoutInformationFactory.IsLegacyTestWithoutGitLayout returns the well-known + /// canned test instance instead of failing. + /// + public static DocumentationFileSystem CreateDocumentationFileSystem( + MockFileSystem fileSystem, + IDirectoryInfo? invocation = null, + GitCheckoutInformation? git = null) + { + var gitPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, ".git"); + if (!fileSystem.Directory.Exists(gitPath)) + fileSystem.Directory.CreateDirectory(gitPath); + invocation ??= fileSystem.DirectoryInfo.New(Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs")); + return DocumentationFileSystem.Resolve(invocation, new DocumentationScopeOptions { Inner = fileSystem, Git = git }); + } + public static IConfigurationContext CreateConfigurationContext(IFileSystem fileSystem, VersionsConfiguration? versionsConfiguration = null, ProductsConfiguration? productsConfiguration = null) { versionsConfiguration ??= new VersionsConfiguration @@ -115,7 +136,7 @@ public static IConfigurationContext CreateConfigurationContext(IFileSystem fileS { Elasticsearch = ElasticsearchEndpoint.Default, }, - ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(TestContext.Current.TestOutputHelper), fileSystem), + ConfigurationFileProvider = new ConfigurationFileProvider(new TestLoggerFactory(TestContext.Current.TestOutputHelper), new ConfigurationFileSystem(fileSystem)), VersionsConfiguration = versionsConfiguration, ProductsConfiguration = productsConfiguration, SearchConfiguration = search, diff --git a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs index 508b09cd2e..fca9bab7b9 100644 --- a/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/ComplexSiteNavigationTests.cs @@ -6,6 +6,7 @@ using Elastic.Documentation.Assembler.Navigation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Leaf; @@ -38,7 +39,7 @@ public async Task MultipleSectionsFromSameRepository_UseContentHashesAndCacheByR var docset = DocumentationSetFile.LoadAndResolve( context.Collector, fileSystem.FileInfo.New($"{repositoryPath}/docs/docset.yml"), - FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); documentationSets.Add( new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance)); } @@ -101,7 +102,7 @@ public void ComplexNavigationWithMultipleNestedTocsAppliesPathPrefixToRootUrls() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -179,7 +180,7 @@ public void DeeplyNestedNavigationMaintainsPathPrefixThroughoutHierarchy() var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { @@ -230,7 +231,7 @@ public void FileNavigationLeafUrlsReflectPathPrefixInDeeplyNestedStructures() var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { @@ -292,7 +293,7 @@ public void FolderNavigationWithinNestedTocsHasCorrectPathPrefix() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, - fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { diff --git a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs index 9936f9f569..8be34fddb3 100644 --- a/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs +++ b/tests/Navigation.Tests/Assembler/IdentifierCollectionTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Node; @@ -21,7 +22,7 @@ public void DocumentationSetNavigationCollectsRootIdentifier() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Root identifier should be :// @@ -36,7 +37,7 @@ public void DocumentationSetNavigationCollectsNestedTocIdentifiers() // Test platform repository with nested TOCs var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, platformContext.ConfigurationPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, platformContext.ConfigurationPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Should collect identifiers from nested TOCs @@ -57,7 +58,7 @@ public void DocumentationSetNavigationWithSimpleStructure() // Test observability repository (no nested TOCs) var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); // Should only have root identifier @@ -74,7 +75,7 @@ public void TableOfContentsNavigationHasCorrectIdentifier() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); // Get the deployment-guide TOC @@ -97,13 +98,13 @@ public void MultipleDocumentationSetsHaveDistinctIdentifiers() var platformContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/platform", output); var platformDocset = DocumentationSetFile.LoadAndResolve( - platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); var observabilityContext = SiteNavigationTestFixture.CreateContext( fileSystem, "/checkouts/current/observability", output); var observabilityDocset = DocumentationSetFile.LoadAndResolve( - observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); // Each should have its own set of identifiers diff --git a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs index 821abe8497..2861cdc71c 100644 --- a/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteDocumentationSetsTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Leaf; @@ -47,7 +48,7 @@ public void CreatesDocumentationSetNavigationsFromCheckoutFolders() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -84,15 +85,15 @@ public void SiteNavigationIntegratesWithDocumentationSets() var documentationSets = new List(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); documentationSets.Add(new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance)); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); documentationSets.Add(new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance)); var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); - var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); documentationSets.Add(new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance)); // Create site navigation context (using any repository's filesystem) @@ -134,7 +135,7 @@ public void SiteNavigationWithNestedTocs() // Create DocumentationSetNavigation for platform var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); platformNav.Url.Should().Be("/"); platformNav.Index.Url.Should().Be("/"); @@ -201,7 +202,7 @@ public void SiteNavigationWithAllRepositories() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); documentationSets.Add(navigation); @@ -241,7 +242,7 @@ public void DocumentationSetNavigationHasCorrectStructure() // Test observability repository structure var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); observabilityNav.NavigationTitle.Should().Be(observabilityNav.NavigationTitle); @@ -270,7 +271,7 @@ public void DocumentationSetWithNestedTocs() // Test platform repository with nested TOCs var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); platformNav.NavigationTitle.Should().Be("Platform"); @@ -300,7 +301,7 @@ public void DocumentationSetWithUnderscoreDocset() // Test serverless-security repository with _docset.yml var securityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); - var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var securityDocset = DocumentationSetFile.LoadAndResolve(securityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var securityNav = new DocumentationSetNavigation(securityDocset, securityContext, GenericDocumentationFileFactory.Instance); securityNav.NavigationTitle.Should().Be("Serverless Security"); @@ -331,7 +332,7 @@ public void SiteNavigationAppliesPathPrefixToAllUrls() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); @@ -368,7 +369,7 @@ public void SiteNavigationWithNestedTocsAppliesCorrectPathPrefixes() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var platformContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateAssemblerContext(fileSystem, "/checkouts/current/platform", output); @@ -399,7 +400,7 @@ public void SiteNavigationRequiresPathPrefix() var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var documentationSets = new List { new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance) }; var siteContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); @@ -422,7 +423,7 @@ public void ObservabilityDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -442,7 +443,7 @@ public void ServerlessSearchDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -462,7 +463,7 @@ public void ServerlessSecurityDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-security", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/serverless-security/docs/_docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -482,7 +483,7 @@ public void PlatformDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -502,7 +503,7 @@ public void ElasticsearchReferenceDocumentationSetNavigationHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/elasticsearch-reference", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/elasticsearch-reference/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -533,7 +534,7 @@ public void AllDocumentationSetsHaveNoDiagnostics() ? $"{repo.FullName}/docs/docset.yml" : $"{repo.FullName}/docs/_docset.yml"; - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, fileSystem.FileInfo.New(docsetPath), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -554,7 +555,7 @@ public void DocumentationSetNavigationWithNestedTocsHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); @@ -580,7 +581,7 @@ public void DocumentationSetNavigationWithFoldersHasNoDiagnostics() var context = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); var docsetPath = fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"); - var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var docset = DocumentationSetFile.LoadAndResolve(context.Collector, docsetPath, new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var navigation = new DocumentationSetNavigation(docset, context, GenericDocumentationFileFactory.Instance); diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs index a9555fb552..7a15d0e7a8 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTestFixture.cs @@ -34,6 +34,7 @@ private static void SetupServerlessObservabilityRepository(MockFileSystem fileSy { var baseDir = "/checkouts/current/observability"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml // language=yaml @@ -68,6 +69,7 @@ private static void SetupServerlessSearchRepository(MockFileSystem fileSystem) { var baseDir = "/checkouts/current/serverless-search"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml // language=yaml @@ -100,6 +102,7 @@ private static void SetupServerlessSecurityRepository(MockFileSystem fileSystem) { var baseDir = "/checkouts/current/serverless-security"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml with underscore prefix // language=yaml @@ -132,6 +135,7 @@ private static void SetupPlatformRepository(MockFileSystem fileSystem) { var baseDir = "/checkouts/current/platform"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml // language=yaml @@ -186,6 +190,7 @@ private static void SetupElasticsearchReferenceRepository(MockFileSystem fileSys { var baseDir = "/checkouts/current/elasticsearch-reference"; fileSystem.AddDirectory(baseDir); + fileSystem.AddDirectory($"{baseDir}/.git"); // Add docset.yml // language=yaml diff --git a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs index c79316636d..b82724fffc 100644 --- a/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs +++ b/tests/Navigation.Tests/Assembler/SiteNavigationTests.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Assembler; using Elastic.Documentation.Navigation.Isolated; using Elastic.Documentation.Navigation.Isolated.Node; @@ -42,11 +43,11 @@ public void ConstructorCreatesSiteNavigation() // Create DocumentationSetNavigation instances for the referenced repos var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav, searchNav }; @@ -80,7 +81,7 @@ public void SiteNavigationWithNestedChildren() // Create DocumentationSetNavigation for platform var platformContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/platform", output); - var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var platformDocset = DocumentationSetFile.LoadAndResolve(platformContext.Collector, fileSystem.FileInfo.New("/checkouts/current/platform/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var platformNav = new DocumentationSetNavigation(platformDocset, platformContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { platformNav }; @@ -118,7 +119,7 @@ public void SitePrefixNormalizesSlashes(string? sitePrefix, string expectedRootU var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav }; @@ -154,7 +155,7 @@ public void SitePrefixAppliedToNavigationItemUrls(string? sitePrefix, string exp var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav }; @@ -213,11 +214,11 @@ public void NavigationNodeIdsAreUniqueAcrossDocsets() // Create navigation for both docsets var productAContext = SiteNavigationTestFixture.CreateContext(fileSystem, productADir, output); - var productADocsetFile = DocumentationSetFile.LoadAndResolve(productAContext.Collector, fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var productADocsetFile = DocumentationSetFile.LoadAndResolve(productAContext.Collector, fileSystem.FileInfo.New($"{productADir}/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var productANav = new DocumentationSetNavigation(productADocsetFile, productAContext, GenericDocumentationFileFactory.Instance); var productBContext = SiteNavigationTestFixture.CreateContext(fileSystem, productBDir, output); - var productBDocsetFile = DocumentationSetFile.LoadAndResolve(productBContext.Collector, fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var productBDocsetFile = DocumentationSetFile.LoadAndResolve(productBContext.Collector, fileSystem.FileInfo.New($"{productBDir}/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var productBNav = new DocumentationSetNavigation(productBDocsetFile, productBContext, GenericDocumentationFileFactory.Instance); // Get the "getting-started" folders from each docset @@ -285,11 +286,11 @@ public void SitePrefixAppliedToMultipleNavigationItems(string? sitePrefix, strin var fileSystem = SiteNavigationTestFixture.CreateMultiRepositoryFileSystem(); var observabilityContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/observability", output); - var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var observabilityDocset = DocumentationSetFile.LoadAndResolve(observabilityContext.Collector, fileSystem.FileInfo.New("/checkouts/current/observability/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var observabilityNav = new DocumentationSetNavigation(observabilityDocset, observabilityContext, GenericDocumentationFileFactory.Instance); var searchContext = SiteNavigationTestFixture.CreateContext(fileSystem, "/checkouts/current/serverless-search", output); - var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), FileSystemFactory.ScopeSourceDirectory(fileSystem, "/checkouts")); + var searchDocset = DocumentationSetFile.LoadAndResolve(searchContext.Collector, fileSystem.FileInfo.New("/checkouts/current/serverless-search/docs/docset.yml"), new CheckoutsFileSystem(fileSystem.DirectoryInfo.New("/checkouts"), inner: fileSystem)); var searchNav = new DocumentationSetNavigation(searchDocset, searchContext, GenericDocumentationFileFactory.Instance); var documentationSets = new List { observabilityNav, searchNav }; diff --git a/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs b/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs index e18dc14018..7049993ca9 100644 --- a/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs +++ b/tests/Navigation.Tests/Codex/CodexConfigurationLoaderTests.cs @@ -6,7 +6,7 @@ using AwesomeAssertions; using Elastic.Codex; using Elastic.Documentation.Configuration; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -21,8 +21,8 @@ public class CodexConfigurationLoaderTests(ITestOutputHelper output) private static readonly string ConfigPath = Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml"); - private ScopedFileSystem ScopedFs(MockFileSystem mockFs) => - FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs); + private CheckoutsFileSystem ScopedFs(MockFileSystem mockFs) => + CheckoutsFileSystem.FromWorkingDirectory(mockFs); private TestDiagnosticsCollector Collector() => new(output); diff --git a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs index ea50bacdde..cc5bbef219 100644 --- a/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs +++ b/tests/Navigation.Tests/Codex/CodexNavigationTestBase.cs @@ -9,9 +9,9 @@ using Elastic.Documentation.Configuration.Codex; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Isolated.Node; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -79,8 +79,7 @@ internal sealed class TestCodexDocumentationContext(IDiagnosticsCollector collec public IFileInfo ConfigurationPath => _fileSystem.FileInfo.New(_fileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, "codex.yml")); public IDiagnosticsCollector Collector => collector; - public ScopedFileSystem ReadFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectory(_fileSystem); - public ScopedFileSystem WriteFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(_fileSystem); + public DocumentationWriteFileSystem WriteFileSystem => new(_fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fileSystem); public IDirectoryInfo OutputDirectory => _fileSystem.DirectoryInfo.New(_fileSystem.Path.Join(Paths.ApplicationData.FullName, "codex", "output")); public BuildType BuildType => BuildType.Codex; diff --git a/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs b/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs index 36f00b3902..d936af460f 100644 --- a/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs +++ b/tests/Navigation.Tests/Codex/FindDocsetFileTests.cs @@ -6,7 +6,7 @@ using AwesomeAssertions; using Elastic.Codex.Sourcing; using Elastic.Documentation.Configuration; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -16,8 +16,8 @@ public class FindDocsetFileTests private static readonly string RepoRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, "repo"); - private static ScopedFileSystem CreateScopedFs(MockFileSystem mockFs) => - FileSystemFactory.ScopeCurrentWorkingDirectory(mockFs); + private static CheckoutsFileSystem CreateScopedFs(MockFileSystem mockFs) => + CheckoutsFileSystem.FromWorkingDirectory(mockFs); [Fact] public void StandardPath_Found() diff --git a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs index f692922cdd..7a2de3d6b6 100644 --- a/tests/Navigation.Tests/Codex/GroupNavigationTests.cs +++ b/tests/Navigation.Tests/Codex/GroupNavigationTests.cs @@ -5,7 +5,7 @@ using AwesomeAssertions; using Elastic.Codex.Navigation; using Elastic.Documentation.Configuration; -using Nullean.ScopedFileSystem; +using Elastic.Documentation.FileSystems; namespace Elastic.Documentation.Navigation.Tests.Codex; @@ -138,8 +138,7 @@ private sealed class MinimalCodexContext : ICodexDocumentationContext private readonly System.IO.Abstractions.TestingHelpers.MockFileSystem _fs = new(); public System.IO.Abstractions.IFileInfo ConfigurationPath => _fs.FileInfo.New("/codex.yml"); public Elastic.Documentation.Diagnostics.IDiagnosticsCollector Collector => new Elastic.Documentation.Diagnostics.DiagnosticsCollector([]); - public ScopedFileSystem ReadFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectory(_fs); - public ScopedFileSystem WriteFileSystem => FileSystemFactory.ScopeCurrentWorkingDirectoryForWrite(_fs); + public DocumentationWriteFileSystem WriteFileSystem => new(_fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, _fs); public System.IO.Abstractions.IDirectoryInfo OutputDirectory => _fs.DirectoryInfo.New("/output"); public BuildType BuildType => BuildType.Codex; public void EmitError(string message) { } diff --git a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs index ea7bc41fbe..811ad530fe 100644 --- a/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs +++ b/tests/Navigation.Tests/Isolation/PhysicalDocsetTests.cs @@ -7,6 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Navigation.Isolated.Leaf; using Elastic.Documentation.Navigation.Isolated.Node; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -28,7 +29,7 @@ public async Task PhysicalDocsetCanBeNavigated() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, context.ReadFileSystem, noSuppress: [HintType.DeepLinkingVirtualFile]); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, context.ReadFileSystem as ScopedFileSystem, noSuppress: [HintType.DeepLinkingVirtualFile]); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); @@ -68,7 +69,7 @@ public async Task PhysicalDocsetNavigationHasCorrectUrls() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealGitRootForPath(null)); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -94,7 +95,7 @@ public async Task PhysicalDocsetNavigationIncludesNestedTocs() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealGitRootForPath(null)); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); @@ -126,7 +127,7 @@ public async Task PhysicalDocsetNavigationHandlesHiddenFiles() var configPath = fileSystem.FileInfo.New(docsetPath); var context = new TestDocumentationSetContext(fileSystem, docsDir, outputDir, configPath, output, "docs-builder"); - var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, FileSystemFactory.RealGitRootForPath(null)); + var docSet = DocumentationSetFile.LoadAndResolve(context.Collector, configPath, DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName)); _ = context.Collector.StartAsync(TestContext.Current.CancellationToken); var navigation = new DocumentationSetNavigation(docSet, context, TestDocumentationFileFactory.Instance); diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs index 37e5458055..b7c299cd00 100644 --- a/tests/Navigation.Tests/TestDocumentationSetContext.cs +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -8,13 +8,13 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; +using Elastic.Documentation.FileSystems; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation.Isolated; using Markdig; using Markdig.Parsers; using Markdig.Syntax; using Markdig.Syntax.Inlines; -using Nullean.ScopedFileSystem; namespace Elastic.Documentation.Navigation.Tests; @@ -83,8 +83,8 @@ public TestDocumentationSetContext(IFileSystem fileSystem, TestDiagnosticsCollector? collector = null ) { - ReadFileSystem = FileSystemFactory.ScopeSourceDirectory(fileSystem, sourceDirectory.FullName); - WriteFileSystem = FileSystemFactory.ScopeSourceDirectoryForWrite(fileSystem, outputDirectory.FullName); + ReadFileSystem = DocumentationFileSystem.Resolve(sourceDirectory, new DocumentationScopeOptions { Inner = fileSystem, ConfigurationFile = configPath.FullName }); + WriteFileSystem = new DocumentationWriteFileSystem(sourceDirectory, outputDirectory, fileSystem); DocumentationSourceDirectory = sourceDirectory; OutputDirectory = outputDirectory; ConfigurationPath = configPath; @@ -102,8 +102,8 @@ public TestDocumentationSetContext(IFileSystem fileSystem, } public IDiagnosticsCollector Collector { get; } - public ScopedFileSystem ReadFileSystem { get; } - public ScopedFileSystem WriteFileSystem { get; } + public IDocumentationFileSystem ReadFileSystem { get; } + public DocumentationWriteFileSystem WriteFileSystem { get; } public IDirectoryInfo OutputDirectory { get; } public IDirectoryInfo DocumentationSourceDirectory { get; } public GitCheckoutInformation Git { get; } diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index 53b55f939f..7d58587158 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -7,6 +7,7 @@ namespace authoring open System open System.Collections.Generic open System.Collections.Frozen +open System.IO.Abstractions open System.IO.Abstractions.TestingHelpers open Elastic.Documentation.Diagnostics open Elastic.Documentation.Links @@ -15,6 +16,7 @@ open Elastic.Documentation open Swensen.Unquote open Elastic.Documentation.Configuration open Elastic.Documentation.Configuration.Builder +open Elastic.Documentation.FileSystems open authoring module CrossLinkResolverAssertions = @@ -31,8 +33,8 @@ module CrossLinkResolverAssertions = member _.Collector = collector member _.DocumentationSourceDirectory = mockFileSystem.DirectoryInfo.New("/docs") member _.Git = GitCheckoutInformation.Unavailable - member _.ReadFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem) - member _.WriteFileSystem = FileSystemFactory.ScopeCurrentWorkingDirectory(mockFileSystem) + member _.ReadFileSystem = DocumentationFileSystem.Resolve(mockFileSystem.DirectoryInfo.New("/docs"), DocumentationScopeOptions(Inner = (mockFileSystem :> IFileSystem), ConfigurationFile = "/docs/docset.yml")) :> IDocumentationFileSystem + member _.WriteFileSystem = DocumentationWriteFileSystem(mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, mockFileSystem) member _.ConfigurationPath = mockFileSystem.FileInfo.New("mock_docset.yml") member _.OutputDirectory = mockFileSystem.DirectoryInfo.New(".artifacts") member _.BuildType = BuildType.Isolated diff --git a/tests/authoring/Framework/Setup.fs b/tests/authoring/Framework/Setup.fs index 96605871f5..a62ca2458a 100644 --- a/tests/authoring/Framework/Setup.fs +++ b/tests/authoring/Framework/Setup.fs @@ -9,10 +9,12 @@ open System open System.Collections.Frozen open System.Collections.Generic open System.IO +open System.IO.Abstractions open System.IO.Abstractions.TestingHelpers open System.Threading.Tasks open YamlDotNet.RepresentationModel open Elastic.Documentation +open Elastic.Documentation.FileSystems open Elastic.Documentation.Versions open Elastic.Documentation.Configuration open Elastic.Documentation.Configuration.LegacyUrlMappings @@ -467,7 +469,7 @@ type Setup = DisplayName = "Elastic Cloud Control ECCTL", VersioningSystem = versionConfig.VersioningSystems[VersioningSystemId.Ecctl])) - let configurationFileProvider = ConfigurationFileProvider(new TestLoggerFactory(), fileSystem) + let configurationFileProvider = ConfigurationFileProvider(new TestLoggerFactory(), ConfigurationFileSystem(fileSystem)) let configurationContext = ConfigurationContext( VersionsConfiguration = versionConfig, ConfigurationFileProvider = configurationFileProvider, @@ -479,9 +481,18 @@ type Setup = LegacyUrlMappings = LegacyUrlMappingConfiguration(Mappings = []), SearchConfiguration = SearchConfiguration(Synonyms = Array.empty, Rules = [], DiminishTerms = []) ) + // A bare .git directory (no config) anchors FindGitRoot so checkout = WorkingDirectoryRoot, + // which makes OutputDirectory = WorkingDirectoryRoot/.artifacts/docs/html (not docs/.artifacts). + // The missing config triggers IsLegacyTestWithoutGitLayout → canned data, but we override that + // with Git = Unavailable because authoring tests exercise rendering, not real git metadata. + let gitPath = Path.Combine(Paths.WorkingDirectoryRoot.FullName, ".git") + if not (fileSystem.Directory.Exists gitPath) then + fileSystem.Directory.CreateDirectory gitPath |> ignore + let invocation = fileSystem.DirectoryInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs/")) + let docFs = DocumentationFileSystem.Resolve(invocation, DocumentationScopeOptions(Inner = (fileSystem :> IFileSystem), Git = GitCheckoutInformation.Unavailable)) let context = BuildContext( collector, - FileSystemFactory.ScopeCurrentWorkingDirectory(fileSystem), + docFs, configurationContext, UrlPathPrefix = (options.UrlPathPrefix |> Option.defaultValue ""), CanonicalBaseUrl = Uri("https://www.elastic.co/") @@ -499,7 +510,7 @@ type Setup = ConversionCollector= conversionCollector Set = set Generator = generator - ReadFileSystem = fileSystem + ReadFileSystem = docFs :> IDocumentationFileSystem WriteFileSystem = fileSystem } context.Bootstrap() diff --git a/tests/authoring/Framework/TestValues.fs b/tests/authoring/Framework/TestValues.fs index b2be8ff49a..4359970a3f 100644 --- a/tests/authoring/Framework/TestValues.fs +++ b/tests/authoring/Framework/TestValues.fs @@ -7,6 +7,7 @@ namespace authoring open System open System.Collections.Concurrent open System.IO.Abstractions +open Elastic.Documentation.FileSystems open Elastic.Documentation.Diagnostics open Elastic.Markdown open Elastic.Markdown.IO @@ -94,7 +95,7 @@ and MarkdownTestContext = ConversionCollector: TestConversionCollector Set: DocumentationSet Generator: DocumentationGenerator - ReadFileSystem: IFileSystem + ReadFileSystem: IDocumentationFileSystem WriteFileSystem: IFileSystem }