File: Caching\AppHostInfoDiskCacheTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using Aspire.Cli.Caching;
using Aspire.Cli.Configuration;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Caching;
 
public class AppHostInfoDiskCacheTests(ITestOutputHelper outputHelper)
{
    private static AppHostInfoDiskCache CreateCache(TemporaryWorkspace workspace, IConfigurationService? configurationService = null)
    {
        var ctx = workspace.CreateExecutionContext();
        return new AppHostInfoDiskCache(NullLogger<AppHostInfoDiskCache>.Instance, ctx, configurationService ?? new TestConfigurationService(), new TestEnvironment());
    }
 
    private static AppHostInfoDiskCache CreateCacheWithRealConfigurationService(TemporaryWorkspace workspace, Dictionary<string, string?>? processConfigurationValues = null)
    {
        var ctx = workspace.CreateExecutionContext();
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(processConfigurationValues ?? new Dictionary<string, string?>())
            .Build();
        var globalSettingsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.global.json"));
        var configurationService = new ConfigurationService(configuration, ctx, globalSettingsFile, NullLogger<ConfigurationService>.Instance);
        return new AppHostInfoDiskCache(NullLogger<AppHostInfoDiskCache>.Instance, ctx, configurationService, new TestEnvironment());
    }
 
    private static FileInfo CreateProjectFile(TemporaryWorkspace workspace, string name = "Test.AppHost.csproj")
    {
        var path = Path.Combine(workspace.WorkspaceRoot.FullName, name);
        File.WriteAllText(path, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
        return new FileInfo(path);
    }
 
    private static AppHostInfoCacheEntry SampleEntry() => new()
    {
        ExitCode = 0,
        IsAspireHost = true,
        AspireHostingVersion = "9.5.0",
        IsUsingCliBundle = false,
        UserSecretsId = "12345",
        RunCommand = "/repo/bin/AppHost",
        TargetPath = "/repo/bin/AppHost.dll",
        RunWorkingDirectory = "/repo/src/AppHost",
        RunArguments = "--from-msbuild",
        TargetFramework = "net10.0",
        TargetFrameworks = "net10.0;net9.0",
    };
 
    private static IEnumerable<string> EnumerateCacheEntries(TemporaryWorkspace workspace)
    {
        var cacheDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cache", "apphost-info");
        return Directory.Exists(cacheDirectory) ? Directory.EnumerateFiles(cacheDirectory) : [];
    }
 
    [Fact]
    public async Task CacheMissThenHit()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace);
 
