File: Commands\ConfigCommandTests.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 Aspire.Cli.Configuration;
using Aspire.Cli.Documentation.ApiDocs;
using Aspire.Cli.Documentation.Docs;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Tests.TestServices;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Commands;
 
public class ConfigCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void ConfigInfo_AdvertisesLsJsonStream()
    {
        Assert.Contains(KnownCapabilities.LsJsonStream, KnownCapabilities.GetAdvertisedCapabilities());
    }
 
    [Fact]
    public void ConfigInfo_AdvertisesIsolatedLaunch()
    {
        Assert.Contains(KnownCapabilities.IsolatedLaunch, KnownCapabilities.GetAdvertisedCapabilities());
    }
 
    [Fact]
    public void ConfigInfo_AdvertisesLaunchProfiles()
    {
        Assert.Contains(KnownCapabilities.LaunchProfile, KnownCapabilities.GetAdvertisedCapabilities());
    }
 
    [Fact]
    public void ConfigInfo_AdvertisesPipelineStepListJson()
    {
        Assert.Contains(KnownCapabilities.PipelineStepListJson, KnownCapabilities.GetAdvertisedCapabilities());
    }
 
    [Fact]
    public void ConfigInfoJson_UsesCamelCasePropertyNames()
    {
        var info = new Aspire.Cli.Commands.ConfigInfo(
            LocalSettingsPath: "local.json",
            GlobalSettingsPath: "global.json",
            AvailableFeatures: [new Aspire.Cli.Commands.FeatureInfo("featureA", "Description", DefaultValue: false)],
            LocalSettingsSchema: new Aspire.Cli.Commands.SettingsSchema([]),
            GlobalSettingsSchema: new Aspire.Cli.Commands.SettingsSchema([]),
            ConfigFileSchema: new Aspire.Cli.Commands.SettingsSchema([]),
            Capabilities: ["docs"]);
 
        var json = JsonSerializer.Serialize(info, JsonSourceGenerationContext.Default.ConfigInfo);
 
        Assert.Contains("\"localSettingsPath\"", json);
        Assert.Contains("\"globalSettingsPath\"", json);
        Assert.Contains("\"availableFeatures\"", json);
        Assert.DoesNotContain("\"LocalSettingsPath\"", json);
    }
 
    [Fact]
    public async Task ConfigCommand_WithExtensionMode_Works()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper,
            options =>
            {
                options.ConfigurationCallback += config =>
                {
                    // Enable extension mode for testing
                    config["ASPIRE_EXTENSION_PROMPT_ENABLED"] = "true";
                    config["ASPIRE_EXTENSION_TOKEN"] = "token";
                };
 
                options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
                options.ExtensionBackchannelFactory = sp => new TestExtensionBackchannel();
 
                options.ConfigurationServiceFactory = _ => new TestConfigurationService();
            });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task ConfigCommandReturnsInvalidCommandExitCode()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
    }
 
    [Fact]
    public async Task ConfigSetCommand_WithFlatKey_CreatesSimpleProperty()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config set foo bar");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
 
        // Verify the settings file was created correctly
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        Assert.True(File.Exists(settingsPath));
 
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
        Assert.Equal("bar", settings["foo"]?.ToString());
    }
 
    [Fact]
    public async Task DocsSourceUrls_CanBeConfiguredViaAspireConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        var setLlmsResult = command.Parse("config set docs.llmsTxtUrl http://localhost:4321/llms-small.txt");
        var setLlmsExitCode = await setLlmsResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setLlmsExitCode);
 
        var setSitemapResult = command.Parse("config set docs.api.sitemapUrl http://localhost:4321/sitemap-0.xml");
        var setSitemapExitCode = await setSitemapResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setSitemapExitCode);
 
        var configurationService = provider.GetRequiredService<IConfigurationService>();
        var settingsPath = configurationService.GetSettingsFilePath(isGlobal: false);
        var settingsJson = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(settingsJson)?.AsObject();
 
        Assert.NotNull(settings);
        Assert.True(settings["docs"] is JsonObject);
        var docsObject = settings["docs"]!.AsObject();
        Assert.Equal("http://localhost:4321/llms-small.txt", docsObject["llmsTxtUrl"]?.ToString());
        Assert.True(docsObject["api"] is JsonObject);
        Assert.Equal("http://localhost:4321/sitemap-0.xml", docsObject["api"]?["sitemapUrl"]?.ToString());
 
        var reloadedServices = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var reloadedProvider = reloadedServices.BuildServiceProvider();
        var configuration = reloadedProvider.GetRequiredService<IConfiguration>();
 
        Assert.Equal("http://localhost:4321/llms-small.txt", DocsSourceConfiguration.GetLlmsTxtUrl(configuration));
        Assert.Equal("http://localhost:4321/sitemap-0.xml", ApiDocsSourceConfiguration.GetSitemapUrl(configuration));
    }
 
    [Fact]
    public async Task ConfigSetCommand_WithDotNotation_CreatesNestedObject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config set foo.bar baz");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
 
        // Verify the settings file was created correctly
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        Assert.True(File.Exists(settingsPath));
 
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
        Assert.True(settings["foo"] is JsonObject);
        var fooObject = settings["foo"]!.AsObject();
        Assert.Equal("baz", fooObject["bar"]?.ToString());
    }
 
    [Fact]
    public async Task ConfigSetCommand_WithDeepDotNotation_CreatesDeeplyNestedObject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config set foo.bar.baz hello");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
 
        // Verify the settings file was created correctly
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        Assert.True(File.Exists(settingsPath));
 
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
 
        Assert.True(settings["foo"] is JsonObject);
        var fooObject = settings["foo"]!.AsObject();
        Assert.True(fooObject["bar"] is JsonObject);
        var barObject = fooObject["bar"]!.AsObject();
        Assert.Equal("hello", barObject["baz"]?.ToString());
    }
 
    [Fact]
    public async Task ConfigSetCommand_ReplacesPrimitiveWithObject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // First set a primitive value
        var result1 = command.Parse("config set foo primitive");
        var exitCode1 = await result1.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode1);
 
        // Then set a nested value that should replace the primitive
        var result2 = command.Parse("config set foo.bar nested");
        var exitCode2 = await result2.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode2);
 
        // Verify the primitive was replaced with an object
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
 
        Assert.True(settings["foo"] is JsonObject);
        var fooObject = settings["foo"]!.AsObject();
        Assert.Equal("nested", fooObject["bar"]?.ToString());
    }
 
    [Fact]
    public async Task ConfigGetCommand_WithFlatKey_ReturnsValue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services1 = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider1 = services1.BuildServiceProvider();
 
        var command1 = provider1.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // First set a value
        var setResult = command1.Parse("config set testkey testvalue");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        // Create a new service collection to reload config.
        var services2 = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider2 = services2.BuildServiceProvider();
 
        var command2 = provider2.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Then get the value
        var getResult = command2.Parse("config get testkey");
        var getExitCode = await getResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, getExitCode);
    }
 
    [Fact]
    public async Task ConfigGetCommand_WithDotNotation_ReturnsNestedValue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services1 = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider1 = services1.BuildServiceProvider();
 
        var command1 = provider1.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // First set a nested value
        var setResult = command1.Parse("config set level1.level2.level3 nestedvalue");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        var services2 = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider2 = services2.BuildServiceProvider();
 
        var command2 = provider2.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Then get the nested value
        var getResult = command2.Parse("config get level1.level2.level3");
        var getExitCode = await getResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, getExitCode);
    }
 
    [Fact]
    public async Task ConfigGetCommand_WithNonExistentKey_ReturnsError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config get nonexistent.key");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(10, exitCode);
    }
 
    [Fact]
    public async Task ConfigDeleteCommand_WithFlatKey_RemovesValue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // First set a value
        var setResult = command.Parse("config set deletekey deletevalue");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        // Then delete the value
        var deleteResult = command.Parse("config delete deletekey");
        var deleteExitCode = await deleteResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, deleteExitCode);
 
        // Verify it's deleted
        var getResult = command.Parse("config get deletekey");
        var getExitCode = await getResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(10, getExitCode); // Should return error for missing key
    }
 
    [Fact]
    public async Task ConfigDeleteCommand_CleansUpEmptyParentObjects()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Set a deeply nested value
        var setResult = command.Parse("config set deep.nested.value test");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        // Delete the nested value
        var deleteResult = command.Parse("config delete deep.nested.value");
        var deleteExitCode = await deleteResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, deleteExitCode);
 
        // Verify the entire deep.nested structure is cleaned up
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
 
        // The deep object should be completely removed since it became empty
        Assert.False(settings.ContainsKey("deep"));
    }
 
    [Fact]
    public async Task ConfigListCommand_ShowsFlattenedDotNotationKeys()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Set various values
        var setResult1 = command.Parse("config set flatkey flatvalue");
        var setExitCode1 = await setResult1.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode1);
 
        var setResult2 = command.Parse("config set nested.key nestedvalue");
        var setExitCode2 = await setResult2.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode2);
 
        var setResult3 = command.Parse("config set deep.nested.key deepvalue");
        var setExitCode3 = await setResult3.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode3);
 
        // List all configuration
        var listResult = command.Parse("config list");
        var listExitCode = await listResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, listExitCode);
    }
 
    [Fact]
    public async Task ConfigListCommand_WithoutAllFlag_ShowsHintInsteadOfFeatures()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Set a value so config list has something to display
        var setResult = command.Parse("config set testkey testvalue");
        await setResult.InvokeAsync().DefaultTimeout();
 
        // List without --all
        var listResult = command.Parse("config list");
        var listExitCode = await listResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, listExitCode);
 
        var output = string.Join("\n", outputWriter.Logs);
        Assert.Contains("--all", output);
    }
 
    [Fact]
    public async Task ConfigListCommand_WithAllFlag_ShowsFeatureDetails()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Set a value so config list has something to display
        var setResult = command.Parse("config set testkey testvalue");
        await setResult.InvokeAsync().DefaultTimeout();
 
        // List with --all
        var listResult = command.Parse("config list --all");
        var listExitCode = await listResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, listExitCode);
 
        var output = string.Join("\n", outputWriter.Logs);
        Assert.Contains("default:", output);
    }
 
    [Fact]
    public async Task ConfigListCommand_WithoutAllFlag_NoConfig_ShowsHintAboutAllFlag()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // List without --all and with no config set
        var listResult = command.Parse("config list");
        var listExitCode = await listResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, listExitCode);
 
        var output = string.Join("\n", outputWriter.Logs);
        Assert.Contains("--all", output);
    }
 
    [Fact]
    public async Task ConfigListCommand_WithAllFlag_NoConfig_ShowsAvailableFeatures()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // List with --all and with no config set
        var listResult = command.Parse("config list --all");
        var listExitCode = await listResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, listExitCode);
 
        var output = string.Join("\n", outputWriter.Logs);
        Assert.Contains("default:", output);
    }
 
    [Fact]
    public async Task ConfigListCommand_WithAllFlag_DoesNotListHiddenFeature()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        var listResult = command.Parse("config list --all");
        var listExitCode = await listResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, listExitCode);
 
        var output = string.Join("\n", outputWriter.Logs);
        // A non-hidden feature is listed, proving the available-features section rendered...
        Assert.Contains(KnownFeatures.ShowAllTemplates, output);
        // ...but the hidden aspireSkillsRemoteFetchEnabled feature must not be advertised.
        Assert.DoesNotContain(KnownFeatures.AspireSkillsRemoteFetchEnabled, output);
    }
 
    [Fact]
    public async Task ConfigInfoJson_DoesNotAdvertiseHiddenFeature()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        var infoResult = command.Parse("config info --json");
        var infoExitCode = await infoResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, infoExitCode);
 
        // The only place a feature name reaches the JSON is availableFeatures (SettingsSchemaBuilder
        // does not enumerate feature flags), so asserting on the name token reliably proves exclusion.
        var output = string.Join("\n", outputWriter.Logs);
        // A non-hidden feature is present, proving availableFeatures was emitted...
        Assert.Contains(KnownFeatures.ShowAllTemplates, output);
        // ...but the hidden aspireSkillsRemoteFetchEnabled feature must not appear.
        Assert.DoesNotContain(KnownFeatures.AspireSkillsRemoteFetchEnabled, output);
    }
 
    [Fact]
    public void HiddenFeature_WhenSetExplicitly_IsStillHonored()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(
            workspace,
            outputHelper,
            // Hiding a feature only removes it from discovery; a value set directly in
            // configuration must still be honored by IFeatures.
            options => options.ConfigurationCallback += config =>
            {
                config[$"{KnownFeatures.FeaturePrefix}:{KnownFeatures.AspireSkillsRemoteFetchEnabled}"] = "true";
            });
        using var provider = services.BuildServiceProvider();
 
        var featureFlags = provider.GetRequiredService<IFeatures>();
        Assert.True(featureFlags.IsFeatureEnabled(KnownFeatures.AspireSkillsRemoteFetchEnabled, defaultValue: false));
    }
 
    [Fact]
    public async Task FeatureFlags_WhenSetToTrue_ReturnsTrue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(
            workspace,
            outputHelper,
            options => options.EnabledFeatures = new[] { "testFeature" }
            );
        using var provider = services.BuildServiceProvider();
 
        // Set the feature flag to true
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var setResult = command.Parse($"config set {KnownFeatures.FeaturePrefix}.testFeature true");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        // Check the feature flag
        var featureFlags = provider.GetRequiredService<IFeatures>();
        Assert.True(featureFlags.IsFeatureEnabled("testFeature", defaultValue: false));
    }
 
    [Fact]
    public async Task FeatureFlags_WhenSetToFalse_ReturnsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => options.DisabledFeatures = new[] { "testFeature" });
        using var provider = services.BuildServiceProvider();
 
        // Set the feature flag to false
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var setResult = command.Parse($"config set {KnownFeatures.FeaturePrefix}.testFeature false");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        // Check the feature flag
        var featureFlags = provider.GetRequiredService<IFeatures>();
        Assert.False(featureFlags.IsFeatureEnabled("testFeature", defaultValue: true));
    }
 
    [Fact]
    public async Task FeatureFlags_WhenSetToInvalidValue_ReturnsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(
            workspace,
            outputHelper,
            options => options.ConfigurationCallback += confing =>
            {
                confing[$"{KnownFeatures.FeaturePrefix}:testFeature"] = "invalid"; // Set an invalid value
            });
        using var provider = services.BuildServiceProvider();
 
        // Set the feature flag to an invalid value
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var setResult = command.Parse($"config set {KnownFeatures.FeaturePrefix}.testFeature invalid");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        // Check the feature flag
        var featureFlags = provider.GetRequiredService<IFeatures>();
        Assert.False(featureFlags.IsFeatureEnabled("testFeature", defaultValue: true));
    }
 
    [Fact]
    public void DeployCommand_IsAlwaysAvailable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var rootCommand = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Check that deploy command is always available
        var hasDeployCommand = rootCommand.Subcommands.Any(cmd => cmd.Name == "deploy");
        Assert.True(hasDeployCommand);
    }
 
    [Fact]
    public async Task ShowDeprecatedPackages_CanBeConfiguredViaCommandLine()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Set the show deprecated packages feature flag to true
        var setResult = command.Parse($"config set {KnownFeatures.FeaturePrefix}.{KnownFeatures.ShowDeprecatedPackages} true");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        // Create new service provider to pick up the configuration change
        var newServices = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var newProvider = newServices.BuildServiceProvider();
 
        // Verify the feature flag is enabled
        var featureFlags = newProvider.GetRequiredService<IFeatures>();
        Assert.True(featureFlags.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false));
    }
 
    [Fact]
    public void ShowDeprecatedPackages_DefaultsToFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        // Verify the feature flag defaults to false
        var featureFlags = provider.GetRequiredService<IFeatures>();
        Assert.False(featureFlags.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false));
    }
 
    [Fact]
    public async Task ConfigSetCommand_WithColonNotation_CreatesNestedObject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config set features:polyglotSupportEnabled true");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
 
        // Colon notation should be normalized to nested JSON, not stored as a flat key
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
 
        // Should be stored as nested object, not as flat "features:polyglotSupportEnabled" key
        Assert.False(settings.ContainsKey("features:polyglotSupportEnabled"));
        Assert.True(settings["features"] is JsonObject);
        var featuresObject = settings["features"]!.AsObject();
        Assert.Equal("true", featuresObject["polyglotSupportEnabled"]?.ToString());
    }
 
    [Fact]
    public async Task ConfigSetCommand_ColonThenDot_NoDuplicateKeys()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Set with colon notation first
        var result1 = command.Parse("config set features:polyglotSupportEnabled true");
        var exitCode1 = await result1.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode1);
 
        // Then set with dot notation
        var result2 = command.Parse("config set features.polyglotSupportEnabled false");
        var exitCode2 = await result2.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode2);
 
        // Should have a single nested entry, no flat colon key
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
 
        Assert.False(settings.ContainsKey("features:polyglotSupportEnabled"));
        Assert.True(settings["features"] is JsonObject);
        var featuresObject = settings["features"]!.AsObject();
        Assert.Equal("false", featuresObject["polyglotSupportEnabled"]?.ToString());
    }
 
    [Fact]
    public async Task ConfigSetCommand_DotThenColon_NoDuplicateKeys()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Set with dot notation first
        var result1 = command.Parse("config set features.polyglotSupportEnabled true");
        var exitCode1 = await result1.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode1);
 
        // Then set with colon notation
        var result2 = command.Parse("config set features:polyglotSupportEnabled false");
        var exitCode2 = await result2.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode2);
 
        // Should have a single nested entry, no flat colon key
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
 
        Assert.False(settings.ContainsKey("features:polyglotSupportEnabled"));
        Assert.True(settings["features"] is JsonObject);
        var featuresObject = settings["features"]!.AsObject();
        Assert.Equal("false", featuresObject["polyglotSupportEnabled"]?.ToString());
    }
 
    [Fact]
    public async Task ConfigDeleteCommand_WithColonNotation_DeletesNestedValue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        // Set with dot notation
        var setResult = command.Parse("config set features.polyglotSupportEnabled true");
        var setExitCode = await setResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, setExitCode);
 
        // Delete with colon notation
        var deleteResult = command.Parse("config delete features:polyglotSupportEnabled");
        var deleteExitCode = await deleteResult.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, deleteExitCode);
 
        // Verify the entire features structure is cleaned up
        var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
        Assert.False(settings.ContainsKey("features"));
    }
 
    [Fact]
    public async Task ConfigSetCommand_WithCorruptedFile_RecoversDuringLoad()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Manually create a corrupted settings file with duplicate keys
        // (flat colon key + nested object for the same path)
        var settingsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire");
        Directory.CreateDirectory(settingsDir);
        var settingsPath = Path.Combine(settingsDir, "settings.json");
        await File.WriteAllTextAsync(settingsPath, """
            {
              "features": {
                "polyglotSupportEnabled": "false"
              },
              "features:polyglotSupportEnabled": "true"
            }
            """);
 
        // Loading configuration should succeed after normalizing the corrupted file
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        // Verify the file was normalized - flat key should be gone, existing nested value preserved
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
        Assert.False(settings.ContainsKey("features:polyglotSupportEnabled"));
        Assert.True(settings["features"] is JsonObject);
        var featuresObject = settings["features"]!.AsObject();
        Assert.Equal("false", featuresObject["polyglotSupportEnabled"]?.ToString());
    }
 
    [Fact]
    public async Task ConfigSetCommand_WithCorruptedFile_PreservesExistingNestedValueOverFlatKey()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create a settings file where both a nested value and a flat colon key exist
        // for the same path but with different values.
        var settingsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire");
        Directory.CreateDirectory(settingsDir);
        var settingsPath = Path.Combine(settingsDir, "settings.json");
        await File.WriteAllTextAsync(settingsPath, """
            {
              "features": {
                "polyglotSupportEnabled": "nested-value"
              },
              "features:polyglotSupportEnabled": "flat-value"
            }
            """);
 
        // Loading configuration triggers normalization
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        // The existing nested value should be preserved; the flat key value should be dropped
        var json = await File.ReadAllTextAsync(settingsPath);
        var settings = JsonNode.Parse(json)?.AsObject();
        Assert.NotNull(settings);
        Assert.False(settings.ContainsKey("features:polyglotSupportEnabled"));
        var featuresObject = settings["features"]!.AsObject();
        Assert.Equal("nested-value", featuresObject["polyglotSupportEnabled"]?.ToString());
    }
 
    [Theory]
    [InlineData("appHost.path")]
    [InlineData("appHost:path")]
    public async Task ConfigSetCommand_LocalAppHostPath_RemainsAllowed(string key)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse($"config set {key} AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        var json = await File.ReadAllTextAsync(configPath);
        var settings = JsonNode.Parse(json)?.AsObject();
 
        Assert.NotNull(settings);
        var appHost = settings["appHost"]?.AsObject();
        Assert.NotNull(appHost);
        Assert.Equal("AppHost.csproj", appHost["path"]?.ToString());
        Assert.False(settings.ContainsKey("appHost:path"));
    }
 
    [Theory]
    [InlineData("appHost.path")]
    [InlineData("appHost:path")]
    public async Task ConfigSetCommand_LocalAppHostPath_MigratesLegacySettingsFile(string key)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var legacySettingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json");
        await File.WriteAllTextAsync(legacySettingsPath, """{ "channel": "daily" }""");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse($"config set {key} AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        var json = await File.ReadAllTextAsync(configPath);
        var settings = JsonNode.Parse(json)?.AsObject();
 
        Assert.NotNull(settings);
        Assert.Equal("daily", settings["channel"]?.ToString());
        var appHost = settings["appHost"]?.AsObject();
        Assert.NotNull(appHost);
        Assert.Equal("AppHost.csproj", appHost["path"]?.ToString());
    }
 
    [Theory]
    [InlineData("appHost.path", nameof(ErrorStrings.GlobalAppHostPathCannotBeSetWithConfigCommand))]
    [InlineData("appHost:path", nameof(ErrorStrings.GlobalAppHostPathCannotBeSetWithConfigCommand))]
    [InlineData("appHostPath", nameof(ErrorStrings.LegacyAppHostPathCannotBeSetWithConfigCommand))]
    public async Task ConfigSetCommand_GlobalAppHostPath_ReturnsInvalidCommand(string key, string expectedErrorResourceName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse($"config set {key} AppHost.csproj --global");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Equal(GetErrorString(expectedErrorResourceName), Assert.Single(testInteractionService.DisplayedErrors));
 
        var settingsPath = provider.GetRequiredService<IConfigurationService>().GetSettingsFilePath(isGlobal: true);
        if (File.Exists(settingsPath))
        {
            var json = await File.ReadAllTextAsync(settingsPath);
            Assert.DoesNotContain("AppHost.csproj", json);
        }
    }
 
    [Fact]
    public async Task ConfigSetCommand_GlobalOverrideStagingFeed_IsReadByPackagingService()
    {
        const string stagingFeed = "https://example.com/staging/v3/index.json";
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using (var provider = services.BuildServiceProvider())
        {
            var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
            var result = command.Parse($"config set -g overrideStagingFeed {stagingFeed}");
 
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, exitCode);
 
            var settingsPath = provider.GetRequiredService<IConfigurationService>().GetSettingsFilePath(isGlobal: true);
            var json = await File.ReadAllTextAsync(settingsPath);
            Assert.Contains(stagingFeed, json);
        }
 
        var reloadedServices = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var reloadedProvider = reloadedServices.BuildServiceProvider();
        var packagingService = reloadedProvider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout();
 
        var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging);
        Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
        Assert.Contains(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*" && m.Source == stagingFeed);
    }
 
    private static string GetErrorString(string resourceName)
    {
        return resourceName switch
        {
            nameof(ErrorStrings.GlobalAppHostPathCannotBeSetWithConfigCommand) => ErrorStrings.GlobalAppHostPathCannotBeSetWithConfigCommand,
            nameof(ErrorStrings.LegacyAppHostPathCannotBeSetWithConfigCommand) => ErrorStrings.LegacyAppHostPathCannotBeSetWithConfigCommand,
            _ => throw new ArgumentOutOfRangeException(nameof(resourceName), resourceName, null)
        };
    }
 
    [Fact]
    public async Task ConfigSetCommand_LegacyAppHostPath_ReturnsInvalidCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config set appHostPath AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        Assert.False(File.Exists(configPath));
    }
 
    [Fact]
    public async Task ConfigSetCommand_AppHostLanguage_RemainsAllowed()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("config set appHost.language typescript/nodejs");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var settingsPath = provider.GetRequiredService<IConfigurationService>().GetSettingsFilePath(isGlobal: false);
        var json = await File.ReadAllTextAsync(settingsPath);
        Assert.Contains("typescript/nodejs", json);
    }
}
 
