File: DistributedApplicationBuilderTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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.Reflection;
using System.Reflection.Emit;
using System.Security.Cryptography;
using System.Text;
using Aspire.Hosting.Ats;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Devcontainers;
using Aspire.Hosting.Lifecycle;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Pipelines.Internal;
using Aspire.Hosting.Utils;
using Aspire.Shared.UserSecrets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.EnvironmentVariables;
using Microsoft.Extensions.Configuration.UserSecrets;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Tests;
 
[Trait("Partition", "5")]
public class DistributedApplicationBuilderTests(ITestOutputHelper outputHelper)
{
    private static readonly ConstructorInfo s_userSecretsIdAttrCtor = typeof(UserSecretsIdAttribute).GetConstructor([typeof(string)])!;
 
    [Theory]
    [InlineData(new string[0], DistributedApplicationOperation.Run)]
    [InlineData(new string[] { "--publisher", "manifest" }, DistributedApplicationOperation.Publish)]
    public void BuilderExecutionContextExposesCorrectOperation(string[] args, DistributedApplicationOperation operation)
    {
        var builder = DistributedApplication.CreateBuilder(args);
        Assert.Equal(operation, builder.ExecutionContext.Operation);
    }
 
    [Fact]
    public void BuilderAddsDefaultServices()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
 
        appBuilder.Services.Configure<DcpOptions>(o =>
        {
            o.DashboardPath = "dashboard";
            o.CliPath = "dcp";
        });
 
        using var app = appBuilder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        Assert.Empty(appModel.Resources);
 
        var eventingSubscribers = app.Services.GetServices<IDistributedApplicationEventingSubscriber>();
        Assert.Collection(
            eventingSubscribers,
            s => Assert.IsType<DashboardEventHandlers>(s),
            s => Assert.IsType<DevcontainerPortForwardingEventingSubscriber>(s),
            s => Assert.IsType<RequiredCommandValidationEventingSubscriber>(s),
            s => Assert.IsType<DotnetBuildCommandEventingSubscriber>(s),
            s => Assert.IsType<TerminalHostEventingSubscriber>(s)
        );
 