        var miss = await cache.TryGetAsync(projectFile, CancellationToken.None).DefaultTimeout();
        Assert.Null(miss);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        // FileInfo caches metadata on first stat, so create a fresh instance after the write.
        var freshProject = new FileInfo(projectFile.FullName);
        var hit = await cache.TryGetAsync(freshProject, CancellationToken.None).DefaultTimeout();
        Assert.NotNull(hit);
        Assert.True(hit!.IsAspireHost);
        Assert.Equal("9.5.0", hit.AspireHostingVersion);
        Assert.Equal("12345", hit.UserSecretsId);
        Assert.Equal("/repo/bin/AppHost", hit.RunCommand);
        Assert.Equal("/repo/bin/AppHost.dll", hit.TargetPath);
        Assert.Equal("/repo/src/AppHost", hit.RunWorkingDirectory);
        Assert.Equal("--from-msbuild", hit.RunArguments);
        Assert.Equal("net10.0", hit.TargetFramework);
    }
 
    [Fact]
    public async Task CacheEntryMissingRunCommandIsTreatedAsMiss()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry() with { RunCommand = null }, CancellationToken.None).DefaultTimeout();
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
 
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task CachePayloadContainsOnlyProjectInspectionMetadata()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace);
 
        await cache.SetAsync(
            projectFile,
            cache.GetCacheKey(projectFile),
            SampleEntry() with { IsUsingCliBundle = true },
            CancellationToken.None).DefaultTimeout();
 
        var cachePath = Assert.Single(EnumerateCacheEntries(workspace));
        using var document = JsonDocument.Parse(await File.ReadAllTextAsync(cachePath));
        var propertyNames = document.RootElement
            .EnumerateObject()
            .Select(property => property.Name)
            .Order(StringComparer.Ordinal)
            .ToArray();
 
        Assert.Equal(
            [
                "aspireHostingVersion",
                "exitCode",
                "isAspireHost",
                "isUsingCliBundle",
                "runArguments",
                "runCommand",
                "runWorkingDirectory",
                "schemaVersion",
                "targetFramework",
                "targetFrameworks",
                "targetPath",
                "userSecretsId",
            ],
            propertyNames);
    }
 
    [Fact]
    public async Task TouchingProjectFileInvalidatesCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        // Make sure the mtime tick actually changes (filesystem resolution can be coarse).
        await Task.Delay(50).DefaultTimeout();
        File.WriteAllText(projectFile.FullName, "<Project Sdk=\"Microsoft.NET.Sdk\" />\n<!-- edit -->");
        File.SetLastWriteTimeUtc(projectFile.FullName, DateTime.UtcNow.AddSeconds(2));
 
        var freshProject = new FileInfo(projectFile.FullName);
        var hit = await cache.TryGetAsync(freshProject, CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task TouchingProjectAssetsJsonInvalidatesCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        // Simulate a `dotnet restore` writing obj/project.assets.json next to the .csproj.
        var objDir = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "obj"));
        var assetsPath = Path.Combine(objDir.FullName, "project.assets.json");
        File.WriteAllText(assetsPath, "{}");
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task TouchingDirectoryPackagesPropsInvalidatesCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        // Drop a Directory.Packages.props next to the project; the cache key walks up to
        // catch this even when nothing else changed.
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"), "<Project />");
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task DisabledCacheNeverReadsOrWrites()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName), """
        {
          "dotnetAppHostInfoCacheDisabled": "true"
        }
        """);
        var cache = CreateCacheWithRealConfigurationService(workspace);
        var projectFile = CreateProjectFile(workspace);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
        var hit = await cache.TryGetAsync(projectFile, CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
 
        // Nothing should have been written to disk.
        var cacheDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cache", "apphost-info");
        Assert.False(Directory.Exists(cacheDir) && Directory.EnumerateFiles(cacheDir).Any());
    }
 
    [Fact]
    public async Task ProcessConfigurationValueDoesNotDisableCache()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCacheWithRealConfigurationService(workspace, new Dictionary<string, string?>
        {
            ["dotnetAppHostInfoCacheDisabled"] = "true",
        });
        var projectFile = CreateProjectFile(workspace);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
        var hit = await cache.TryGetAsync(projectFile, CancellationToken.None).DefaultTimeout();
 
        Assert.NotNull(hit);
    }
 
    [Fact]
    public async Task ConcurrentWritesToSameKeyLeaveReadableCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace);
 
        await Task.WhenAll(Enumerable.Range(0, 20).Select(i =>
        {
            var entry = SampleEntry() with
            {
                AspireHostingVersion = $"9.5.{i}",
                UserSecretsId = $"secrets-{i}",
            };
            return cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), entry, CancellationToken.None);
        })).DefaultTimeout();
 
        var cacheDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cache", "apphost-info");
        Assert.Empty(Directory.EnumerateFiles(cacheDir, "*.tmp"));
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.NotNull(hit);
        Assert.StartsWith("9.5.", hit!.AspireHostingVersion, StringComparison.Ordinal);
        Assert.StartsWith("secrets-", hit.UserSecretsId, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task SetAsync_SkipsWriteWhenProjectKeyChangesAfterEvaluation()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace);
        var expectedKey = cache.GetCacheKey(projectFile);
 
        await Task.Delay(50).DefaultTimeout();
        File.WriteAllText(projectFile.FullName, "<Project Sdk=\"Microsoft.NET.Sdk\" />\n<!-- edit -->");
        File.SetLastWriteTimeUtc(projectFile.FullName, DateTime.UtcNow.AddSeconds(2));
 
        await cache.SetAsync(new FileInfo(projectFile.FullName), expectedKey, SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task EditingCustomWalkUpImportInvalidatesCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // The prefilter admits this project because of the walk-up import, and MSBuild resolves that import
        // to the Aspire.Common.props two directories above. Flipping IsAspireHost inside that file changes
        // the answer without touching the .csproj, obj/project.assets.json, or any Directory.Build.* /
        // Directory.Packages.* / global.json the conventional walk stats — so unless the custom import is
        // fingerprinted too, the stale "not an AppHost" entry wins forever and the AppHost stays
        // undiscoverable across CLI invocations.
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "src", "MyHost"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "MyHost.csproj"));
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Aspire.Common.props', '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        var commonProps = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Common.props");
        File.WriteAllText(commonProps, "<Project />");
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry() with { IsAspireHost = false }, CancellationToken.None).DefaultTimeout();
        Assert.NotNull(await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout());
 
        File.WriteAllText(commonProps, """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        File.SetLastWriteTimeUtc(commonProps, DateTime.UtcNow.AddSeconds(2));
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task UnfingerprintableWalkUpImportNeverReadsOrWrites()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // Here the searched file name is itself an MSBuild expression, so no filesystem walk can predict
        // which file the import binds to and no mtime can represent it. Caching such a project would make an
        // edit to that unknown file permanently invisible, so the disk cache must opt out entirely.
        var projectFile = CreateProjectFile(workspace, "MyHost.csproj");
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetPathOfFileAbove($(SharedPropsFileName), '$(MSBuildThisFileDirectory)../'))" />
            </Project>
            """);
 
        await cache.SetAsync(new FileInfo(projectFile.FullName), cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
 
        Assert.Null(hit);
 
        Assert.Empty(EnumerateCacheEntries(workspace));
    }
 
    [Fact]
    public async Task EditingExactStaticImportInvalidatesCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // The nearest Directory.Build.props carries no marker but imports a Directory.Build.props from a
        // SIBLING directory, so the prefilter cannot prove the project is not an AppHost and admits it.
        // 'shared' is not an ancestor of the project, so the conventional walk never stats the file MSBuild
        // actually evaluates; only fingerprinting the exact resolved path notices the edit.
        //   <root>/shared/Directory.Build.props   the file that decides the verdict
        //   <root>/repo/Directory.Build.props     imports $(MSBuildThisFileDirectory)../shared/Directory.Build.props
        //   <root>/repo/proj/MyHost.csproj
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "proj"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "MyHost.csproj"));
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
 
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$(MSBuildThisFileDirectory)../shared/Directory.Build.props" />
            </Project>
            """);
 
        var sharedDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "shared"));
        var sharedProps = Path.Combine(sharedDirectory.FullName, "Directory.Build.props");
        File.WriteAllText(sharedProps, "<Project />");
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry() with { IsAspireHost = false }, CancellationToken.None).DefaultTimeout();
        Assert.NotNull(await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout());
 
        File.WriteAllText(sharedProps, """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        File.SetLastWriteTimeUtc(sharedProps, DateTime.UtcNow.AddSeconds(2));
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task EditingExactStaticImportWithRedundantSeparatorInvalidatesCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "proj"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "MyHost.csproj"));
        File.WriteAllText(projectFile.FullName, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$(MSBuildThisFileDirectory)/../shared/Directory.Build.props" />
            </Project>
            """);
 
        var sharedDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "shared"));
        var sharedProps = Path.Combine(sharedDirectory.FullName, "Directory.Build.props");
        File.WriteAllText(sharedProps, "<Project />");
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry() with { IsAspireHost = false }, CancellationToken.None).DefaultTimeout();
        Assert.NotNull(await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout());
 
        File.WriteAllText(sharedProps, """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        File.SetLastWriteTimeUtc(sharedProps, DateTime.UtcNow.AddSeconds(2));
 
        Assert.Null(await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout());
    }
 
    [Fact]
    public async Task WildcardStaticConventionalImportNeverReadsOrWrites()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "proj"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "MyHost.csproj"));
        File.WriteAllText(projectFile.FullName, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="../Directory.Build.p*" />
            </Project>
            """);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        Assert.Null(await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout());
        Assert.Empty(EnumerateCacheEntries(workspace));
    }
 
    [Fact]
    public async Task UnresolvableStaticImportNeverReadsOrWrites()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // <Import Project="$(RepoRoot)Directory.Build.props" /> names a conventional file, so the prefilter
        // admits the project, but only MSBuild knows which directory $(RepoRoot) expands to. There is no
        // path to stat, so the disk cache must opt out instead of storing an entry that an edit to that file
        // could never invalidate.
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "proj"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "MyHost.csproj"));
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
 
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "Directory.Build.props"), """
            <Project>
              <Import Project="$(RepoRoot)Directory.Build.props" />
            </Project>
            """);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
 
        Assert.Null(hit);
        Assert.Empty(EnumerateCacheEntries(workspace));
    }
 
    [Fact]
    public async Task EditingAppendedWalkUpImportTargetInvalidatesCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // GetPathOfFileAbove returns a FILE path, so trailing text concatenates onto the file name rather
        // than naming a separate file: the import below resolves to <root>/Aspire.Common.props, not to
        // 'Aspire.Common' plus '.props'. Recording those two pieces separately fingerprints neither the file
        // MSBuild reads nor anything that changes when it is edited.
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "src", "MyHost"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "MyHost.csproj"));
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetPathOfFileAbove('Aspire.Common', '$(MSBuildThisFileDirectory)../')).props" />
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Common"), "");
        var commonProps = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Common.props");
        File.WriteAllText(commonProps, "<Project />");
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry() with { IsAspireHost = false }, CancellationToken.None).DefaultTimeout();
        Assert.NotNull(await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout());
 
        File.WriteAllText(commonProps, """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        File.SetLastWriteTimeUtc(commonProps, DateTime.UtcNow.AddSeconds(2));
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task WildcardWalkUpImportNeverReadsOrWrites()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // A glob suffix expands to every match at evaluation time, so the import has no single target to
        // stat and creating a new matching file would not move any tracked mtime.
        var projectFile = CreateProjectFile(workspace, "MyHost.csproj");
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)../', 'Repo.marker'))/*.props" />
            </Project>
            """);
 
        await cache.SetAsync(new FileInfo(projectFile.FullName), cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
 
        Assert.Null(hit);
        Assert.Empty(EnumerateCacheEntries(workspace));
    }
 
    [Fact]
    public async Task WrappedWalkUpImportNeverReadsOrWrites()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
        var projectFile = CreateProjectFile(workspace, "MyHost.csproj");
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([System.String]::Concat($([MSBuild]::GetPathOfFileAbove('Anchor', '$(MSBuildThisFileDirectory)../')), '.props'))" />
            </Project>
            """);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        Assert.Null(await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout());
        Assert.Empty(EnumerateCacheEntries(workspace));
    }
 
    [Fact]
    public async Task ShadowedAncestorWithUnfingerprintableImportStillCaches()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // The nearest Directory.Build.props declares no chaining import, so MSBuild never evaluates the
        // outer one and its unresolvable import cannot affect this project. Analyzing every ancestor level
        // instead of the reachable chain would disable caching here for no reason.
        //   <root>/Directory.Build.props        imports $(SharedPropsDir)Directory.Build.props (shadowed)
        //   <root>/repo/Directory.Build.props   no marker, no imports — terminates the chain
        //   <root>/repo/proj/MyHost.csproj
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "proj"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "MyHost.csproj"));
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk" />
            """);
 
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Build.props"), """
            <Project>
              <Import Project="$(SharedPropsDir)Directory.Build.props" />
            </Project>
            """);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "repo", "Directory.Build.props"), """
            <Project>
              <PropertyGroup>
                <SomeUnrelated>1</SomeUnrelated>
              </PropertyGroup>
            </Project>
            """);
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.NotNull(hit);
    }
 
    [Fact]
    public async Task EditingAppendedDirectoryNameWalkUpTargetInvalidatesCacheEntry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // The mirror image of the GetPathOfFileAbove case: GetDirectoryNameOfFileAbove returns a DIRECTORY,
        // so the suffix must start with a separator and names a file BESIDE the anchor rather than a
        // continuation of the anchor's name. The import below resolves to <root>/Custom.props, selected by
        // <root>/Repo.marker. Both names have to be fingerprinted — the anchor because it decides which
        // ancestor directory is chosen, Custom.props because it is the file MSBuild actually reads.
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "src", "MyHost"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "MyHost.csproj"));
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)../', 'Repo.marker'))/Custom.props" />
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "Repo.marker"), "");
        var customProps = Path.Combine(workspace.WorkspaceRoot.FullName, "Custom.props");
        File.WriteAllText(customProps, "<Project />");
 
        await cache.SetAsync(projectFile, cache.GetCacheKey(projectFile), SampleEntry() with { IsAspireHost = false }, CancellationToken.None).DefaultTimeout();
        Assert.NotNull(await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout());
 
        File.WriteAllText(customProps, """
            <Project>
              <PropertyGroup>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
        File.SetLastWriteTimeUtc(customProps, DateTime.UtcNow.AddSeconds(2));
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.Null(hit);
    }
 
    [Fact]
    public async Task NestedSuffixWalkUpImportNeverReadsOrWrites()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // A suffix that descends into a sub-directory ('/build/Custom.props') resolves to a file whose
        // directory the ancestor walk never enumerates, so statting the leaf name at every ancestor level
        // would miss it. There is no single name to fingerprint, so the cache must opt out.
        var projectFile = CreateProjectFile(workspace, "MyHost.csproj");
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove('$(MSBuildThisFileDirectory)../', 'Repo.marker'))/build/Custom.props" />
            </Project>
            """);
 
        await cache.SetAsync(new FileInfo(projectFile.FullName), cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
 
        Assert.Null(hit);
        Assert.Empty(EnumerateCacheEntries(workspace));
    }
 
    [Fact]
    public async Task UnresolvableStaticImportInProjectFileStillCaches()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cache = CreateCache(workspace);
 
        // Only walk-up imports inside the .csproj itself can promote it (IsLikelyAppHost step 1b), so a
        // static import there never changes the prefilter's verdict and must not widen the cache bypass.
        // Ordinary static imports remain this cache's documented pre-existing limitation; they are no worse
        // here than for any other project.
        var projectFile = CreateProjectFile(workspace, "Test.AppHost.csproj");
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Import Project="$(RepoRoot)Directory.Build.props" />
            </Project>
            """);
 
        await cache.SetAsync(new FileInfo(projectFile.FullName), cache.GetCacheKey(projectFile), SampleEntry(), CancellationToken.None).DefaultTimeout();
 
        var hit = await cache.TryGetAsync(new FileInfo(projectFile.FullName), CancellationToken.None).DefaultTimeout();
        Assert.NotNull(hit);
    }
 
    [Fact]
    public void ComputeKey_IsStableForUnchangedInputs()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = CreateProjectFile(workspace);
 
        var key1 = AppHostInfoDiskCache.ComputeKeyAsync(new FileInfo(projectFile.FullName), new TestEnvironment());
        var key2 = AppHostInfoDiskCache.ComputeKeyAsync(new FileInfo(projectFile.FullName), new TestEnvironment());
        Assert.Equal(key1, key2);
        Assert.NotEmpty(key1);
    }
 
    [Fact]
    public void ComputeKey_DiffersByProjectPath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectA = CreateProjectFile(workspace, "A.csproj");
        var projectB = CreateProjectFile(workspace, "B.csproj");
 
        var keyA = AppHostInfoDiskCache.ComputeKeyAsync(projectA, new TestEnvironment());
        var keyB = AppHostInfoDiskCache.ComputeKeyAsync(projectB, new TestEnvironment());
        Assert.NotEqual(keyA, keyB);
    }
 
    [Fact]
    public void ComputeKey_IsCaseInsensitiveForPath()
    {
        Assert.SkipWhen(!OperatingSystem.IsWindows(),
            "Path casing normalization only applies on Windows.");
 
        // On Windows the same physical project is reached through paths that differ only by case —
        // VS Code launches the CLI with a lowercase drive letter ("c:\...") while a terminal uses an
        // uppercase one ("C:\..."), and segment casing can vary too. Because the filesystem is
        // case-insensitive, all of these must derive the same cache key, otherwise a terminal-
        // populated entry is invisible to a later read for the same project and the AppHost is
        // needlessly re-evaluated. The cache key lowercases the whole path on Windows, so vary both
        // the drive letter and the rest of the path here to mirror that scenario.
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = CreateProjectFile(workspace);
 
        var fullPath = projectFile.FullName;
        var lowerPath = fullPath.ToLowerInvariant();
        var upperDrivePath = char.ToUpperInvariant(fullPath[0]) + fullPath[1..].ToUpperInvariant();
 
        var keyLower = AppHostInfoDiskCache.ComputeKeyAsync(new FileInfo(lowerPath), new TestEnvironment());
        var keyUpper = AppHostInfoDiskCache.ComputeKeyAsync(new FileInfo(upperDrivePath), new TestEnvironment());
 
        Assert.Equal(keyLower, keyUpper);
    }
 
    [Fact]
    public void ComputeKey_WalksAboveTenParentDirectories()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var projectDir = workspace.WorkspaceRoot.FullName;
        for (var i = 0; i < 11; i++)
        {
            projectDir = Directory.CreateDirectory(Path.Combine(projectDir, $"level-{i}")).FullName;
        }
 
        var projectFile = new FileInfo(Path.Combine(projectDir, "Deep.AppHost.csproj"));
        File.WriteAllText(projectFile.FullName, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
 
        var keyBeforeImport = AppHostInfoDiskCache.ComputeKeyAsync(projectFile, new TestEnvironment());
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Build.props"), "<Project />");
        var keyAfterImport = AppHostInfoDiskCache.ComputeKeyAsync(new FileInfo(projectFile.FullName), new TestEnvironment());
 
        Assert.NotEqual(keyBeforeImport, keyAfterImport);
    }
 
    [Fact]
    public void ComputeKey_WalksPastGitFileBoundary()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var parentImport = Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Build.props");
        File.WriteAllText(parentImport, "<Project />");
 
        var repoRoot = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "worktree-root"));
        File.WriteAllText(
            Path.Combine(repoRoot.FullName, ".git"),
            $"gitdir: {Path.Combine(workspace.WorkspaceRoot.FullName, ".git", "worktrees", "worktree-root")}");
 
        var projectDir = Directory.CreateDirectory(Path.Combine(repoRoot.FullName, "src", "AppHost"));
        var projectFile = new FileInfo(Path.Combine(projectDir.FullName, "AppHost.csproj"));
        File.WriteAllText(projectFile.FullName, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
 
        var keyBeforeParentImportEdit = AppHostInfoDiskCache.ComputeKeyAsync(projectFile, new TestEnvironment());
 
        File.WriteAllText(parentImport, "<Project><!-- above git boundary edit --></Project>");
        File.SetLastWriteTimeUtc(parentImport, DateTime.UtcNow.AddSeconds(2));
 
        var keyAfterParentImportEdit = AppHostInfoDiskCache.ComputeKeyAsync(new FileInfo(projectFile.FullName), new TestEnvironment());
 
        // MSBuild's Directory.Build.* discovery has no .git boundary, so a Directory.Build.props above a
        // nested .git can still influence evaluation. The fingerprint walk mirrors that range (and
        // DotNetAppHostProject.IsLikelyAppHost's ancestor walk), so editing the file above the .git must
        // change the key — otherwise a classifier that promoted the project would reuse a stale entry.
        Assert.NotEqual(keyBeforeParentImportEdit, keyAfterParentImportEdit);
    }
}