File: Commands\ConfigSourceCommandTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.Git;
using Aspire.Cli.Documentation.ApiDocs;
using Aspire.Cli.Documentation.Docs;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Shared.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Commands;
 
public class ConfigSourceCommandTests(ITestOutputHelper outputHelper)
{
    private static readonly ConfigSource s_source = new()
    {
        Repository = "https://git.example/configuration",
        Path = "aspire.config.json",
        AppliedVersion = "version-1"
    };
 
    [Theory]
    [InlineData("stop", true, false)]
    [InlineData("ps --format json", false, false)]
    [InlineData("stop", true, true)]
    [InlineData("ps --format json", false, true)]
    public async Task CompletedCheck_NotifiesAtExitAfterCliUpdateUnlessMachineReadable(string commandLine, bool notify, bool timeout)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var interaction = new TestInteractionService();
        var cliNoticeShown = false;
        var notifier = new TestCliUpdateNotifier
        {
            NotifyIfUpdateAvailableCallback = () =>
            {
                Assert.Equal(0, interaction.DisplayedMessages.Count(message => message.Message == ConfigSourceStrings.UpdateAvailable));
                Assert.Empty(interaction.DisplayedRawText);
                cliNoticeShown = true;
            }
        };
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interaction;
            options.CliUpdateNotifierFactory = _ => notifier;
        }).BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        var prompt = new string('x', 70 * 1024) + "Enter passphrase: ";
        git.InspectConfiguration = (_, _) => timeout
            ? throw new ConfigSourceException(ConfigSourceStrings.GitTimeout) { GitOutput = prompt }
            : Task.FromResult("version-2");
        var service = provider.GetRequiredService<ConfigSourceService>();
        service.CheckForConfigSourceUpdates(workspace.WorkspaceRoot, CancellationToken.None);
        await service.WaitForChecksAsync().DefaultTimeout();
        Assert.Empty(interaction.DisplayedMessages);
        Assert.Empty(interaction.DisplayedRawText);
 
        await provider.GetRequiredService<RootCommand>().Parse(commandLine).InvokeAsync().DefaultTimeout();
 
        Assert.Equal(notify, cliNoticeShown);
        var expectedMessage = timeout
            ? string.Format(System.Globalization.CultureInfo.CurrentCulture, ConfigSourceStrings.CheckFailed, ConfigSourceStrings.GitTimeout)
            : ConfigSourceStrings.UpdateAvailable;
        Assert.Equal(notify ? 1 : 0, interaction.DisplayedMessages.Count(message => message.Message == expectedMessage));
        if (notify && timeout)
        {
            Assert.Equal((prompt[^(64 * 1024)..], (ConsoleOutput?)ConsoleOutput.Error), Assert.Single(interaction.DisplayedRawText));
            service.NotifyIfUpdateAvailable();
            Assert.Single(interaction.DisplayedRawText);
        }
        else if (!notify)
        {
            Assert.Equal(("[]", (ConsoleOutput?)ConsoleOutput.Standard), Assert.Single(interaction.DisplayedRawText));
        }
        else
        {
            Assert.Empty(interaction.DisplayedRawText);
        }
    }
 
    [Fact]
    public async Task ProjectSelection_DoesNotWaitForAdvisoryCheck()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var projectFile = new FileInfo(Path.Combine(workspace.Path, "apphost.cs"));
        await File.WriteAllTextAsync(projectFile.FullName, "#:sdk Aspire.AppHost.Sdk");
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
        var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.InspectConfiguration = async (_, token) =>
        {
            started.SetResult();
            try
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, token);
                return "version-2";
            }
            finally
            {
                stopped.SetResult();
            }
        };
 
        var project = await provider.GetRequiredService<IProjectLocator>().UseOrFindAppHostProjectFileAsync(
            projectFile, createSettingsFile: false, CancellationToken.None).DefaultTimeout();
        await started.Task.DefaultTimeout();
 
        Assert.Equal(projectFile.FullName, project?.FullName);
        Assert.False(stopped.Task.IsCompleted);
        await provider.GetRequiredService<RootCommand>().Parse("config list").InvokeAsync().DefaultTimeout();
        Assert.True(stopped.Task.IsCompletedSuccessfully);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task Docs_UsesMaterializedConfigurationWithoutAcquisition(bool localOverride)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string sharedUrl = "https://shared.example.test/llms-full.txt";
        const string localUrl = "https://local.example.test/llms-full.txt";
        var acquired = new TestConfigSourceRepository
        {
            ReadConfiguration = (_, _) => Task.FromResult(("version-1", new JsonObject
            {
                ["docs"] = new JsonObject { ["llmsTxtUrl"] = sharedUrl }
            }))
        };
        await TestConfigSourceServices.Create(workspace.CreateExecutionContext(), git: acquired)
            .MaterializeAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        if (localOverride)
        {
            await ConfigurationService.SetConfigurationInFileAsync(
                Path.Combine(workspace.Path, AspireConfigFile.FileName), "docs.llmsTxtUrl", localUrl);
        }
        using var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("# Shared Document\n\n[Related](/guide/)\n")
        };
        string? requestedUrl = null;
        using var handler = new MockHttpMessageHandler(response, request => requestedUrl = request.RequestUri!.AbsoluteUri);
        using var httpClient = new HttpClient(handler);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        services.AddSingleton<IDocsFetcher>(provider => new DocsFetcher(httpClient,
            provider.GetRequiredService<IDocsCache>(), provider.GetRequiredService<IConfiguration>(), NullLogger<DocsFetcher>.Instance));
        using var provider = services.BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        var root = provider.GetRequiredService<RootCommand>();
        Assert.Equal(0, git.Calls);
 
        var result = root.Parse("docs get shared-document --format json");
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
 
        Assert.Equal(localOverride ? localUrl : sharedUrl, requestedUrl);
        Assert.Equal(0, git.Calls);
        Assert.Equal(0, git.CheckCalls);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ApiDocs_UsesMaterializedConfigurationWithoutAcquisition(bool localOverride)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string sharedUrl = "https://shared.example.test/sitemap.xml";
        const string localUrl = "https://local.example.test/sitemap.xml";
        var acquired = new TestConfigSourceRepository
        {
            ReadConfiguration = (_, _) => Task.FromResult(("version-1", new JsonObject
            {
                ["docs"] = new JsonObject { ["api"] = new JsonObject { ["sitemapUrl"] = sharedUrl } }
            }))
        };
        await TestConfigSourceServices.Create(workspace.CreateExecutionContext(), git: acquired)
            .MaterializeAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var expectedUrl = localOverride ? localUrl : sharedUrl;
        if (localOverride)
        {
            await ConfigurationService.SetConfigurationInFileAsync(
                Path.Combine(workspace.Path, AspireConfigFile.FileName), "docs.api.sitemapUrl", localUrl);
        }
        var requests = new List<string>();
        using var handler = new MockHttpMessageHandler(request =>
        {
            requests.Add(request.RequestUri!.AbsoluteUri);
            return new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(request.RequestUri.AbsoluteUri.EndsWith(".xml", StringComparison.Ordinal) ? """
                    <?xml version="1.0" encoding="UTF-8"?>
                    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
                      <url><loc>https://aspire.dev/reference/api/csharp/aspire.hosting/</loc></url>
                    </urlset>
                    """ : "# Aspire.Hosting\n\n[Related](/guide/)\n")
            };
        });
        using var httpClient = new HttpClient(handler);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        services.AddSingleton<IApiDocsFetcher>(provider => new ApiDocsFetcher(httpClient,
            provider.GetRequiredService<IApiDocsCache>(), provider.GetRequiredService<IConfiguration>(), NullLogger<ApiDocsFetcher>.Instance));
        using var provider = services.BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        var root = provider.GetRequiredService<RootCommand>();
        Assert.Equal(0, git.Calls);
 
        var result = root.Parse("docs api get csharp/aspire.hosting --format json");
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
 
        Assert.Equal([expectedUrl, $"{new Uri(expectedUrl).GetLeftPart(UriPartial.Authority)}/reference/api/csharp/aspire.hosting.md"], requests);
        Assert.Equal(0, git.Calls);
        Assert.Equal(0, git.CheckCalls);
    }
 
    [Fact]
    public async Task UpdateConfigSource_RefreshesFreshCacheWithoutUpdatingPackages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Refresh-only mode must not resolve package channels.")
            };
        });
        using var provider = services.BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        var service = provider.GetRequiredService<ConfigSourceService>();
        await service.CheckProjectAsync(workspace.WorkspaceRoot, force: false, CancellationToken.None);
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --config-source --yes --non-interactive");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(1, git.Calls);
        Assert.Equal(1, git.CheckCalls);
        Assert.False(Assert.IsType<UpdateCommand>(result.CommandResult.Command).RequiresCliPackageMetadata);
    }
 
    [Theory]
    [InlineData(false, 0)]
    [InlineData(true, 0)]
    [InlineData(true, 64 * 1024)]
    [InlineData(true, 64 * 1024 + 1)]
    public async Task UpdateConfigSource_AcquisitionFailureRemainsCommandFailure(bool timeout, int outputLength)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var interaction = new TestInteractionService();
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interaction;
        }).BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        var prompt = outputLength == 0 ? "Enter passphrase: " : new string('x', outputLength - 1) + "?";
        git.ReadConfiguration = (_, _) => throw new ConfigSourceException(timeout ? ConfigSourceStrings.GitTimeout : ConfigSourceStrings.AuthenticationFailed)
        {
            GitOutput = timeout ? prompt : null
        };
        var path = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        var previous = await File.ReadAllTextAsync(path);
        var result = provider.GetRequiredService<RootCommand>().Parse("update --config-source --yes --non-interactive");
 
        Assert.NotEqual(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
 
        Assert.Equal(1, git.Calls);
        Assert.Equal(0, git.CheckCalls);
        Assert.Equal(previous, await File.ReadAllTextAsync(path));
        if (timeout)
        {
            var expectedOutput = outputLength > 64 * 1024 ? prompt[^(64 * 1024)..] : prompt;
            Assert.Equal((expectedOutput, (ConsoleOutput?)ConsoleOutput.Error), Assert.Single(interaction.DisplayedRawText));
        }
        else
        {
            Assert.Empty(interaction.DisplayedRawText);
        }
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("ref")]
    [InlineData("repository")]
    [InlineData("path")]
    [InlineData("both")]
    public async Task ProjectSelection_CheckFailureDoesNotFailOrReplaceSavedSettings(string? invalidField)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(new()
        {
            Repository = s_source.Repository,
            Path = s_source.Path,
            Ref = invalidField == "ref" ? "HEAD" : null,
            AppliedVersion = s_source.AppliedVersion
        }, workspace.WorkspaceRoot, CancellationToken.None);
        var path = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        await ConfigurationService.SetConfigurationInFileAsync(path, "docs.llmsTxtUrl", "https://local.example/llms.txt");
        await ConfigurationService.SetConfigurationInFileAsync(path, "appHost.path", "apphost.cs");
        if (invalidField is "repository" or "path" or "both")
        {
            var configuration = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject();
            var binding = configuration["configSource"]!.AsObject();
            foreach (var field in invalidField == "both" ? new[] { "repository", "path" } : [invalidField])
            {
                Assert.True(binding.Remove(field));
            }
            await File.WriteAllTextAsync(path, configuration.ToJsonString());
        }
        var previous = await File.ReadAllTextAsync(path);
        var projectFile = new FileInfo(Path.Combine(workspace.Path, "apphost.cs"));
        await File.WriteAllTextAsync(projectFile.FullName, "#:sdk Aspire.AppHost.Sdk");
        var interaction = new TestInteractionService();
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interaction;
        }).BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.InspectConfiguration = (_, _) => throw new ConfigSourceException(ConfigSourceStrings.AuthenticationFailed);
 
        var project = await provider.GetRequiredService<IProjectLocator>().UseOrFindAppHostProjectFileAsync(
            projectFile, createSettingsFile: true, CancellationToken.None);
        await provider.GetRequiredService<ConfigSourceService>().WaitForChecksAsync().DefaultTimeout();
        Assert.Equal(projectFile.FullName, project?.FullName);
        var result = provider.GetRequiredService<RootCommand>().Parse("config get docs.llmsTxtUrl");
 
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
        Assert.Equal(previous, await File.ReadAllTextAsync(path));
        Assert.Equal(0, git.Calls);
        Assert.Equal(invalidField is not null ? 0 : 1, git.CheckCalls);
        var expectedError = invalidField switch
        {
            null => ConfigSourceStrings.AuthenticationFailed,
            "ref" => ConfigSourceStrings.InvalidRef,
            _ => ConfigSourceStrings.InvalidBinding
        };
        var warning = Assert.Single(interaction.DisplayedMessages, message => message.ConsoleOverride is Aspire.Cli.Interaction.ConsoleOutput.Error);
        Assert.Equal(string.Format(System.Globalization.CultureInfo.CurrentCulture, ConfigSourceStrings.CheckFailed,
            expectedError), warning.Message);
        if (invalidField is not null)
        {
            var refresh = provider.GetRequiredService<RootCommand>().Parse("update --config-source --yes --non-interactive");
            Assert.Equal(CliExitCodes.InvalidCommand, await refresh.InvokeAsync().DefaultTimeout());
            Assert.Equal(expectedError, Assert.Single(interaction.DisplayedErrors));
            Assert.Equal(0, git.Calls);
            Assert.Equal(previous, await File.ReadAllTextAsync(path));
        }
    }
 
    [Fact]
    public void UpdateConfigSource_NonInteractiveRequiresYes()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
 
        var result = provider.GetRequiredService<RootCommand>().Parse("update --config-source --non-interactive");
 
        Assert.Single(result.Errors);
    }
 
    [Theory]
    [InlineData("--self")]
    [InlineData("--migrate")]
    [InlineData("--channel daily")]
    [InlineData("--quality stable")]
    [InlineData("--nuget-config-dir .")]
    public void UpdateConfigSource_RejectsIncompatibleOptions(string option)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
 
        var result = provider.GetRequiredService<RootCommand>().Parse($"update --config-source {option}");
 
        Assert.Equal(ConfigSourceStrings.IncompatibleOptions, Assert.Single(result.Errors).Message);
    }
 
    [Theory]
    [InlineData(false, "implicit")]
    [InlineData(false, "file")]
    [InlineData(false, "directory")]
    [InlineData(true, "implicit")]
    [InlineData(true, "file")]
    [InlineData(true, "directory")]
    public async Task ProjectSelection_ProcessesAncestorSourceForChildAppHost(bool refresh, string selection)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var project = workspace.CreateDirectory("AppHost");
        var projectFile = new FileInfo(Path.Combine(project.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(projectFile.FullName, "#:sdk Aspire.AppHost.Sdk");
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var rootPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        await ConfigurationService.SetConfigurationInFileAsync(rootPath, "appHost.path", "AppHost/apphost.cs");
        var original = await File.ReadAllTextAsync(rootPath);
        var interaction = new TestInteractionService();
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interaction;
        }).BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.InspectConfiguration = (_, _) => Task.FromResult("version-2");
        git.ReadConfiguration = (_, _) => Task.FromResult(("version-2", new JsonObject()));
        var requested = selection switch
        {
            "file" => projectFile,
            "directory" => new FileInfo(project.FullName),
            _ => null
        };
 
        if (refresh)
        {
            var argument = requested is null ? "" : $" --apphost \"{requested.FullName}\"";
            var result = provider.GetRequiredService<RootCommand>().Parse($"update --config-source{argument} --yes --non-interactive");
            Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
            Assert.Equal("version-2", AspireConfigFile.Load(workspace.Path)!.ConfigSource!.AppliedVersion);
        }
        else
        {
            var selected = await provider.GetRequiredService<IProjectLocator>().UseOrFindAppHostProjectFileAsync(
                requested, createSettingsFile: false, CancellationToken.None);
            Assert.Equal(projectFile.FullName, selected?.FullName);
            await provider.GetRequiredService<ConfigSourceService>().WaitForChecksAsync().DefaultTimeout();
            var service = provider.GetRequiredService<ConfigSourceService>();
            service.NotifyIfUpdateAvailable();
            Assert.Equal(ConfigSourceStrings.UpdateAvailable, Assert.Single(interaction.DisplayedMessages).Message);
            Assert.Equal(original, await File.ReadAllTextAsync(rootPath));
        }
 
        Assert.Equal(refresh ? 1 : 0, git.Calls);
        Assert.Equal(refresh ? 0 : 1, git.CheckCalls);
        Assert.False(File.Exists(Path.Combine(project.FullName, AspireConfigFile.FileName)));
    }
 
    [Theory]
    [InlineData("missing.cs")]
    [InlineData("missing.csproj")]
    [InlineData("missing-directory")]
    [InlineData("missing-directory/apphost.cs")]
    public async Task UpdateConfigSource_MissingExplicitPathFailsWithoutRefreshingBindings(string requestedPath)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var localPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        var originalLocalConfiguration = await File.ReadAllTextAsync(localPath);
        var interaction = new TestInteractionService();
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interaction;
        }).BuildServiceProvider();
        var globalFile = new FileInfo(provider.GetRequiredService<IConfigurationService>().GetSettingsFilePath(isGlobal: true));
        globalFile.Directory!.Create();
        await TestConfigSourceServices.SaveBindingAsync(s_source, globalFile.Directory, CancellationToken.None);
        File.Move(Path.Combine(globalFile.Directory.FullName, AspireConfigFile.FileName), globalFile.FullName);
        var originalGlobalConfiguration = await File.ReadAllTextAsync(globalFile.FullName);
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.ReadConfiguration = (_, _) => Task.FromResult(("version-2", new JsonObject()));
        var result = provider.GetRequiredService<RootCommand>().Parse(
            $"update --config-source --apphost \"{Path.Combine(workspace.Path, requestedPath)}\" --yes --non-interactive");
        Assert.Empty(result.Errors);
 
        Assert.Equal(CliExitCodes.FailedToFindProject, await result.InvokeAsync().DefaultTimeout());
        Assert.Equal(InteractionServiceStrings.ProjectOptionDoesntExist, Assert.Single(interaction.DisplayedErrors));
        Assert.Equal(0, git.Calls);
        Assert.Equal(0, git.CheckCalls);
        Assert.Equal(0, Assert.IsType<TestConfigSourceDetector>(provider.GetRequiredService<IInternalMicrosoftDetector>()).Calls);
        Assert.Equal(originalLocalConfiguration, await File.ReadAllTextAsync(localPath));
        Assert.Equal(originalGlobalConfiguration, await File.ReadAllTextAsync(globalFile.FullName));
    }
 
    [Fact]
    public async Task UpdateConfigSource_ExplicitAppHostRefreshesNearestBindingOnly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var project = workspace.CreateDirectory("selected-project");
        await File.WriteAllTextAsync(Path.Combine(project.FullName, "apphost.cs"), "#:sdk Aspire.AppHost.Sdk");
        await TestConfigSourceServices.SaveBindingAsync(
            new ConfigSource { Repository = "https://parent.example/configuration", Path = "parent.json", AppliedVersion = "parent-version" },
            workspace.WorkspaceRoot, CancellationToken.None);
        var parentPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        await ConfigurationService.SetConfigurationInFileAsync(parentPath, "appHost.path", "selected-project/apphost.cs");
        var parentConfiguration = await File.ReadAllTextAsync(parentPath);
        await TestConfigSourceServices.SaveBindingAsync(s_source, project, CancellationToken.None);
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"update --config-source --apphost \"{Path.Combine(project.FullName, "apphost.cs")}\" --yes --non-interactive");
 
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
        Assert.Equal(1, Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>()).Calls);
        Assert.Equal(0, Assert.IsType<TestConfigSourceDetector>(provider.GetRequiredService<IInternalMicrosoftDetector>()).Calls);
        Assert.Equal(parentConfiguration, await File.ReadAllTextAsync(parentPath));
        Assert.Equal("version-1", AspireConfigFile.Load(project.FullName)!.ConfigSource!.AppliedVersion);
    }
 
    [Fact]
    public async Task UpdateConfigSource_MissingBindingSucceedsWithoutDetectionOrAcquisition()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interaction = new TestInteractionService();
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interaction;
        }).BuildServiceProvider();
        var result = provider.GetRequiredService<RootCommand>().Parse("update --config-source --yes --non-interactive");
 
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
        Assert.Empty(interaction.DisplayedErrors);
        var notice = Assert.Single(interaction.DisplayedMessages);
        Assert.Equal(ConfigSourceStrings.NoBinding, notice.Message);
        Assert.Equal(ConsoleOutput.Error, notice.ConsoleOverride);
        Assert.Equal(0, Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>()).Calls);
        Assert.Equal(0, Assert.IsType<TestConfigSourceDetector>(provider.GetRequiredService<IInternalMicrosoftDetector>()).Calls);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task UpdateConfigSource_RefreshesGlobalBindingWhenNearestConfigurationHasNone(bool explicitAppHost)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var localPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(localPath, "{}");
        if (explicitAppHost)
        {
            await File.WriteAllTextAsync(Path.Combine(workspace.Path, "apphost.cs"), "#:sdk Aspire.AppHost.Sdk");
        }
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
        var globalFile = new FileInfo(provider.GetRequiredService<IConfigurationService>().GetSettingsFilePath(isGlobal: true));
        globalFile.Directory!.Create();
        await TestConfigSourceServices.SaveBindingAsync(s_source, globalFile.Directory, CancellationToken.None);
        File.Move(Path.Combine(globalFile.Directory.FullName, AspireConfigFile.FileName), globalFile.FullName);
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.ReadConfiguration = (_, _) => Task.FromResult(("version-2", new JsonObject()));
        var appHostArgument = explicitAppHost ? $" --apphost \"{Path.Combine(workspace.Path, "apphost.cs")}\"" : "";
        var result = provider.GetRequiredService<RootCommand>().Parse(
            $"update --config-source{appHostArgument} --yes --non-interactive");
 
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
 
        Assert.Equal(1, git.Calls);
        Assert.Equal(0, git.CheckCalls);
        Assert.Equal("{}", await File.ReadAllTextAsync(localPath));
        var globalConfiguration = JsonNode.Parse(await File.ReadAllTextAsync(globalFile.FullName))!;
        Assert.Equal("version-2", globalConfiguration["configSource"]!["appliedVersion"]!.GetValue<string>());
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task UpdateConfigSource_RefreshesNearestConfigurationForConfiguredProject(bool bound, bool childConfigExists)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interaction = new TestInteractionService();
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var selected = workspace.CreateDirectory("selected");
        var selectedFile = new FileInfo(Path.Combine(selected.FullName, "apphost.cs"));
        var selectedSource = new ConfigSource { Repository = s_source.Repository, Path = "selected.json", AppliedVersion = s_source.AppliedVersion };
        if (bound)
        {
            await TestConfigSourceServices.SaveBindingAsync(selectedSource, selected, CancellationToken.None);
        }
        else if (childConfigExists)
        {
            await File.WriteAllTextAsync(Path.Combine(selected.FullName, AspireConfigFile.FileName), "{}");
        }
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interaction;
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                GetAppHostFromSettingsAsyncCallback = _ => Task.FromResult<FileInfo?>(selectedFile)
            };
        });
        using var provider = services.BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.ReadConfiguration = (source, _) =>
        {
            Assert.True(ConfigSource.HasSameIdentity(selectedSource, source) || ConfigSource.HasSameIdentity(s_source, source));
            Assert.Null(source.AppliedVersion);
            return Task.FromResult(("version-1", new JsonObject()));
        };
        var result = provider.GetRequiredService<RootCommand>().Parse("update --config-source --yes --non-interactive");
 
        var hasBinding = bound || !childConfigExists;
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
        Assert.Empty(interaction.DisplayedErrors);
        if (!hasBinding)
        {
            Assert.Equal(ConfigSourceStrings.NoBinding, Assert.Single(interaction.DisplayedMessages).Message);
        }
        Assert.Equal(hasBinding ? 1 : 0, git.Calls);
        Assert.Equal(bound || childConfigExists ? s_source.AppliedVersion : "version-1", AspireConfigFile.Load(workspace.Path)!.ConfigSource!.AppliedVersion);
        Assert.Equal(bound, AspireConfigFile.Load(selected.FullName)?.ConfigSource is not null);
    }
 
    [Fact]
    public async Task ProjectSelection_ChecksAncestorSourceWithoutCopyingItsBinding()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var inheritedUrl = "https://parent.example/llms.txt";
        var initial = TestConfigSourceServices.Create(workspace.CreateExecutionContext(), git: new TestConfigSourceRepository
        {
            ReadConfiguration = (_, _) => Task.FromResult(("version-1", new JsonObject
            {
                ["docs"] = new JsonObject { ["llmsTxtUrl"] = inheritedUrl }
            }))
        });
        await initial.MaterializeAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var parentPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        await ConfigurationService.SetConfigurationInFileAsync(parentPath, "appHost.path", "A/apphost.cs");
        var parentConfiguration = await File.ReadAllTextAsync(parentPath);
        var selected = workspace.CreateDirectory("B");
        var projectFile = new FileInfo(Path.Combine(selected.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(projectFile.FullName, "#:sdk Aspire.AppHost.Sdk");
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
 
        var project = await provider.GetRequiredService<IProjectLocator>().UseOrFindAppHostProjectFileAsync(
            projectFile, createSettingsFile: false, CancellationToken.None);
        var sources = provider.GetRequiredService<ConfigSourceService>();
        await sources.WaitForChecksAsync().DefaultTimeout();
 
        Assert.Equal(projectFile.FullName, project?.FullName);
        var inherited = await provider.GetRequiredService<IConfigurationService>().GetConfigurationFromDirectoryAsync(
            "docs.llmsTxtUrl", selected, continueSearchWhenKeyMissing: true, cancellationToken: CancellationToken.None);
        Assert.Equal(inheritedUrl, inherited);
        await sources.CheckProjectAsync(selected, force: true, CancellationToken.None);
        Assert.Equal(1, git.Calls);
        Assert.Equal(0, git.CheckCalls);
        Assert.Equal(parentConfiguration, await File.ReadAllTextAsync(parentPath));
        Assert.False(File.Exists(Path.Combine(selected.FullName, AspireConfigFile.FileName)));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ProjectSelection_ChecksResolvedProjectSource(bool explicitProject)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var selected = workspace.CreateDirectory("selected");
        var projectFile = new FileInfo(Path.Combine(selected.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(projectFile.FullName, "#:sdk Aspire.AppHost.Sdk");
        var selectedSource = new ConfigSource { Repository = s_source.Repository, Path = "selected.json", AppliedVersion = s_source.AppliedVersion };
        await TestConfigSourceServices.SaveBindingAsync(selectedSource, selected, CancellationToken.None);
        await ConfigurationService.SetConfigurationInFileAsync(
            Path.Combine(workspace.Path, AspireConfigFile.FileName), "appHost.path", "selected/apphost.cs");
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.InspectConfiguration = (source, _) =>
        {
            Assert.True(ConfigSource.HasSameIdentity(selectedSource, source));
            Assert.Null(source.AppliedVersion);
            return Task.FromResult("version-1");
        };
        var project = await provider.GetRequiredService<IProjectLocator>().UseOrFindAppHostProjectFileAsync(
            explicitProject ? projectFile : null, createSettingsFile: false, CancellationToken.None);
        await provider.GetRequiredService<ConfigSourceService>().WaitForChecksAsync().DefaultTimeout();
 
        Assert.Equal(projectFile.FullName, project?.FullName);
        Assert.Equal(1, git.CheckCalls);
        Assert.Equal(0, git.Calls);
    }
 
    [Theory]
    [InlineData(true, true)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(false, false)]
    public async Task UpdateConfigSource_ExplicitDirectoryResolvesAppHostBeforeBindingFallback(bool hasAppHost, bool bound)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        var parentPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        var parentConfiguration = await File.ReadAllTextAsync(parentPath);
        var requestedDirectory = workspace.CreateDirectory("selected");
        var projectDirectory = hasAppHost ? requestedDirectory.CreateSubdirectory("AppHost") : requestedDirectory;
        if (hasAppHost)
        {
            await File.WriteAllTextAsync(Path.Combine(projectDirectory.FullName, "apphost.cs"), "#:sdk Aspire.AppHost.Sdk");
            await ConfigurationService.SetConfigurationInFileAsync(parentPath, "appHost.path", "selected/AppHost/apphost.cs");
            parentConfiguration = await File.ReadAllTextAsync(parentPath);
        }
        var selectedSource = new ConfigSource
        {
            Repository = "https://selected.example/configuration",
            Path = "selected.json",
            AppliedVersion = "version-1"
        };
        var selectedPath = Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName);
        if (bound)
        {
            await TestConfigSourceServices.SaveBindingAsync(selectedSource, projectDirectory, CancellationToken.None);
        }
        else
        {
            await File.WriteAllTextAsync(selectedPath, "{}");
        }
        var originalConfiguration = await File.ReadAllTextAsync(selectedPath);
        var interaction = new TestInteractionService();
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interaction;
        }).BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.ReadConfiguration = (source, _) =>
        {
            Assert.True(ConfigSource.HasSameIdentity(selectedSource, source));
            return Task.FromResult(("version-2", new JsonObject()));
        };
        var result = provider.GetRequiredService<RootCommand>().Parse(
            $"update --config-source --apphost \"{requestedDirectory.FullName}\" --yes --non-interactive");
 
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
 
        Assert.Empty(interaction.DisplayedErrors);
        Assert.Equal(bound ? 1 : 0, git.Calls);
        Assert.Equal(0, git.CheckCalls);
        Assert.Equal(0, Assert.IsType<TestConfigSourceDetector>(provider.GetRequiredService<IInternalMicrosoftDetector>()).Calls);
        Assert.Equal(parentConfiguration, await File.ReadAllTextAsync(parentPath));
        if (bound)
        {
            Assert.Equal("version-2", AspireConfigFile.Load(projectDirectory.FullName)!.ConfigSource!.AppliedVersion);
        }
        else
        {
            var notice = Assert.Single(interaction.DisplayedMessages);
            Assert.Equal(ConfigSourceStrings.NoBinding, notice.Message);
            Assert.Equal(ConsoleOutput.Error, notice.ConsoleOverride);
            Assert.Equal(originalConfiguration, await File.ReadAllTextAsync(selectedPath));
        }
    }
 
    [Theory]
    [InlineData("run")]
    [InlineData("start")]
    [InlineData("restore")]
    public async Task ProjectCommands_DoNotCheckSourceWhenProjectResolutionFails(string commandLine)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
        var missingProject = Path.Combine(workspace.Path, "missing.csproj");
        var result = provider.GetRequiredService<RootCommand>().Parse($"{commandLine} --apphost \"{missingProject}\"");
        Assert.Empty(result.Errors);
 
        Assert.NotEqual(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
 
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        Assert.Equal(0, git.CheckCalls);
        Assert.Equal(0, git.Calls);
    }
 
    [Theory]
    [InlineData("config get features.shared")]
    [InlineData("config list --all")]
    [InlineData("config set --global features.shared true")]
    public async Task CommandsWithoutProjectResolution_DoNotCheckSavedSource(string commandLine)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
        var result = provider.GetRequiredService<RootCommand>().Parse(commandLine);
        Assert.Empty(result.Errors);
 
        await result.InvokeAsync().DefaultTimeout();
 
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        Assert.Equal(0, git.Calls);
        Assert.Equal(0, git.CheckCalls);
    }
 
    [Theory]
    [InlineData("update --help")]
    [InlineData("new --help")]
    [InlineData("init --help")]
    public async Task Help_DoesNotAcquireSavedSource(string commandLine)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await TestConfigSourceServices.SaveBindingAsync(s_source, workspace.WorkspaceRoot, CancellationToken.None);
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
        var result = provider.GetRequiredService<RootCommand>().Parse(commandLine);
 
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
        Assert.Equal(0, Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>()).Calls);
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(true, true)]
    public async Task New_PersistsSourceOnlyInCreatedProjectWhenAccepted(bool accepted, bool localTemplate)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var output = Path.Combine(workspace.Path, "created-project");
        if (localTemplate)
        {
            var config = workspace.CreateDirectory("template/.template.config");
            await File.WriteAllTextAsync(Path.Combine(config.FullName, "template.json"), """
                { "identity": "ConfigSource.Local", "name": "Local template", "shortName": "local" }
                """);
            await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", AspireConfigFile.FileName), "{}");
        }
        var templateOptions = localTemplate
            ? "--template-path template"
            : "aspire-empty --language csharp --version 17.0.0";
        var services = CreateCreationServices(workspace);
        using var provider = services.BuildServiceProvider();
        var result = provider.GetRequiredService<RootCommand>().Parse(
            $"new {templateOptions} --name CreatedProject --output \"{output}\" --suppress-agent-init --use-config-source {accepted.ToString().ToLowerInvariant()} --non-interactive");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equivalent(accepted ? s_source : null, AspireConfigFile.Load(output)?.ConfigSource, strict: true);
        var configuration = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(output, AspireConfigFile.FileName)))!;
        Assert.Equal(accepted ? "https://docs.example/llms.txt" : null, configuration["docs"]?["llmsTxtUrl"]?.GetValue<string>());
        Assert.Equal(accepted ? 1 : 0, Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>()).Calls);
        Assert.False(File.Exists(Path.Combine(workspace.Path, AspireConfigFile.FileName)));
    }
 
    [Fact]
    public async Task Init_PersistsAcceptedSourceAlongsideSkeleton()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var provider = CreateCreationServices(workspace).BuildServiceProvider();
        var result = provider.GetRequiredService<RootCommand>().Parse(
            "init --file-based --language csharp --suppress-agent-init --use-config-source true --non-interactive");
 
        Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
        Assert.Equivalent(s_source, AspireConfigFile.Load(workspace.Path)!.ConfigSource, strict: true);
        var configuration = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(workspace.Path, AspireConfigFile.FileName)))!;
        Assert.Equal("https://docs.example/llms.txt", configuration["docs"]!["llmsTxtUrl"]!.GetValue<string>());
        Assert.True(File.Exists(Path.Combine(workspace.Path, "apphost.cs")));
        Assert.Equal(1, Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>()).Calls);
    }
 
    [Fact]
    public async Task Init_ExistingBindingChecksWithoutReapplyingOrRetargeting()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string command = "init --file-based --language csharp --suppress-agent-init --use-config-source true --non-interactive";
        using (var original = CreateCreationServices(workspace).BuildServiceProvider())
        {
            Assert.Equal(CliExitCodes.Success, await original.GetRequiredService<RootCommand>().Parse(command).InvokeAsync().DefaultTimeout());
        }
        var path = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        await ConfigurationService.SetConfigurationInFileAsync(path, "docs.llmsTxtUrl", "https://local.example/llms.txt");
        var services = CreateCreationServices(workspace);
        services.AddSingleton<IEnvironment>(new TestEnvironment(new Dictionary<string, string?>
        {
            [CliConfigNames.InitSourceUrl] = "https://other.example/configuration"
        }));
        using var provider = services.BuildServiceProvider();
 
        Assert.Equal(CliExitCodes.Success, await provider.GetRequiredService<RootCommand>().Parse(
            "init --file-based --language csharp --suppress-agent-init --non-interactive").InvokeAsync().DefaultTimeout());
 
        var configuration = JsonNode.Parse(await File.ReadAllTextAsync(path))!;
        Assert.Equal("https://local.example/llms.txt", configuration["docs"]!["llmsTxtUrl"]!.GetValue<string>());
        Assert.Equivalent(s_source, configuration["configSource"]!.Deserialize(JsonSourceGenerationContext.Default.ConfigSource), strict: true);
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        Assert.Equal(0, git.Calls);
        Assert.Equal(0, git.CheckCalls);
    }
 
    [Theory]
    [InlineData(false, true, false)]
    [InlineData(true, true, false)]
    [InlineData(true, true, true)]
    [InlineData(true, false, false)]
    [InlineData(true, false, true)]
    public async Task Init_SolutionModeUsesAppHostConfigurationAndRefreshesIt(bool existingAppHost, bool appHostConfig, bool existingBinding)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await File.WriteAllTextAsync(Path.Combine(workspace.Path, "Test.sln"), "Fake solution file");
        var childPath = Path.Combine(workspace.Path, "Test.AppHost");
        var projectPath = Path.Combine(childPath, "Test.AppHost.csproj");
        var rootConfigPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(rootConfigPath, """
            {"appHost":{"path":"Test.AppHost/Test.AppHost.csproj"},"custom":"solution","docs":{"llmsTxtUrl":"https://solution.example/llms.txt"}}
            """);
        if (appHostConfig)
        {
            await TestConfigSourceServices.SaveBindingAsync(new ConfigSource
            {
                Repository = "https://solution.example/configuration",
                Path = "solution.json",
                AppliedVersion = "solution-version"
            }, workspace.WorkspaceRoot, CancellationToken.None);
        }
        const string templateConfig = """
            {"appHost":{"path":"Test.AppHost.csproj"},"profiles":{"custom":{"applicationUrl":"https://localhost:1234"}},"custom":"template"}
            """;
        if (existingAppHost)
        {
            Directory.CreateDirectory(childPath);
            await File.WriteAllTextAsync(projectPath, "<Project />");
            if (appHostConfig)
            {
                await File.WriteAllTextAsync(Path.Combine(childPath, AspireConfigFile.FileName), templateConfig);
            }
        }
        var configDirectory = appHostConfig ? new DirectoryInfo(childPath) : workspace.WorkspaceRoot;
        var configPath = Path.Combine(configDirectory.FullName, AspireConfigFile.FileName);
        if (existingBinding)
        {
            await TestConfigSourceServices.SaveBindingAsync(s_source, configDirectory, CancellationToken.None);
            await ConfigurationService.SetConfigurationInFileAsync(configPath, "docs.llmsTxtUrl", "https://local.example/llms.txt");
        }
        var originalRootConfiguration = await File.ReadAllTextAsync(rootConfigPath);
        var originalConfiguration = existingBinding ? await File.ReadAllTextAsync(configPath) : null;
        var services = CreateCreationServices(workspace);
        services.AddSingleton(workspace.CreateExecutionContext(identityChannel: "default"));
        var cache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
                Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                    [new() { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "17.0.0" }])
        };
        services.AddSingleton<Aspire.Cli.Packaging.IPackagingService>(new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<Aspire.Cli.Packaging.PackageChannel>>(
                [Aspire.Cli.Packaging.PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance)])
        });
        services.AddSingleton<Aspire.Cli.DotNet.IDotNetCliRunner>(new TestDotNetCliRunner
        {
            NewProjectAsyncCallback = (_, name, outputPath, _, _) =>
            {
                Directory.CreateDirectory(outputPath);
                File.WriteAllText(Path.Combine(outputPath, $"{name}.csproj"), "<Project />");
                File.WriteAllText(Path.Combine(outputPath, AspireConfigFile.FileName), templateConfig);
                return 0;
            }
        });
        services.AddSingleton<Aspire.Cli.Certificates.ICertificateService>(provider => new TestCertificateService
        {
            EnsureCertificatesTrustedAsyncCallback = async cancellationToken =>
            {
                // Keep init alive until its advisory check finishes instead of racing shutdown.
                await provider.GetRequiredService<ConfigSourceService>().WaitForChecksAsync().DefaultTimeout();
                return await new TestCertificateService().EnsureCertificatesTrustedAsync(cancellationToken);
            }
        });
        if (existingBinding)
        {
            services.AddSingleton<IEnvironment>(new TestEnvironment(new Dictionary<string, string?>
            {
                [CliConfigNames.InitSourceUrl] = "https://other.example/configuration"
            }));
        }
        using var provider = services.BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        ConfigSource? checkedSource = null;
        git.InspectConfiguration = (source, _) =>
        {
            checkedSource = source;
            return Task.FromResult("version-2");
        };
        var command = existingBinding
            ? "init --language csharp --suppress-agent-init --non-interactive"
            : "init --language csharp --suppress-agent-init --use-config-source true --non-interactive";
 
        var exitCode = await provider.GetRequiredService<RootCommand>().Parse(command).InvokeAsync().DefaultTimeout();
        Assert.True(exitCode == CliExitCodes.Success, string.Join(Environment.NewLine,
            Assert.IsType<TestInteractionService>(provider.GetRequiredService<IInteractionService>()).DisplayedErrors));
        var sources = provider.GetRequiredService<ConfigSourceService>();
        await sources.WaitForChecksAsync().DefaultTimeout();
 
        var configuration = AspireConfigFile.Load(configDirectory.FullName)!;
        Assert.Equivalent(s_source, configuration.ConfigSource, strict: true);
        Assert.Equal(existingBinding ? "https://local.example/llms.txt" : "https://docs.example/llms.txt", configuration.Docs?.LlmsTxtUrl);
        Assert.True(Directory.Exists(childPath));
        Assert.Equal(appHostConfig, File.Exists(Path.Combine(childPath, AspireConfigFile.FileName)));
        Assert.Equal(appHostConfig ? "Test.AppHost.csproj" : "Test.AppHost/Test.AppHost.csproj", configuration.AppHost!.Path);
        if (appHostConfig)
        {
            Assert.Equal("https://localhost:1234", configuration.Profiles!["custom"].ApplicationUrl);
            Assert.Equal(originalRootConfiguration, await File.ReadAllTextAsync(rootConfigPath));
        }
        Assert.Equal(existingBinding ? 0 : 1, git.Calls);
        Assert.Equal(existingBinding ? 1 : 0, git.CheckCalls);
        if (existingBinding)
        {
            Assert.NotNull(checkedSource);
            Assert.True(ConfigSource.HasSameIdentity(s_source, checkedSource));
            Assert.Equal(originalConfiguration, await File.ReadAllTextAsync(configPath));
        }
        else
        {
            Assert.Null(checkedSource);
        }
 
        using var refreshProvider = CreateCreationServices(workspace).BuildServiceProvider();
        var refreshGit = Assert.IsType<TestConfigSourceRepository>(refreshProvider.GetRequiredService<IGitClient>());
        refreshGit.ReadConfiguration = (source, _) =>
        {
            Assert.True(ConfigSource.HasSameIdentity(s_source, source));
            return Task.FromResult(("version-2", new JsonObject
            {
                ["docs"] = new JsonObject { ["llmsTxtUrl"] = "https://updated.example/llms.txt" }
            }));
        };
        var refresh = refreshProvider.GetRequiredService<RootCommand>().Parse(
            "update --config-source --yes --non-interactive");
 
        Assert.Equal(CliExitCodes.Success, await refresh.InvokeAsync().DefaultTimeout());
 
        var refreshed = AspireConfigFile.Load(configDirectory.FullName)!;
        Assert.Equal("version-2", refreshed.ConfigSource!.AppliedVersion);
        Assert.Equal("https://updated.example/llms.txt", refreshed.Docs!.LlmsTxtUrl);
        Assert.Equal(configuration.AppHost.Path, refreshed.AppHost!.Path);
        Assert.Equal(appHostConfig, File.Exists(Path.Combine(childPath, AspireConfigFile.FileName)));
        if (appHostConfig)
        {
            Assert.Equal("https://localhost:1234", refreshed.Profiles!["custom"].ApplicationUrl);
            Assert.Equal(originalRootConfiguration, await File.ReadAllTextAsync(rootConfigPath));
        }
        Assert.Equal(1, refreshGit.Calls);
        Assert.Equal(0, refreshGit.CheckCalls);
    }
 
    [Theory]
    [InlineData("aspire-empty --language csharp")]
    [InlineData("--template-path template")]
    public async Task New_AcquisitionFailureDoesNotCreateProjectOrFallBack(string templateOptions)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var output = Path.Combine(workspace.Path, "created-project");
        using var provider = CreateCreationServices(workspace).BuildServiceProvider();
        var git = Assert.IsType<TestConfigSourceRepository>(provider.GetRequiredService<IGitClient>());
        git.ReadConfiguration = (_, _) => throw new ConfigSourceException(ConfigSourceStrings.AuthenticationFailed);
        var result = provider.GetRequiredService<RootCommand>().Parse(
            $"new {templateOptions} --output \"{output}\" --use-config-source true --non-interactive");
 
        Assert.Equal(CliExitCodes.InvalidCommand, await result.InvokeAsync().DefaultTimeout());
        Assert.False(Directory.Exists(output));
        Assert.Equal(1, git.Calls);
        Assert.Equal(ConfigSourceStrings.AuthenticationFailed,
            Assert.Single(Assert.IsType<TestInteractionService>(provider.GetRequiredService<Aspire.Cli.Interaction.IInteractionService>()).DisplayedErrors));
    }
 
    private IServiceCollection CreateCreationServices(TemporaryWorkspace workspace)
    {
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: "stable");
            options.InteractionServiceFactory = _ => new TestInteractionService();
        });
        services.AddSingleton<IEnvironment>(new TestEnvironment(new Dictionary<string, string?>
        {
            [CliConfigNames.InitSourceUrl] = s_source.Repository
        }));
        services.AddSingleton<IGitClient>(new TestConfigSourceRepository
        {
            ReadConfiguration = (_, _) => Task.FromResult(("version-1", new JsonObject
            {
                ["docs"] = new JsonObject { ["llmsTxtUrl"] = "https://docs.example/llms.txt" },
                ["sdk"] = new JsonObject { ["version"] = "99.0.0" }
            }))
        });
        return services;
    }
}