public class TestConfigurationService : IConfigurationService
{
    public Task SetConfigurationAsync(string key, string value, bool isGlobal = false, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
 
    public Task<bool> DeleteConfigurationAsync(string key, bool isGlobal = false, CancellationToken cancellationToken = default)
    {
        return Task.FromResult(true);
    }
 
    public Task<Dictionary<string, string>> GetAllConfigurationAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(new Dictionary<string, string>
        {
            { "testkey", "testvalue" },
            { "", "" },
        });
    }
 
    public Task<Dictionary<string, string>> GetLocalConfigurationAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(new Dictionary<string, string>
        {
            { "testkey", "testvalue" },
        });
    }
 
    public Task<Dictionary<string, string>> GetGlobalConfigurationAsync(CancellationToken cancellationToken = default)
    {
        return Task.FromResult(new Dictionary<string, string>());
    }
 
    public Task<string?> GetConfigurationAsync(string key, CancellationToken cancellationToken = default)
    {
        return Task.FromResult<string?>(key);
    }
 
    public Task<string?> GetConfigurationFromDirectoryAsync(string key, DirectoryInfo startDirectory, bool continueSearchWhenKeyMissing = false, CancellationToken cancellationToken = default)
    {
        return GetConfigurationAsync(key, cancellationToken);
    }
 
    public string GetSettingsFilePath(bool isGlobal)
    {
        return string.Empty;
    }
}