        var options = app.Services.GetRequiredService<IOptions<PipelineOptions>>();
        Assert.Null(options.Value.OutputPath);
    }
 
    [Fact]
    public void BuilderAddsResourceToAddModel()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
        appBuilder.AddResource(new TestResource());
        using var app = appBuilder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var resource = Assert.Single(appModel.Resources);
        Assert.IsType<TestResource>(resource);
    }
 
    [Fact]
    public void BuilderConfiguresPublishingOptionsFromCommandLine()
    {
        var appBuilder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", "/tmp/"]);
        using var app = appBuilder.Build();
 
        var pipelineOptions = app.Services.GetRequiredService<IOptions<PipelineOptions>>();
        Assert.Equal("/tmp/", pipelineOptions.Value.OutputPath);
    }
 
    [Fact]
    public void BuilderConfiguresPublishingOptionsFromConfig()
    {
        var appBuilder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", "/tmp/"]);
        appBuilder.Configuration["Pipeline:OutputPath"] = "/path/";
        using var app = appBuilder.Build();
 
        var pipelineOptions = app.Services.GetRequiredService<IOptions<PipelineOptions>>();
        Assert.Equal("/path/", pipelineOptions.Value.OutputPath);
    }
 
    [Fact]
    public void AppHostDirectoryAvailableViaConfig()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
        var appHostDirectory = appBuilder.AppHostDirectory;
        using var app = appBuilder.Build();
 
        var config = app.Services.GetRequiredService<IConfiguration>();
        Assert.Equal(appHostDirectory, config["AppHost:Directory"]);
    }
 
    [Fact]
    public async Task PolyglotBuilderLoadsAppSettingsFromProjectDirectory()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var projectDirectory = workspace.WorkspaceRoot.FullName;
 
        await File.WriteAllTextAsync(
            Path.Combine(projectDirectory, "appsettings.json"),
            """
            {
              "Validation": {
                "BaseOnly": "base",
                "Override": "base"
              },
              "Logging": {
                "LogLevel": {
                  "Microsoft.AspNetCore": "Debug",
                  "Aspire.Hosting.Dcp": "Trace"
                }
              }
            }
            """);
        await File.WriteAllTextAsync(
            Path.Combine(projectDirectory, "appsettings.PolyglotTest.json"),
            """
            {
              "Validation": {
                "Override": "environment"
              }
            }
            """);
 
        var appBuilder = DistributedApplication.CreateBuilder(new CreateBuilderOptions
        {
            Args = ["--environment", "PolyglotTest"],
            ProjectDirectory = projectDirectory,
            AppHostFilePath = Path.Combine(projectDirectory, "apphost.mts")
        });
 
        Assert.Equal(projectDirectory, appBuilder.Environment.ContentRootPath);
        Assert.Equal("base", appBuilder.Configuration["Validation:BaseOnly"]);
        Assert.Equal("environment", appBuilder.Configuration["Validation:Override"]);
        Assert.Equal("Debug", appBuilder.Configuration["Logging:LogLevel:Microsoft.AspNetCore"]);
        Assert.Equal("Trace", appBuilder.Configuration["Logging:LogLevel:Aspire.Hosting.Dcp"]);
    }
 
    [Fact]
    public async Task PolyglotBuilderPreservesManagedLoggingDefaultsAndAllowsAppSettingsOverrides()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var projectDirectory = workspace.WorkspaceRoot.FullName;
 
        await File.WriteAllTextAsync(
            Path.Combine(projectDirectory, "appsettings.json"),
            """
            {
              "Logging": {
                "LogLevel": {
                  "Aspire.Hosting.Dcp": "Trace"
                }
              }
            }
            """);
 
        var appBuilder = DistributedApplication.CreateBuilder(new CreateBuilderOptions
        {
            ProjectDirectory = projectDirectory,
            AppHostFilePath = Path.Combine(projectDirectory, "apphost.mts")
        });
        using var app = appBuilder.Build();
        var filterOptions = app.Services.GetRequiredService<IOptions<LoggerFilterOptions>>().Value;
 
        Assert.Equal(
            [LogLevel.Warning, LogLevel.Warning],
            filterOptions.Rules
                .Where(rule => rule.ProviderName is null && rule.CategoryName == "Microsoft.AspNetCore")
                .Select(rule => rule.LogLevel));
        Assert.Equal(
            [LogLevel.Trace, LogLevel.Trace],
            filterOptions.Rules
                .Where(rule => rule.ProviderName is null && rule.CategoryName == "Aspire.Hosting.Dcp")
                .Select(rule => rule.LogLevel));
    }
 
    [Fact]
    public void PipelineOutputServiceUsesAppHostDirectoryByDefault()
    {
        var projectDirectory = OperatingSystem.IsWindows() ? @"C:\projects\Tailspin" : "/projects/Tailspin";
        var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
        {
            ProjectDirectory = projectDirectory
        });
        using var app = appBuilder.Build();
 
        var outputService = app.Services.GetRequiredService<IPipelineOutputService>();
        Assert.Equal(Path.Combine(projectDirectory, "aspire-output"), outputService.GetOutputDirectory());
    }
 
    [Fact]
    public void PipelineOutputServiceIgnoresInvalidAppHostDirectoryWhenOutputPathSpecified()
    {
        var outputPath = OperatingSystem.IsWindows() ? @"C:\tmp\output" : "/tmp/output";
        var appBuilder = DistributedApplication.CreateBuilder(["--publisher", "manifest", "--output-path", outputPath]);
        appBuilder.Configuration["AppHost:Directory"] = "\0";
        using var app = appBuilder.Build();
 
        var outputService = app.Services.GetRequiredService<IPipelineOutputService>();
        Assert.Equal(Path.GetFullPath(outputPath), outputService.GetOutputDirectory());
    }
 
    [Fact]
    public void PipelineOutputServiceFallsBackToCurrentDirectoryWhenAppHostDirectoryIsInvalid()
    {
        var appBuilder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]);
        appBuilder.Configuration["AppHost:Directory"] = "\0";
        using var app = appBuilder.Build();
 
        var outputService = app.Services.GetRequiredService<IPipelineOutputService>();
        Assert.Equal(Path.Combine(Environment.CurrentDirectory, "aspire-output"), outputService.GetOutputDirectory());
    }
 
    [Fact]
    public void ResourceServiceConfig_Secured()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
        using var app = appBuilder.Build();
 
        var config = app.Services.GetRequiredService<IConfiguration>();
        Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), config["AppHost:ResourceService:AuthMode"]);
        Assert.False(string.IsNullOrEmpty(config["AppHost:ResourceService:ApiKey"]));
    }
 
    [Fact]
    public void AspireLogLevelOverridesConfiguredDefaultLogLevel()
    {
        var appBuilder = DistributedApplication.CreateBuilder(args: [$"{KnownConfigNames.AspireLogLevel}=Trace"]);
        appBuilder.Configuration["Logging:LogLevel:Default"] = "Information";
 
        using var app = appBuilder.Build();
 
        var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("AspireLogLevelTest");
        Assert.True(logger.IsEnabled(LogLevel.Trace));
    }
 
    [Fact]
    public void HttpClientFactoryDebugLogsAreFilteredFromAllProviders()
    {
        var testSink = new TestSink();
        var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
        {
            Args = [$"{KnownConfigNames.AspireLogLevel}=Trace"],
            DisableDashboard = true
        });
        appBuilder.Services.AddSingleton<ILoggerProvider>(new TestLoggerProvider(testSink));
        using var app = appBuilder.Build();
 
        var factory = app.Services.GetRequiredService<ILoggerFactory>();
        factory.CreateLogger("Microsoft.Extensions.Http.DefaultHttpClientFactory").LogDebug("HTTP cleanup");
        factory.CreateLogger("Microsoft.Extensions.Http.DefaultHttpClientFactory").LogWarning("HTTP warning");
        factory.CreateLogger("Microsoft.Extensions.Http.DefaultHttpClientFactory.Handlers").LogInformation("HTTP nested");
        factory.CreateLogger("Microsoft.Extensions.Http.DefaultHttpClientFactory").LogError("HTTP error");
        factory.CreateLogger("Microsoft.Extensions.Http.OtherCategory").LogDebug("Other HTTP debug");
 
        var backchannel = app.Services.GetRequiredService<BackchannelLoggerProvider>();
        var (entries, subscriberId, _) = backchannel.Subscribe();
        backchannel.Unsubscribe(subscriberId);
 
        Assert.Collection(entries,
            entry => Assert.Equal("HTTP warning", entry.Message),
            entry => Assert.Equal("HTTP nested", entry.Message),
            entry => Assert.Equal("HTTP error", entry.Message),
            entry => Assert.Equal("Other HTTP debug", entry.Message));
        Assert.Equal(
            ["HTTP warning", "HTTP nested", "HTTP error", "Other HTTP debug"],
            testSink.Writes
                .Select(write => write.Message)
                .Where(message => message is "HTTP cleanup" or "HTTP warning" or "HTTP nested" or "HTTP error" or "Other HTTP debug"));
    }
 
    [Fact]
    public void PolyglotAppHostUsesAspireUserSecretsIdForUserSecretsManager()
    {
        var userSecretsId = Guid.NewGuid().ToString("N");
        var legacyUserSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(userSecretsId);
        var aspireSecretsPath = AspireSecretsPathHelper.GetSecretsFilePath(
            AspireSecretsPathHelper.GetDefaultHomeDirectory(),
            userSecretsId,
            "Production");
 
        DeleteUserSecretsFile(legacyUserSecretsPath);
        DeleteFileIfExists(aspireSecretsPath);
 
        try
        {
            var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
            {
                Args = [$"{KnownConfigNames.AspireUserSecretsId}={userSecretsId}"],
                DisableDashboard = true,
            });
 
            Assert.True(appBuilder.UserSecretsManager.IsAvailable);
            Assert.Equal(aspireSecretsPath, appBuilder.UserSecretsManager.FilePath);
        }
        finally
        {
            DeleteFileIfExists(aspireSecretsPath);
        }
    }
 
    [Fact]
    public void UserSecretsManagerDoesNotDeleteLegacyUserSecretsOutsideDevelopment()
    {
        var userSecretsId = Guid.NewGuid().ToString("N");
        var legacyUserSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(userSecretsId);
        var aspireSecretsPath = AspireSecretsPathHelper.GetSecretsFilePath(
            AspireSecretsPathHelper.GetDefaultHomeDirectory(),
            userSecretsId,
            "Production");
 
        DeleteUserSecretsFile(legacyUserSecretsPath);
        DeleteFileIfExists(aspireSecretsPath);
 
        File.WriteAllText(legacyUserSecretsPath, """
            {
              "LegacyOnly": "legacy"
            }
            """);
 
        try
        {
            var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
            {
                Args = [$"{KnownConfigNames.AspireUserSecretsId}={userSecretsId}"],
                DisableDashboard = true,
            });
 
            Assert.Equal(aspireSecretsPath, appBuilder.UserSecretsManager.FilePath);
            Assert.True(appBuilder.UserSecretsManager.TryDeleteSecret("LegacyOnly"));
 
            var legacyJson = File.ReadAllText(legacyUserSecretsPath);
            Assert.Contains("LegacyOnly", legacyJson, StringComparison.Ordinal);
        }
        finally
        {
            DeleteFileIfExists(legacyUserSecretsPath);
            DeleteFileIfExists(aspireSecretsPath);
        }
    }
 
    [Fact]
    public void AspireUserSecretsIdOverridesAppHostAssemblyUserSecretsId()
    {
        var assemblyUserSecretsId = Guid.NewGuid().ToString("N");
        var configuredUserSecretsId = Guid.NewGuid().ToString("N");
        var assemblyUserSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(assemblyUserSecretsId);
        var configuredUserSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(configuredUserSecretsId);
 
        DeleteUserSecretsFile(assemblyUserSecretsPath);
        DeleteUserSecretsFile(configuredUserSecretsPath);
 
        File.WriteAllText(assemblyUserSecretsPath, """
            {
              "AssemblyOnly": "assembly-only",
              "Probe": "assembly"
            }
            """);
 
        File.WriteAllText(configuredUserSecretsPath, """
            {
              "ConfiguredOnly": "configured-only",
              "Probe": "configured"
            }
            """);
 
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new AssemblyName($"TestAssembly{Guid.NewGuid():N}"),
            AssemblyBuilderAccess.RunAndCollect,
            [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [assemblyUserSecretsId])]);
 
        try
        {
            var configuration = new ConfigurationManager();
            configuration.AddUserSecrets(testAssembly);
            configuration[KnownConfigNames.AspireUserSecretsId] = configuredUserSecretsId;
 
            Assert.Equal(configuredUserSecretsId, DistributedApplicationBuilder.ResolveUserSecretsId(testAssembly, configuration));
 
            DistributedApplicationBuilder.AddConfiguredUserSecrets(configuration, testAssembly, configuredUserSecretsId, isDevelopment: true);
 
            Assert.Null(configuration["AssemblyOnly"]);
            Assert.Equal("configured-only", configuration["ConfiguredOnly"]);
            Assert.Equal("configured", configuration["Probe"]);
        }
        finally
        {
            DeleteUserSecretsFile(assemblyUserSecretsPath);
            DeleteUserSecretsFile(configuredUserSecretsPath);
        }
    }
 
    [Fact]
    public void EmptyAspireUserSecretsIdFallsBackToAppHostAssemblyUserSecretsId()
    {
        var assemblyUserSecretsId = Guid.NewGuid().ToString("N");
        var testAssembly = AssemblyBuilder.DefineDynamicAssembly(
            new AssemblyName($"TestAssembly{Guid.NewGuid():N}"),
            AssemblyBuilderAccess.RunAndCollect,
            [new CustomAttributeBuilder(s_userSecretsIdAttrCtor, [assemblyUserSecretsId])]);
 
        var configuration = new ConfigurationManager();
        configuration[KnownConfigNames.AspireUserSecretsId] = "";
 
        Assert.Equal(assemblyUserSecretsId, DistributedApplicationBuilder.ResolveUserSecretsId(testAssembly, configuration));
    }
 
    [Theory]
    [InlineData(KnownConfigNames.AspireSecretsFile, "SECRETS_FILE")]
    [InlineData(KnownConfigNames.AspireUserSecretsId, "USER_SECRETS_ID")]
    public void AspirePrefixedConfigurationValueReadsOnlyCapturedEnvironmentProvider(string key, string strippedKey)
    {
        var provider = new TestEnvironmentVariablesConfigurationProvider(
            new Dictionary<string, string?> { [strippedKey] = "from-aspire-environment" });
        provider.Load();
        var configuration = new ConfigurationManager
        {
            [strippedKey] = "unrelated-value"
        };
 
        Assert.Equal("from-aspire-environment", DistributedApplicationBuilder.GetAspirePrefixedConfigurationValue(configuration, key, provider));
    }
 
    [Theory]
    [InlineData(KnownConfigNames.AspireSecretsFile, "SECRETS_FILE")]
    [InlineData(KnownConfigNames.AspireUserSecretsId, "USER_SECRETS_ID")]
    public void AspirePrefixedConfigurationValuePrefersExactConfigurationKey(string key, string strippedKey)
    {
        var provider = new TestEnvironmentVariablesConfigurationProvider(
            new Dictionary<string, string?> { [strippedKey] = "from-aspire-environment" });
        provider.Load();
        var configuration = new ConfigurationManager
        {
            [strippedKey] = "unrelated-value",
            [key] = "from-exact-key"
        };
 
        Assert.Equal("from-exact-key", DistributedApplicationBuilder.GetAspirePrefixedConfigurationValue(configuration, key, provider));
    }
 
    [Theory]
    [InlineData("json")]
    [InlineData("command-line")]
    [InlineData("in-memory")]
    public void AspirePrefixedConfigurationValueIgnoresUnrelatedStrippedKeys(string source)
    {
        var configuration = new ConfigurationManager();
        var values = new Dictionary<string, string?>
        {
            ["SECRETS_FILE"] = "unrelated-file",
            ["USER_SECRETS_ID"] = "unrelated-id"
        };
        using var json = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("""
            {
              "SECRETS_FILE": "unrelated-file",
              "USER_SECRETS_ID": "unrelated-id"
            }
            """));
        switch (source)
        {
            case "json":
                configuration.AddJsonStream(json);
                break;
            case "command-line":
                configuration.AddCommandLine(["SECRETS_FILE=unrelated-file", "USER_SECRETS_ID=unrelated-id"]);
                break;
            case "in-memory":
                configuration.AddInMemoryCollection(values);
                break;
        }
 
        var provider = new TestEnvironmentVariablesConfigurationProvider(new Dictionary<string, string?>());
        provider.Load();
        Assert.Equal("unrelated-file", configuration["SECRETS_FILE"]);
        Assert.Equal("unrelated-id", configuration["USER_SECRETS_ID"]);
        Assert.Null(DistributedApplicationBuilder.GetAspirePrefixedConfigurationValue(configuration, KnownConfigNames.AspireSecretsFile, provider));
        Assert.Null(DistributedApplicationBuilder.GetAspirePrefixedConfigurationValue(configuration, KnownConfigNames.AspireUserSecretsId, provider));
        Assert.Null(DistributedApplicationBuilder.ResolveUserSecretsId(null, configuration, provider));
    }
 
    [Fact]
    public void AspireUserSecretsIdReadsCapturedEnvironmentProvider()
    {
        var configuration = new ConfigurationManager
        {
            ["USER_SECRETS_ID"] = "unrelated-id"
        };
        var provider = new TestEnvironmentVariablesConfigurationProvider(
            new Dictionary<string, string?> { ["USER_SECRETS_ID"] = "aspire-id" });
        provider.Load();
 
        Assert.Equal("aspire-id", DistributedApplicationBuilder.ResolveUserSecretsId(null, configuration, provider));
    }
 
    [Fact]
    public void UnrelatedSecretsFileDoesNotAffectAppHostStartup()
    {
        var tempDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var unrelatedFile = Path.Combine(tempDirectory.FullName, "unrelated.json");
            File.WriteAllText(unrelatedFile, "not JSON");
 
            var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
            {
                Args = [$"SECRETS_FILE={unrelatedFile}"],
                DisableDashboard = true,
            });
 
            Assert.Equal(unrelatedFile, appBuilder.Configuration["SECRETS_FILE"]);
            Assert.NotEqual(unrelatedFile, appBuilder.UserSecretsManager.FilePath);
        }
        finally
        {
            tempDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void AspireSecretsFileLoadsInProduction()
    {
        var tempDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var secretsFile = Path.Combine(tempDirectory.FullName, "Production.json");
            File.WriteAllText(secretsFile, """
                {
                  "Parameters:api_key": "from-secret"
                }
                """);
 
            var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
            {
                Args = [$"{KnownConfigNames.AspireSecretsFile}={secretsFile}"],
                DisableDashboard = true,
            });
 
            Assert.Equal("Production", appBuilder.Environment.EnvironmentName);
            Assert.Equal("from-secret", appBuilder.Configuration["Parameters:api_key"]);
        }
        finally
        {
            tempDirectory.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData("Development")]
    [InlineData("Staging")]
    [InlineData("Production")]
    [InlineData("development")]
    [InlineData("STAGING")]
    [InlineData("production")]
    public void AspireSecretsIdentityUsesFinalHostEnvironment(string environment)
    {
        var userSecretsId = Guid.NewGuid().ToString("N");
        var homeDirectory = AspireSecretsPathHelper.GetDefaultHomeDirectory();
        var secretsDirectory = AspireSecretsPathHelper.GetSecretsDirectoryPath(homeDirectory, userSecretsId);
        try
        {
            foreach (var name in new[] { "Development", "Staging", "Production" })
            {
                var store = new SecretsStore(AspireSecretsPathHelper.GetSecretsFilePath(homeDirectory, userSecretsId, name));
                store.Set("Parameters:api_key", $"{name}-secret");
                store.Save();
            }
 
            var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
            {
                Args = [$"{KnownConfigNames.AspireUserSecretsId}={userSecretsId}", "--environment", environment],
                DisableDashboard = true,
            });
 
            var storedEnvironment = new[] { "Development", "Staging", "Production" }
                .Single(name => name.Equals(environment, StringComparison.OrdinalIgnoreCase));
            Assert.Equal(environment, appBuilder.Environment.EnvironmentName);
            Assert.Equal($"{storedEnvironment}-secret", appBuilder.Configuration["Parameters:api_key"]);
            Assert.Equal(AspireSecretsPathHelper.GetSecretsFilePath(homeDirectory, userSecretsId, storedEnvironment),
                appBuilder.UserSecretsManager.FilePath);
        }
        finally
        {
            Directory.Delete(secretsDirectory, recursive: true);
        }
    }
 
    [Theory]
    [InlineData("prod/eu")]
    [InlineData("Production.")]
    [InlineData("CON")]
    [InlineData("Local Development")]
    public void AspireSecretsIdentitySkipsUnsupportedEnvironmentNames(string environment)
    {
        var userSecretsId = Guid.NewGuid().ToString("N");
        Assert.Null(DistributedApplicationBuilder.ResolveAspireSecretsFilePath(null, userSecretsId, environment));
        var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
        {
            Args = [$"{KnownConfigNames.AspireUserSecretsId}={userSecretsId}", "--environment", environment],
            DisableDashboard = true,
        });
        Assert.Equal(environment, appBuilder.Environment.EnvironmentName);
        Assert.False(appBuilder.UserSecretsManager.IsAvailable);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("set")]
    [InlineData("delete")]
    public async Task UserSecretsStatePreservesJsonTypesAcrossReload(string? secretOperation)
    {
        var directory = Directory.CreateTempSubdirectory();
        try
        {
            var path = Path.Combine(directory.FullName, "state.json");
            var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
            {
                Args = [$"{KnownConfigNames.AspireSecretsFile}={path}", "--environment", "QA.EU"],
                DisableDashboard = true,
            });
            var state = System.Text.Json.Nodes.JsonNode.Parse("""
                {
                  "Parameters:Target": "original",
                  "Resource": {
                    "Count": 42,
                    "Enabled": true,
                    "Ratio": 1.25,
                    "Missing": null,
                    "Text": "value"
                  }
                }
                """)!.AsObject();
            await builder.UserSecretsManager.SaveStateAsync(state);
 
            if (secretOperation == "set")
            {
                Assert.True(builder.UserSecretsManager.TrySetSecret("parameters:TARGET", "updated"));
            }
            else if (secretOperation == "delete")
            {
                Assert.True(builder.UserSecretsManager.TryDeleteSecret("parameters:TARGET"));
            }
 
            var manager = new Aspire.Hosting.Pipelines.Internal.UserSecretsDeploymentStateManager(
                Microsoft.Extensions.Logging.Abstractions.NullLogger<Aspire.Hosting.Pipelines.Internal.UserSecretsDeploymentStateManager>.Instance,
                builder.UserSecretsManager);
            var section = await manager.AcquireSectionAsync("Resource");
            Assert.Equal(42, section.Data["Count"]!.GetValue<int>());
            Assert.True(section.Data["Enabled"]!.GetValue<bool>());
            Assert.Equal(1.25, section.Data["Ratio"]!.GetValue<double>());
            Assert.True(section.Data.ContainsKey("Missing"));
            Assert.Null(section.Data["Missing"]);
            Assert.Equal("value", section.Data["Text"]!.GetValue<string>());
            Assert.Equal(path, builder.UserSecretsManager.FilePath);
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task LegacyDevelopmentImportPreservesDeploymentStateTypes()
    {
        var directory = Directory.CreateTempSubdirectory();
        try
        {
            var legacyPath = Path.Combine(directory.FullName, "legacy.json");
            var aspirePath = Path.Combine(directory.FullName, "Development.json");
            const string legacyJson = """
                {
                  "Resource": {
                    "Count": 42,
                    "Enabled": true,
                    "Ratio": 1.25,
                    "Missing": null,
                    "Text": "value"
                  }
                }
                """;
            await File.WriteAllTextAsync(legacyPath, legacyJson);
 
            DistributedApplicationBuilder.ImportLegacyUserSecretsIfNeeded(legacyPath, aspirePath, isDevelopment: true);
 
            var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
            {
                Args = [$"{KnownConfigNames.AspireSecretsFile}={aspirePath}", "--environment", "Development"],
                DisableDashboard = true,
            });
            var manager = new Aspire.Hosting.Pipelines.Internal.UserSecretsDeploymentStateManager(
                Microsoft.Extensions.Logging.Abstractions.NullLogger<Aspire.Hosting.Pipelines.Internal.UserSecretsDeploymentStateManager>.Instance,
                builder.UserSecretsManager);
            var section = await manager.AcquireSectionAsync("Resource");
            Assert.Equal(42, section.Data["Count"]!.GetValue<int>());
            Assert.True(section.Data["Enabled"]!.GetValue<bool>());
            Assert.Equal(1.25, section.Data["Ratio"]!.GetValue<double>());
            Assert.True(section.Data.ContainsKey("Missing"));
            Assert.Null(section.Data["Missing"]);
            Assert.Equal("value", section.Data["Text"]!.GetValue<string>());
            Assert.Equal(legacyJson, await File.ReadAllTextAsync(legacyPath));
 
            await File.WriteAllTextAsync(legacyPath, """{"Resource:Count":0}""");
            DistributedApplicationBuilder.ImportLegacyUserSecretsIfNeeded(legacyPath, aspirePath, isDevelopment: true);
            Assert.Equal(42, System.Text.Json.Nodes.JsonNode.Parse(await File.ReadAllTextAsync(aspirePath))!["Resource:Count"]!.GetValue<int>());
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void CommandLineOverridesAspireSecretsFile()
    {
        var tempDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var secretsFile = Path.Combine(tempDirectory.FullName, "Production.json");
            File.WriteAllText(secretsFile, """
                {
                  "Parameters:api_key": "from-secret"
                }
                """);
 
            var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
            {
                Args =
                [
                    $"{KnownConfigNames.AspireSecretsFile}={secretsFile}",
                    "Parameters:api_key=from-command-line"
                ],
                DisableDashboard = true,
            });
 
            Assert.Equal("from-command-line", appBuilder.Configuration["Parameters:api_key"]);
        }
        finally
        {
            tempDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void EnvironmentVariableOverridesAspireSecretsFile()
    {
        var tempDirectory = Directory.CreateTempSubdirectory();
        try
        {
            var secretsFile = Path.Combine(tempDirectory.FullName, "Production.json");
            File.WriteAllText(secretsFile, """
                {
                  "Parameters:api_key": "from-secret"
                }
                """);
 
            var configuration = new ConfigurationManager();
            configuration.Sources.Add(new TestEnvironmentVariablesConfigurationSource(
                new Dictionary<string, string?>
                {
                    ["Parameters:api_key"] = "from-environment"
                }));
 
            DistributedApplicationBuilder.AddConfiguredAspireSecretsFile(configuration, secretsFile);
 
            Assert.Equal("from-environment", configuration["Parameters:api_key"]);
        }
        finally
        {
            tempDirectory.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData(KnownConfigNames.DashboardUnsecuredAllowAnonymous)]
    [InlineData(KnownConfigNames.Legacy.DashboardUnsecuredAllowAnonymous)]
    public void ResourceServiceConfig_Unsecured(string dashboardUnsecuredAllowAnonymousKey)
    {
        var appBuilder = DistributedApplication.CreateBuilder(args: [$"{dashboardUnsecuredAllowAnonymousKey}=true"]);
        using var app = appBuilder.Build();
 
        var config = app.Services.GetRequiredService<IConfiguration>();
        Assert.Equal(nameof(ResourceServiceAuthMode.Unsecured), config["AppHost:ResourceService:AuthMode"]);
        Assert.True(string.IsNullOrEmpty(config["AppHost:ResourceService:ApiKey"]));
    }
 
    private static void DeleteUserSecretsFile(string userSecretsPath)
    {
        DeleteFileIfExists(userSecretsPath);
 
        var directory = Path.GetDirectoryName(userSecretsPath);
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }
    }
 
    private static void DeleteFileIfExists(string path)
    {
        if (File.Exists(path))
        {
            File.Delete(path);
        }
    }
 
    private sealed class TestEnvironmentVariablesConfigurationSource(IReadOnlyDictionary<string, string?> values) : EnvironmentVariablesConfigurationSource, IConfigurationSource
    {
        IConfigurationProvider IConfigurationSource.Build(IConfigurationBuilder builder) =>
            new TestEnvironmentVariablesConfigurationProvider(values);
    }
 
    private sealed class TestEnvironmentVariablesConfigurationProvider(IReadOnlyDictionary<string, string?> values) : ConfigurationProvider
    {
        public override void Load()
        {
            foreach (var (key, value) in values)
            {
                Data[key] = value;
            }
        }
    }
 
    [Fact]
    public void AddResource_DuplicateResourceNames_SameCasing_Error()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
 
        appBuilder.AddResource(new ContainerResource("Test"));
 
        var ex = Assert.Throws<DistributedApplicationException>(() => appBuilder.AddResource(new ContainerResource("Test")));
        Assert.Equal("Cannot add resource of type 'Aspire.Hosting.ApplicationModel.ContainerResource' with name 'Test' because resource of type 'Aspire.Hosting.ApplicationModel.ContainerResource' with that name already exists. Resource names are case-insensitive.", ex.Message);
    }
 
    [Fact]
    public void AddResource_DuplicateResourceNames_MixedCasing_Error()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
 
        appBuilder.AddResource(new ContainerResource("Test"));
 
        var ex = Assert.Throws<DistributedApplicationException>(() => appBuilder.AddResource(new ContainerResource("TEST")));
        Assert.Equal("Cannot add resource of type 'Aspire.Hosting.ApplicationModel.ContainerResource' with name 'TEST' because resource of type 'Aspire.Hosting.ApplicationModel.ContainerResource' with that name already exists. Resource names are case-insensitive.", ex.Message);
    }
 
    [Fact]
    public void AppHostIdentitiesAreAvailable()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
 
        var pathSha = appBuilder.Configuration["AppHost:PathSha256"];
        var deploymentStatePathSha = appBuilder.Configuration["AppHost:DeploymentStatePathSha256"];
        var projectNameSha = appBuilder.Configuration["AppHost:ProjectNameSha256"];
        var legacySha = appBuilder.Configuration["AppHost:Sha256"];
 
        Assert.NotNull(pathSha);
        Assert.NotNull(deploymentStatePathSha);
        Assert.NotNull(projectNameSha);
        Assert.NotNull(legacySha);
 
        Assert.False(appBuilder.ExecutionContext.IsPublishMode);
        Assert.Equal(pathSha, legacySha);
    }
 
    [Fact]
    public void PathShaDiffersForDifferentPaths()
    {
        var options1 = new DistributedApplicationOptions
        {
            ProjectDirectory = "/home/user/project1",
            ProjectName = "TestApp",
            Args = []
        };
 
        var options2 = new DistributedApplicationOptions
        {
            ProjectDirectory = "/home/user/project2",
            ProjectName = "TestApp", // Same name, different path
            Args = []
        };
 
        var builder1 = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options1);
        var builder2 = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options2);
 
        var pathSha1 = builder1.Configuration["AppHost:PathSha256"];
        var pathSha2 = builder2.Configuration["AppHost:PathSha256"];
        var projectNameSha1 = builder1.Configuration["AppHost:ProjectNameSha256"];
        var projectNameSha2 = builder2.Configuration["AppHost:ProjectNameSha256"];
 
        // PathSha should differ for different paths
        Assert.NotEqual(pathSha1, pathSha2);
 
        // ProjectNameSha should be the same for same project name
        Assert.Equal(projectNameSha1, projectNameSha2);
    }
 
    [Fact]
    public void DeploymentStatePathShaDiffersForPolyglotAppHostFilesInSameDirectory()
    {
        var options1 = new DistributedApplicationOptions
        {
            ProjectDirectory = "/home/user/project",
            ProjectName = "Aspire.Hosting.RemoteHost",
            AppHostFilePath = "/home/user/project/first.ts",
            Args = []
        };
 
        var options2 = new DistributedApplicationOptions
        {
            ProjectDirectory = "/home/user/project",
            ProjectName = "Aspire.Hosting.RemoteHost",
            AppHostFilePath = "/home/user/project/second.ts",
            Args = []
        };
 
        var builder1 = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options1);
        var builder2 = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options2);
 
        Assert.Equal(
            builder1.Configuration["AppHost:PathSha256"],
            builder2.Configuration["AppHost:PathSha256"]);
        Assert.NotEqual(
            builder1.Configuration["AppHost:DeploymentStatePathSha256"],
            builder2.Configuration["AppHost:DeploymentStatePathSha256"]);
        Assert.Equal(
            builder1.Configuration["AppHost:LegacyDeploymentStatePathSha256"],
            builder2.Configuration["AppHost:LegacyDeploymentStatePathSha256"]);
        Assert.NotNull(builder1.Configuration["AppHost:LegacyDeploymentStatePathSha256"]);
        Assert.Equal(
            builder1.Configuration["AppHost:ProjectNameSha256"],
            builder2.Configuration["AppHost:ProjectNameSha256"]);
        Assert.Equal(
            builder1.Configuration["AppHost:PathSha256"],
            builder1.Configuration["AppHost:Sha256"]);
        Assert.Equal(
            builder1.Configuration["AppHost:LegacyDeploymentStatePathSha256"],
            builder1.Configuration["AppHost:Sha256"]);
    }
 
    [Fact]
    public void PolyglotAppHostPathIdentityPreservesFilesystemCaseSemantics()
    {
        var projectDirectory = Directory.CreateTempSubdirectory("aspire-path-case-");
        try
        {
            var appHostPath = Path.Combine(projectDirectory.FullName, "apphost.ts");
            File.WriteAllText(appHostPath, string.Empty);
            var lowerCaseOptions = new DistributedApplicationOptions
            {
                ProjectDirectory = projectDirectory.FullName,
                ProjectName = "Aspire.Hosting.RemoteHost",
                AppHostFilePath = appHostPath,
                Args = []
            };
            var upperCaseOptions = new DistributedApplicationOptions
            {
                ProjectDirectory = projectDirectory.FullName,
                ProjectName = "Aspire.Hosting.RemoteHost",
                AppHostFilePath = Path.Combine(projectDirectory.FullName, "AppHost.ts"),
                Args = []
            };
 
            var lowerCaseBuilder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(lowerCaseOptions);
            var upperCaseBuilder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(upperCaseOptions);
 
            if (File.Exists(upperCaseOptions.AppHostFilePath))
            {
                Assert.Equal(
                    lowerCaseBuilder.Configuration["AppHost:DeploymentStatePathSha256"],
                    upperCaseBuilder.Configuration["AppHost:DeploymentStatePathSha256"]);
            }
            else
            {
                Assert.NotEqual(
                    lowerCaseBuilder.Configuration["AppHost:DeploymentStatePathSha256"],
                    upperCaseBuilder.Configuration["AppHost:DeploymentStatePathSha256"]);
            }
        }
        finally
        {
            projectDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void PolyglotAppHostDeploymentStateIdentityDoesNotResolveSymlinks()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(),
            "Unix-only: unprivileged symlink creation is not reliable on Windows.");
 
        var projectDirectory = Directory.CreateTempSubdirectory("aspire-deployment-path-");
        try
        {
            var realDirectory = projectDirectory.CreateSubdirectory("real");
            var appHostPath = Path.Combine(realDirectory.FullName, "apphost.ts");
            File.WriteAllText(appHostPath, string.Empty);
 
            var symlinkDirectory = Path.Combine(projectDirectory.FullName, "link");
            Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName);
            var appHostPathViaSymlink = Path.Combine(symlinkDirectory, "apphost.ts");
 
            var options = new DistributedApplicationOptions
            {
                ProjectDirectory = projectDirectory.FullName,
                ProjectName = "Aspire.Hosting.RemoteHost",
                AppHostFilePath = appHostPathViaSymlink,
                Args = []
            };
 
            var builder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
            var previousIdentityPath = PathNormalizer.ResolvePathCasing(Path.GetFullPath(appHostPathViaSymlink));
            var expectedSha = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(previousIdentityPath)));
 
            Assert.NotEqual(PathNormalizer.ResolveToFilesystemPath(appHostPathViaSymlink), previousIdentityPath);
            Assert.Equal(expectedSha, builder.Configuration["AppHost:DeploymentStatePathSha256"]);
        }
        finally
        {
            projectDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void PolyglotAppHostLoadsLegacyDeploymentConfiguration()
    {
        var projectDirectory = Directory.CreateTempSubdirectory("aspire-polyglot-");
        const string projectName = "Aspire.Hosting.RemoteHost";
        var legacyAppHostPath = Path.GetFullPath(Path.Join(projectDirectory.FullName, projectName));
        var legacySha = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(legacyAppHostPath.ToLowerInvariant())));
        const string environment = "production";
        var legacyDirectory = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".aspire",
            "deployments",
            legacySha);
        var legacyStatePath = Path.Combine(legacyDirectory, $"{environment}.json");
        string? canonicalDirectory = null;
 
        try
        {
            Directory.CreateDirectory(legacyDirectory);
            File.WriteAllText(legacyStatePath, """{"MigratedValue":"loaded"}""");
 
            var options = new DistributedApplicationOptions
            {
                ProjectDirectory = projectDirectory.FullName,
                ProjectName = projectName,
                AppHostFilePath = Path.Combine(projectDirectory.FullName, "apphost.ts"),
                Args = ["--publisher", "manifest"]
            };
 
            var builder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
            canonicalDirectory = Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                ".aspire",
                "deployments",
                builder.Configuration["AppHost:DeploymentStatePathSha256"]!);
 
            Assert.True(builder.ExecutionContext.IsPublishMode);
            Assert.Equal(legacySha, builder.Configuration["AppHost:LegacyDeploymentStatePathSha256"]);
            Assert.Equal("loaded", builder.Configuration["MigratedValue"]);
        }
        finally
        {
            if (File.Exists(legacyStatePath))
            {
                File.Delete(legacyStatePath);
            }
            if (Directory.Exists(legacyDirectory) && !Directory.EnumerateFileSystemEntries(legacyDirectory).Any())
            {
                Directory.Delete(legacyDirectory);
            }
            if (canonicalDirectory is not null &&
                Directory.Exists(canonicalDirectory) &&
                !Directory.EnumerateFileSystemEntries(canonicalDirectory).Any())
            {
                Directory.Delete(canonicalDirectory);
            }
            projectDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void SourceAppHostWithInvalidEnvironmentNameDoesNotThrowInPublishMode()
    {
        var projectDirectory = Directory.CreateTempSubdirectory("aspire-invalid-env-");
        try
        {
            // An environment name outside [a-zA-Z0-9_-] makes deployment-state path resolution throw
            // ArgumentException. Best-effort state loading must swallow that so publish-mode builder
            // construction succeeds instead of failing outright.
            var options = new DistributedApplicationOptions
            {
                ProjectDirectory = projectDirectory.FullName,
                ProjectName = "Aspire.Hosting.RemoteHost",
                AppHostFilePath = Path.Combine(projectDirectory.FullName, "apphost.ts"),
                Args = ["--publisher", "manifest", "--environment", "Invalid.Env"]
            };
 
            var builder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
 
            Assert.True(builder.ExecutionContext.IsPublishMode);
            Assert.Equal("Invalid.Env", builder.Environment.EnvironmentName);
        }
        finally
        {
            projectDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void PolyglotAppHostDoesNotLoadLegacyDeploymentConfigurationAfterClear()
    {
        var projectDirectory = Directory.CreateTempSubdirectory("aspire-polyglot-");
        const string projectName = "Aspire.Hosting.RemoteHost";
        const string environment = "production";
        var options = new DistributedApplicationOptions
        {
            ProjectDirectory = projectDirectory.FullName,
            ProjectName = projectName,
            AppHostFilePath = Path.Combine(projectDirectory.FullName, "apphost.ts"),
            Args = ["--publisher", "manifest"]
        };
        var probeBuilder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
        var currentSha = probeBuilder.Configuration["AppHost:DeploymentStatePathSha256"]!;
        var legacySha = probeBuilder.Configuration["AppHost:LegacyDeploymentStatePathSha256"]!;
        var deploymentsDirectory = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".aspire",
            "deployments");
        var currentDirectory = Path.Combine(deploymentsDirectory, currentSha);
        var legacyDirectory = Path.Combine(deploymentsDirectory, legacySha);
        var currentStatePath = Path.Combine(currentDirectory, $"{environment}.json");
        var legacyStatePath = Path.Combine(legacyDirectory, $"{environment}.json");
        var migrationStatePath = FileDeploymentStateManager.GetMigrationStatePath(currentStatePath);
 
        try
        {
            Directory.CreateDirectory(currentDirectory);
            Directory.CreateDirectory(legacyDirectory);
            File.WriteAllText(legacyStatePath, """{"MigratedValue":"loaded"}""");
            File.WriteAllText(migrationStatePath, """{"LegacyFallbackDisabled":true}""");
 
            var builder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
 
            Assert.Null(builder.Configuration["MigratedValue"]);
        }
        finally
        {
            if (File.Exists(migrationStatePath))
            {
                File.Delete(migrationStatePath);
            }
            if (File.Exists(legacyStatePath))
            {
                File.Delete(legacyStatePath);
            }
            if (Directory.Exists(currentDirectory) && !Directory.EnumerateFileSystemEntries(currentDirectory).Any())
            {
                Directory.Delete(currentDirectory);
            }
            if (Directory.Exists(legacyDirectory) && !Directory.EnumerateFileSystemEntries(legacyDirectory).Any())
            {
                Directory.Delete(legacyDirectory);
            }
            projectDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void PolyglotAppHostLoadsEffectiveDeploymentConfigurationAfterCanonicalSave()
    {
        var projectDirectory = Directory.CreateTempSubdirectory("aspire-polyglot-");
        const string projectName = "Aspire.Hosting.RemoteHost";
        const string environment = "production";
        var options = new DistributedApplicationOptions
        {
            ProjectDirectory = projectDirectory.FullName,
            ProjectName = projectName,
            AppHostFilePath = Path.Combine(projectDirectory.FullName, "apphost.ts"),
            Args = ["--publisher", "manifest"]
        };
        var probeBuilder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
        var currentSha = probeBuilder.Configuration["AppHost:DeploymentStatePathSha256"]!;
        var legacySha = probeBuilder.Configuration["AppHost:LegacyDeploymentStatePathSha256"]!;
        var deploymentsDirectory = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".aspire",
            "deployments");
        var currentDirectory = Path.Combine(deploymentsDirectory, currentSha);
        var legacyDirectory = Path.Combine(deploymentsDirectory, legacySha);
        var currentStatePath = Path.Combine(currentDirectory, $"{environment}.json");
        var legacyStatePath = Path.Combine(legacyDirectory, $"{environment}.json");
 
        try
        {
            Directory.CreateDirectory(currentDirectory);
            Directory.CreateDirectory(legacyDirectory);
            File.WriteAllText(currentStatePath, """{"CurrentValue":"current"}""");
            File.WriteAllText(legacyStatePath, """{"LegacyValue":"legacy"}""");
 
            var builder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
 
            Assert.Equal("current", builder.Configuration["CurrentValue"]);
            Assert.Equal("legacy", builder.Configuration["LegacyValue"]);
        }
        finally
        {
            if (File.Exists(currentStatePath))
            {
                File.Delete(currentStatePath);
            }
            if (File.Exists(legacyStatePath))
            {
                File.Delete(legacyStatePath);
            }
            if (Directory.Exists(currentDirectory) && !Directory.EnumerateFileSystemEntries(currentDirectory).Any())
            {
                Directory.Delete(currentDirectory);
            }
            if (Directory.Exists(legacyDirectory) && !Directory.EnumerateFileSystemEntries(legacyDirectory).Any())
            {
                Directory.Delete(legacyDirectory);
            }
            projectDirectory.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData("[]")]
    [InlineData("""{"LegacyFallbackDisabled":"invalid"}""")]
    [InlineData("""{"LegacyState":"invalid"}""")]
    [InlineData("""{"ClaimedSections":"null"}""")]
    [InlineData("""{"ClaimedSections":"[null]"}""")]
    public void PolyglotAppHostIgnoresLegacyDeploymentConfigurationWhenMigrationStateIsMalformed(string migrationState)
    {
        var projectDirectory = Directory.CreateTempSubdirectory("aspire-polyglot-");
        const string projectName = "Aspire.Hosting.RemoteHost";
        const string environment = "production";
        var options = new DistributedApplicationOptions
        {
            ProjectDirectory = projectDirectory.FullName,
            ProjectName = projectName,
            AppHostFilePath = Path.Combine(projectDirectory.FullName, "apphost.ts"),
            Args = ["--publisher", "manifest"]
        };
        var probeBuilder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
        var currentSha = probeBuilder.Configuration["AppHost:DeploymentStatePathSha256"]!;
        var legacySha = probeBuilder.Configuration["AppHost:LegacyDeploymentStatePathSha256"]!;
        var deploymentsDirectory = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".aspire",
            "deployments");
        var currentDirectory = Path.Combine(deploymentsDirectory, currentSha);
        var legacyDirectory = Path.Combine(deploymentsDirectory, legacySha);
        var currentStatePath = Path.Combine(currentDirectory, $"{environment}.json");
        var legacyStatePath = Path.Combine(legacyDirectory, $"{environment}.json");
        var migrationStatePath = FileDeploymentStateManager.GetMigrationStatePath(currentStatePath);
 
        try
        {
            Directory.CreateDirectory(currentDirectory);
            Directory.CreateDirectory(legacyDirectory);
            File.WriteAllText(legacyStatePath, """{"MigratedValue":"loaded"}""");
            File.WriteAllText(migrationStatePath, migrationState);
 
            var builder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
 
            Assert.Null(builder.Configuration["MigratedValue"]);
        }
        finally
        {
            if (File.Exists(migrationStatePath))
            {
                File.Delete(migrationStatePath);
            }
            if (File.Exists(legacyStatePath))
            {
                File.Delete(legacyStatePath);
            }
            if (Directory.Exists(currentDirectory) && !Directory.EnumerateFileSystemEntries(currentDirectory).Any())
            {
                Directory.Delete(currentDirectory);
            }
            if (Directory.Exists(legacyDirectory) && !Directory.EnumerateFileSystemEntries(legacyDirectory).Any())
            {
                Directory.Delete(legacyDirectory);
            }
            projectDirectory.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData("null")]
    [InlineData("[]")]
    [InlineData("""{"Key":"one","key":"two"}""")]
    public void PolyglotAppHostIgnoresInvalidDeploymentConfiguration(string deploymentState)
    {
        var projectDirectory = Directory.CreateTempSubdirectory("aspire-polyglot-");
        const string projectName = "Aspire.Hosting.RemoteHost";
        const string environment = "production";
        var options = new DistributedApplicationOptions
        {
            ProjectDirectory = projectDirectory.FullName,
            ProjectName = projectName,
            AppHostFilePath = Path.Combine(projectDirectory.FullName, "apphost.ts"),
            Args = ["--publisher", "manifest"]
        };
        var probeBuilder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
        var currentSha = probeBuilder.Configuration["AppHost:DeploymentStatePathSha256"]!;
        var currentDirectory = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".aspire",
            "deployments",
            currentSha);
        var currentStatePath = Path.Combine(currentDirectory, $"{environment}.json");
 
        try
        {
            Directory.CreateDirectory(currentDirectory);
            File.WriteAllText(currentStatePath, deploymentState);
 
            var builder = (DistributedApplicationBuilder)DistributedApplication.CreateBuilder(options);
 
            Assert.Null(builder.Configuration["MigratedValue"]);
        }
        finally
        {
            if (File.Exists(currentStatePath))
            {
                File.Delete(currentStatePath);
            }
            if (Directory.Exists(currentDirectory) && !Directory.EnumerateFileSystemEntries(currentDirectory).Any())
            {
                Directory.Delete(currentDirectory);
            }
            projectDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void LegacyShaUsesProjectNameShaInPublishMode()
    {
        var appBuilder = DistributedApplication.CreateBuilder(["--publisher", "manifest"]);
 
        var pathSha = appBuilder.Configuration["AppHost:PathSha256"];
        var projectNameSha = appBuilder.Configuration["AppHost:ProjectNameSha256"];
        var legacySha = appBuilder.Configuration["AppHost:Sha256"];
 
        // Verify all three SHA values are available
        Assert.NotNull(pathSha);
        Assert.NotNull(projectNameSha);
        Assert.NotNull(legacySha);
 
        // In publish mode, legacy SHA should equal ProjectNameSha
        Assert.True(appBuilder.ExecutionContext.IsPublishMode);
        Assert.Equal(projectNameSha, legacySha);
    }
 
    [Fact]
    public void AddResource_InvalidName_Error()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
 
        var longName = new string('a', 65);
        var resource = new ContainerResource(longName);
 
        var ex = Assert.Throws<ArgumentException>(() => appBuilder.AddResource(resource));
        Assert.Equal($"Resource name '{longName}' is invalid. Name must be between 1 and 64 characters long. (Parameter 'name')", ex.Message);
    }
 
    [Fact]
    public void AddResource_InvalidNameWithExcludeAnnotation_Success()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
 
        var longName = new string('a', 65);
        var resource = new ContainerResource(longName);
        resource.Annotations.Add(NameValidationPolicyAnnotation.None);
 
        appBuilder.AddResource(resource);
 
        Assert.Contains(appBuilder.Resources, r => r.Name == longName);
    }
 
    [Fact]
    public void Build_InvalidName_Error()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
 
        var longName = new string('a', 65);
        appBuilder.Resources.Add(new ContainerResource(longName));
 
        var ex = Assert.Throws<ArgumentException>(appBuilder.Build);
        Assert.Equal($"Resource name '{longName}' is invalid. Name must be between 1 and 64 characters long. (Parameter 'name')", ex.Message);
    }
 
    [Fact]
    public void Build_InvalidNameWithExcludeAnnotation_Success()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
 
        var longName = new string('a', 65);
        var resource = new ContainerResource(longName);
        resource.Annotations.Add(NameValidationPolicyAnnotation.None);
        appBuilder.Resources.Add(resource);
 
        var app = appBuilder.Build();
 
        Assert.NotNull(app);
    }
 
    private sealed class TestResource : IResource
    {
        public string Name => nameof(TestResource);
 
        public ResourceAnnotationCollection Annotations { get; } = new();
    }
}