File: Projects\ProjectUpdaterNuGetConfigurationTests.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.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Xml;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Shared;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Projects;
 
public class ProjectUpdaterNuGetConfigurationTests(ITestOutputHelper outputHelper)
{
    public static TheoryData<string, string, string, bool> ConfigurationOverrides
    {
        get
        {
            var data = new TheoryData<string, string, string, bool>();
            foreach (var fileName in new[] { "AppHost.csproj", "apphost.cs" })
            {
                foreach (var property in new[]
                {
                    "RestoreConfigFile", "RestoreRootConfigDirectory", "RestoreSources",
                    "_RestoreSourcesOverride", "NuGetRestoreTargets"
                })
                {
                    data.Add(fileName, property, "stable", true);
                    data.Add(fileName, property, "stable", false);
                    data.Add(fileName, property, "default", false);
                }
            }
            return data;
        }
    }
 
    [Theory]
    [MemberData(nameof(ConfigurationOverrides))]
    public async Task UpdateAsync_CustomizedNuGetConfiguration_OnlyBlocksExplicitChannelPolicy(
        string fileName, string overriddenProperty, string channelName, bool hasExplicitChannel)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        var configFile = new FileInfo(Path.Combine(workspace.Path, "NuGet.Config"));
        await File.WriteAllTextAsync(configFile.FullName, """
            <configuration>
              <packageSources>
                <add key="company" value="https://company.example/v3/index.json" />
              </packageSources>
            </configuration>
            """);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        var runner = CreateRunner(projectFile, hasExplicitChannel);
        var inspectionInvoked = false;
        runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (file, _, properties, targets, options, _) =>
        {
            Assert.Equal(projectFile.FullName, file.FullName);
            if (!properties.Contains("RestoreConfigFile", StringComparer.Ordinal))
            {
                return CreatePackageEvaluation();
            }
 
            inspectionInvoked = true;
            Assert.Empty(targets);
            Assert.True(options.NoRestore);
            Assert.True(options.ExcludeRestorePackageImports);
            Assert.True(options.SuppressLogging);
            using var output = TestDotNetCliRunner.CreateRestoreSettingsOutput(file, properties);
            var json = JsonNode.Parse(output.RootElement.GetRawText())!.AsObject();
            json["Properties"]![overriddenProperty] = Path.Combine(workspace.WorkspaceRoot.FullName, "custom-policy");
            return (0, JsonDocument.Parse(json.ToJsonString()));
        };
 
        using var provider = CreateServices(workspace, runner).BuildServiceProvider();
        var context = await CreateContextAsync(provider, projectFile, channelName, hasExplicitChannel);
        var updater = provider.GetRequiredService<IProjectUpdater>();
        if (hasExplicitChannel)
        {
            var error = await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
                updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken));
            Assert.Contains(overriddenProperty, error.Message);
            Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
            var interaction = Assert.IsType<TestInteractionService>(provider.GetRequiredService<IInteractionService>());
            Assert.Empty(interaction.BooleanPromptCalls);
            Assert.Empty(interaction.FilePathPromptCalls);
        }
        else
        {
            var result = await updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken);
            Assert.True(result.UpdatedApplied);
            AssertUpdatedSdk(provider, projectFile);
        }
 
        Assert.Equal(originalConfig, await File.ReadAllBytesAsync(configFile.FullName));
        Assert.Equal(hasExplicitChannel, inspectionInvoked);
        Assert.Empty(Directory.EnumerateDirectories(Path.Combine(workspace.Path, ".aspire"), ".aspire-nuget-config-*"));
    }
 
    [Theory]
    [InlineData("AppHost.csproj", "stable", true)]
    [InlineData("AppHost.csproj", "stable", false)]
    [InlineData("AppHost.csproj", "default", false)]
    [InlineData("apphost.cs", "stable", true)]
    [InlineData("apphost.cs", "stable", false)]
    [InlineData("apphost.cs", "default", false)]
    public async Task UpdateAsync_FailedEvaluation_RollsBackExplicitSdkRepair(
        string fileName, string channelName, bool hasExplicitChannel)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) => (1, null);
        if (hasExplicitChannel)
        {
            runner.AddPackageAsyncCallback = (_, _, _, _, _, _, _) =>
                throw new InvalidOperationException("Unknown restore policy must block package edits.");
            runner.RestoreAsyncCallback = (_, _, _) =>
                throw new InvalidOperationException("Unknown restore policy must block graph restore.");
        }
 
        using var provider = CreateServices(workspace, runner).BuildServiceProvider();
        var context = await CreateContextAsync(provider, projectFile, channelName, hasExplicitChannel);
        var updater = provider.GetRequiredService<IProjectUpdater>();
        if (hasExplicitChannel)
        {
            await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
                updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken));
            Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
        }
        else
        {
            var result = await updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken);
            Assert.True(result.UpdatedApplied);
            AssertUpdatedSdk(provider, projectFile);
        }
 
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
        Assert.Empty(Directory.EnumerateDirectories(Path.Combine(workspace.Path, ".aspire"), ".aspire-nuget-config-*"));
    }
 
    [Theory]
    [InlineData("AppHost.csproj", "stable", false, "success")]
    [InlineData("apphost.cs", "stable", false, "success")]
    [InlineData("AppHost.csproj", "stable", true, "success")]
    [InlineData("apphost.cs", "stable", true, "success")]
    [InlineData("AppHost.csproj", "stable", false, "decline")]
    [InlineData("apphost.cs", "stable", false, "decline")]
    [InlineData("AppHost.csproj", "stable", false, "restore")]
    [InlineData("apphost.cs", "stable", false, "restore")]
    [InlineData("AppHost.csproj", "default", false, "success")]
    [InlineData("apphost.cs", "default", false, "success")]
    public async Task UpdateAsync_AmbientChannelsSynchronizeSdkMetadataAfterSuccessfulRestore(
        string fileName, string channelName, bool sdkAlreadyCurrent, string outcome)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        projectFile.MoveTo(Path.Combine(workspace.CreateDirectory("AppHost").FullName, fileName));
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var configPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        const string originalConfig = """{ "channel": "daily", "sdk": { "version": "9.4.0-preview.1" } }""";
        await File.WriteAllTextAsync(configPath, originalConfig);
        var targetVersion = sdkAlreadyCurrent ? "9.4.1" : "9.4.2";
        var restoreCount = 0;
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => CreatePackageEvaluation();
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, _, _, _, _) =>
        {
            Assert.Equal(projectFile.DirectoryName, directory.FullName);
            return (0, [new NuGetPackageCli { Id = query, Version = targetVersion, Source = PackageSources.NuGetOrg }]);
        };
        runner.AddPackageAsyncCallback = (file, _, version, _, noRestore, _, _) =>
        {
            Assert.True(noRestore);
            File.WriteAllText(file.FullName, File.ReadAllText(file.FullName).Replace("9.4.1", version, StringComparison.Ordinal));
            return 0;
        };
        runner.RestoreAsyncCallback = (_, _, _) =>
        {
            Assert.Equal(originalConfig, File.ReadAllText(configPath));
            restoreCount++;
            return outcome == "restore" ? 1 : 0;
        };
        using var provider = CreateServices(workspace, runner).BuildServiceProvider();
        var interaction = Assert.IsType<TestInteractionService>(provider.GetRequiredService<IInteractionService>());
        interaction.ConfirmCallback = (_, _) => outcome != "decline";
        var context = await CreateContextAsync(provider, projectFile, channelName, hasExplicitChannel: channelName == "stable");
        var updater = provider.GetRequiredService<IProjectUpdater>();
        if (outcome == "restore")
        {
            await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
                updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken));
        }
        else
        {
            var result = await updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken);
            Assert.Equal(outcome == "success", result.UpdatedApplied);
        }
 
        if (outcome == "success")
        {
            var config = AspireConfigFile.Load(workspace.Path);
            Assert.NotNull(config);
            Assert.Equal(targetVersion, config.SdkVersion);
            Assert.Equal(channelName == "stable" ? null : "daily", config.Channel);
            using var parsed = provider.GetRequiredService<FallbackProjectParser>().ParseProject(projectFile);
            Assert.Equal(targetVersion, parsed.RootElement.GetProperty("Properties").GetProperty("AspireHostingSDKVersion").GetString());
        }
        else
        {
            Assert.Equal(originalConfig, await File.ReadAllTextAsync(configPath));
            Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
        }
        Assert.Equal(outcome == "decline" || sdkAlreadyCurrent ? 0 : 1, restoreCount);
        Assert.Equal([UpdateCommandStrings.PerformUpdatesPrompt],
            interaction.BooleanPromptCalls.Select(static call => call.PromptText));
        Assert.Empty(interaction.FilePathPromptCalls);
    }
 
    [Theory]
    [InlineData("AppHost.csproj", "success")]
    [InlineData("apphost.cs", "success")]
    [InlineData("AppHost.csproj", "evaluation")]
    [InlineData("apphost.cs", "evaluation")]
    [InlineData("AppHost.csproj", "RestoreSources")]
    [InlineData("apphost.cs", "RestoreSources")]
    [InlineData("AppHost.csproj", "restore")]
    [InlineData("apphost.cs", "restore")]
    public async Task UpdateAsync_UnresolvableSdk_ValidatesRepairedPolicyBeforePackageEdits(
        string fileName, string outcome)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var configFile = await WriteCurrentPolicyAsync(workspace, "https://old.example/v3/index.json", configureCache: false);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        var aspireConfigPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        const string originalAspireConfig = """{ "channel": "staging", "sdkVersion": "9.4.1" }""";
        await File.WriteAllTextAsync(aspireConfigPath, originalAspireConfig);
        const string newSource = "https://new.example/v3/index.json";
        List<string> calls = [];
        var inspectionCount = 0;
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (file, _, properties, targets, options, _) =>
        {
            Assert.Empty(targets);
            Assert.True(options.NoRestore);
            Assert.True(options.ExcludeRestorePackageImports);
            if (!properties.Contains("RestoreConfigFile", StringComparer.Ordinal))
            {
                return (1, null);
            }
 
            inspectionCount++;
            using var parsed = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance).ParseProject(file);
            if (parsed.RootElement.GetProperty("Properties").GetProperty("AspireHostingSDKVersion").GetString() == "9.4.1")
            {
                return (1, null);
            }
 
            Assert.Equal("9.4.2", parsed.RootElement.GetProperty("Properties").GetProperty("AspireHostingSDKVersion").GetString());
            var package = Assert.Single(parsed.RootElement.GetProperty("Items").GetProperty("PackageReference").EnumerateArray());
            Assert.Equal("9.4.1", package.GetProperty("Version").GetString());
            Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
            Assert.Equal(originalAspireConfig, File.ReadAllText(aspireConfigPath));
            Assert.Equal(["bootstrap"], calls);
            calls.Add("policy");
            if (outcome == "evaluation")
            {
                return (1, null);
            }
 
            using var settings = TestDotNetCliRunner.CreateRestoreSettingsOutput(file, properties);
            var json = JsonNode.Parse(settings.RootElement.GetRawText())!.AsObject();
            if (outcome == "RestoreSources")
            {
                json["Properties"]!["RestoreSources"] = "https://restricted.example/v3/index.json";
            }
            return (0, JsonDocument.Parse(json.ToJsonString()));
        };
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, _, _, _, _) =>
        {
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(directory.FullName, query));
            return (0, [new NuGetPackageCli { Id = query, Version = "9.4.2", Source = newSource }]);
        };
        runner.GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0, [configFile.FullName]);
        runner.AddPackageAsyncCallback = (file, id, version, _, noRestore, _, _) =>
        {
            Assert.Equal(["bootstrap", "policy"], calls);
            Assert.Equal("Aspire.Hosting.Redis", id);
            Assert.True(noRestore);
            File.WriteAllText(file.FullName, File.ReadAllText(file.FullName).Replace("9.4.1", version, StringComparison.Ordinal));
            calls.Add("package");
            return 0;
        };
        runner.RestoreAsyncCallback = (_, options, _) =>
        {
            Assert.Equal(["bootstrap", "policy", "package"], calls);
            Assert.NotNull(options.NuGetRestoreTargetsFile);
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(
                options.NuGetRestoreTargetsFile.DirectoryName!, "Aspire.Hosting.Redis"));
            calls.Add("restore");
            return outcome == "restore" ? 1 : 0;
        };
        var client = new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings,
            RestoreCallback = (packages, _, _, _, _, _, directory, _, _, _) =>
            {
                Assert.Equal([("Aspire.AppHost.Sdk", "9.4.2")], packages);
                Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(directory, "Aspire.AppHost.Sdk"));
                Assert.Equal(originalProject, File.ReadAllBytes(projectFile.FullName));
                Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
                calls.Add("bootstrap");
                return Task.CompletedTask;
            }
        };
        using var provider = CreateServices(workspace, runner, client).BuildServiceProvider();
        var channel = PackageChannel.CreateExplicitChannel(
            "daily", PackageChannelQuality.Both, [new("Aspire*", newSource)],
            provider.GetRequiredService<INuGetPackageCache>(), new TestFeatures(), NullLogger.Instance);
        var context = CreateContext(projectFile, channel, hasExplicitChannel: true);
        var updater = provider.GetRequiredService<IProjectUpdater>();
        if (outcome == "success")
        {
            var result = await updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken);
            Assert.True(result.UpdatedApplied);
            AssertUpdatedSdk(provider, projectFile);
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(projectFile.DirectoryName!, "Aspire.Hosting.Redis"));
            var aspireConfig = AspireConfigFile.Load(workspace.Path);
            Assert.NotNull(aspireConfig);
            Assert.Equal("daily", aspireConfig.Channel);
            Assert.Equal("9.4.2", aspireConfig.SdkVersion);
        }
        else
        {
            var error = await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
                updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken));
            if (outcome == "RestoreSources")
            {
                Assert.Contains("RestoreSources", error.Message);
            }
            Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
            Assert.Equal(originalConfig, await File.ReadAllBytesAsync(configFile.FullName));
            Assert.Equal(originalAspireConfig, await File.ReadAllTextAsync(aspireConfigPath));
        }
 
        Assert.Equal(2, inspectionCount);
        Assert.Equal(outcome is "success" or "restore"
            ? ["bootstrap", "policy", "package", "restore"]
            : ["bootstrap", "policy"], calls);
        Assert.Empty(Directory.EnumerateDirectories(Path.Combine(workspace.Path, ".aspire"), ".aspire-nuget-config-*"));
    }
 
    [Theory]
    [InlineData("AppHost.csproj", "stable", true)]
    [InlineData("AppHost.csproj", "stable", false)]
    [InlineData("AppHost.csproj", "default", true)]
    [InlineData("apphost.cs", "stable", true)]
    [InlineData("apphost.cs", "stable", false)]
    [InlineData("apphost.cs", "default", true)]
    public async Task UpdateAsync_CanceledEvaluation_DoesNotFallBackOrApplyUpdates(
        string fileName, string channelName, bool hasExplicitChannel)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        using var cancellation = new CancellationTokenSource();
        var runner = CreateRunner(projectFile, hasExplicitChannel: true);
        runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargetsAsync = (_, _, _, _, _, token) =>
        {
            cancellation.Cancel();
            return Task.FromCanceled<(int, JsonDocument?)>(token);
        };
 
        using var provider = CreateServices(workspace, runner).BuildServiceProvider();
        var context = await CreateContextAsync(provider, projectFile, channelName, hasExplicitChannel);
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(context, cancellation.Token));
 
        Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
        Assert.False(File.Exists(Path.Combine(workspace.Path, "nuget.config")));
        Assert.Empty(Directory.EnumerateDirectories(Path.Combine(workspace.Path, ".aspire"), ".aspire-nuget-config-*"));
    }
 
    [Fact]
    public async Task UpdateAsync_UnresolvableSdkAndBackslashReference_RepairsSdkForNativeEvaluation()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDirectory = workspace.CreateDirectory("AppHost");
        var webDirectory = workspace.CreateDirectory("Web");
        var oldFeed = workspace.CreateDirectory("old-feed");
        var newFeed = workspace.CreateDirectory("new-feed");
        var packageCache = workspace.CreateDirectory("packages");
        var suffix = Guid.NewGuid().ToString("N");
        var oldVersion = $"99.0.0-missing-{suffix}";
        var newVersion = $"99.0.1-native-{suffix}";
        var packageId = $"Aspire.UpdateRepair.{suffix}";
        NuGetTestHelper.CreatePackage(newFeed, packageId, newVersion,
            new Dictionary<string, string> { ["lib/net10.0/_._"] = "" });
        NuGetTestHelper.CreatePackage(newFeed, "Aspire.AppHost.Sdk", newVersion, new Dictionary<string, string>
        {
            ["Sdk/Sdk.props"] = $$"""
                <Project>
                  <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
                  <PropertyGroup><AspireHostingSDKVersion>{{newVersion}}</AspireHostingSDKVersion></PropertyGroup>
                </Project>
                """,
            ["Sdk/Sdk.targets"] = """<Project><Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" /></Project>"""
        });
        var configFile = new FileInfo(Path.Combine(workspace.Path, "NuGet.Config"));
        await File.WriteAllTextAsync(configFile.FullName, $$"""
            <configuration>
              <packageSources><clear /><add key="old" value="{{oldFeed.FullName}}" /></packageSources>
              <packageSourceMapping><clear /><packageSource key="old"><package pattern="Aspire*" /></packageSource></packageSourceMapping>
              <config><add key="globalPackagesFolder" value="{{packageCache.FullName}}" /></config>
            </configuration>
            """);
        var projectFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, $$"""
            <Project Sdk="Aspire.AppHost.Sdk/{{oldVersion}}">
              <PropertyGroup><TargetFramework>net10.0</TargetFramework><DisableImplicitFrameworkReferences>true</DisableImplicitFrameworkReferences><NuGetAudit>false</NuGetAudit></PropertyGroup>
              <ItemGroup><ProjectReference Include="..\Web\Web.csproj" /></ItemGroup>
            </Project>
            """);
        var webProject = new FileInfo(Path.Combine(webDirectory.FullName, "Web.csproj"));
        await File.WriteAllTextAsync(webProject.FullName, $$"""
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup><TargetFramework>net10.0</TargetFramework><DisableImplicitFrameworkReferences>true</DisableImplicitFrameworkReferences><NuGetAudit>false</NuGetAudit></PropertyGroup>
              <ItemGroup><PackageReference Include="{{packageId}}" Version="{{oldVersion}}" /></ItemGroup>
            </Project>
            """);
        using var nativeProvider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DotNetCliExecutionFactoryFactory = _ =>
                new ProcessExecutionFactory(new TestEnvironment(), NullLogger<ProcessExecutionFactory>.Instance);
        }).BuildServiceProvider();
        var nativeRunner = nativeProvider.GetRequiredService<IDotNetCliRunner>();
        var inspectionCount = 0;
        var runner = new TestDotNetCliRunner
        {
            GetProjectItemsAndPropertiesAsyncCallbackWithTargetsAsync = async (file, items, properties, targets, options, token) =>
            {
                var result = await nativeRunner.GetProjectItemsAndPropertiesAsync(file, items, properties, targets, options, token);
                if (properties.Contains("RestoreConfigFile", StringComparer.Ordinal))
                {
                    inspectionCount++;
                    Assert.Equal(inspectionCount == 1, result.ExitCode != 0);
                }
                return result;
            },
            SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                (0, [new NuGetPackageCli { Id = query, Version = newVersion, Source = newFeed.FullName }]),
            GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0, [configFile.FullName]),
            AddPackageAsyncCallback = (file, id, version, _, noRestore, _, _) =>
            {
                Assert.Equal(webProject.FullName, file.FullName);
                Assert.Equal(packageId, id);
                Assert.True(noRestore);
                Assert.Equal(2, inspectionCount);
                File.WriteAllText(file.FullName, File.ReadAllText(file.FullName).Replace(oldVersion, version, StringComparison.Ordinal));
                return 0;
            },
            RestoreAsyncCallback = (_, options, _) =>
            {
                Assert.Equal(2, inspectionCount);
                Assert.NotNull(options.NuGetRestoreTargetsFile);
                Assert.Equal([newFeed.FullName], NuGetTestHelper.GetEligiblePackageSources(
                    options.NuGetRestoreTargetsFile.DirectoryName!, packageId));
                return 0;
            }
        };
        using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DotNetCliRunnerFactory = _ => runner;
            options.NuGetClientFactory = _ => NuGetTestHelper.CreateClient();
        }).BuildServiceProvider();
        var channel = PackageChannel.CreateExplicitChannel(
            "daily", PackageChannelQuality.Both, [new("Aspire*", newFeed.FullName)],
            provider.GetRequiredService<INuGetPackageCache>(), new TestFeatures(), NullLogger.Instance);
 
        // NUGET_PACKAGES can override the fixture's configured globalPackagesFolder.
        var globalPackagesFolder = provider.GetRequiredService<BundleNuGetService>().GetGlobalPackagesFolder(appHostDirectory);
        var result = await provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(
            CreateContext(projectFile, channel, hasExplicitChannel: true), TestContext.Current.CancellationToken);
 
        Assert.True(result.UpdatedApplied);
        Assert.Equal(2, inspectionCount);
        using var parsed = provider.GetRequiredService<FallbackProjectParser>().ParseProject(projectFile);
        Assert.Equal(newVersion, parsed.RootElement.GetProperty("Properties").GetProperty("AspireHostingSDKVersion").GetString());
        using var parsedWeb = provider.GetRequiredService<FallbackProjectParser>().ParseProject(webProject);
        var package = Assert.Single(parsedWeb.RootElement.GetProperty("Items").GetProperty("PackageReference").EnumerateArray());
        Assert.Equal(newVersion, package.GetProperty("Version").GetString());
        Assert.True(File.Exists(Path.Combine(globalPackagesFolder, "aspire.apphost.sdk", newVersion, "Sdk", "Sdk.props")));
        Assert.Equal([newFeed.FullName], NuGetTestHelper.GetEligiblePackageSources(appHostDirectory.FullName, packageId));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task UpdateAsync_FileBasedNativePackageMetadataEditsSourceAndRollsBackOnFailure(bool succeeds)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var feed = workspace.CreateDirectory("feed");
        var suffix = Guid.NewGuid().ToString("N");
        var oldVersion = $"99.0.0-native-{suffix}";
        var newVersion = $"99.0.1-native-{suffix}";
        const string packageId = "Aspire.Hosting.AppHost";
        foreach (var version in new[] { oldVersion, newVersion })
        {
            NuGetTestHelper.CreatePackage(feed, packageId, version,
                new Dictionary<string, string> { ["lib/net10.0/_._"] = "" });
        }
        var configFile = new FileInfo(Path.Combine(workspace.Path, "NuGet.Config"));
        await File.WriteAllTextAsync(configFile.FullName, $$"""
            <configuration>
              <packageSources><clear /><add key="fixture" value="{{feed.FullName}}" /></packageSources>
              <config><add key="globalPackagesFolder" value="{{workspace.CreateDirectory("packages").FullName}}" /></config>
            </configuration>
            """);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        var projectFile = new FileInfo(Path.Combine(workspace.Path, "apphost.cs"));
        await File.WriteAllTextAsync(projectFile.FullName, $$"""
            #:package {{packageId}}@{{oldVersion}}
            #:property TargetFramework=net10.0
            #:property DisableImplicitFrameworkReferences=true
            #:property PublishAot=false
            #:property UseAppHost=false
            #:property NuGetAudit=false
            """);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var aspireConfigPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        var originalAspireConfig = $$"""{ "sdk": { "version": "{{oldVersion}}" } }""";
        await File.WriteAllTextAsync(aspireConfigPath, originalAspireConfig);
        using var nativeProvider = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DotNetCliExecutionFactoryFactory = _ =>
                new ProcessExecutionFactory(new TestEnvironment(), NullLogger<ProcessExecutionFactory>.Instance);
        }).BuildServiceProvider();
        var nativeRunner = nativeProvider.GetRequiredService<IDotNetCliRunner>();
        var evaluatedMetadata = false;
        var restoreCount = 0;
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargetsAsync = async (file, items, properties, targets, options, token) =>
        {
            var result = await nativeRunner.GetProjectItemsAndPropertiesAsync(file, items, properties, targets, options, token);
            Assert.Equal(0, result.ExitCode);
            Assert.NotNull(result.Output);
            if (items.Contains("PackageReference", StringComparer.Ordinal))
            {
                var package = Assert.Single(result.Output.RootElement.GetProperty("Items").GetProperty("PackageReference").EnumerateArray());
                var evaluatedPath = result.Output.RootElement.GetProperty("Properties").GetProperty("MSBuildProjectFullPath").GetString();
                Assert.Equal(projectFile.FullName + ".csproj", evaluatedPath);
                Assert.Equal(evaluatedPath, package.GetProperty("DefiningProjectFullPath").GetString());
                evaluatedMetadata = true;
            }
            return result;
        };
        runner.SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
            (0, [new NuGetPackageCli { Id = query, Version = newVersion, Source = feed.FullName }]);
        runner.AddPackageAsyncCallbackAsync = (file, id, version, source, noRestore, options, token) =>
        {
            Assert.Equal(projectFile.FullName, file.FullName);
            Assert.True(noRestore);
            return nativeRunner.AddPackageAsync(file, id, version, source, noRestore, options, token);
        };
        runner.RestoreAsyncCallbackAsync = async (file, options, token) =>
        {
            restoreCount++;
            var output = new OutputCollector();
            options.StandardOutputCallback = output.AppendOutput;
            options.StandardErrorCallback = output.AppendError;
            var exitCode = await nativeRunner.RestoreAsync(file, options, token);
            Assert.True(exitCode == 0, string.Join(Environment.NewLine, output.GetLines().Select(static line => line.Line)));
            return succeeds ? 0 : 1;
        };
        using var provider = CreateServices(workspace, runner).BuildServiceProvider();
        var context = await CreateContextAsync(provider, projectFile, "default", hasExplicitChannel: false);
        if (succeeds)
        {
            var result = await provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(context, TestContext.Current.CancellationToken);
            Assert.True(result.UpdatedApplied);
            Assert.Equal(newVersion, AspireConfigFile.Load(workspace.Path)!.SdkVersion);
            Assert.Equal(System.Text.Encoding.UTF8.GetString(originalProject).Replace(oldVersion, newVersion, StringComparison.Ordinal),
                await File.ReadAllTextAsync(projectFile.FullName));
        }
        else
        {
            await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
                provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(context, TestContext.Current.CancellationToken));
            Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
            Assert.Equal(originalAspireConfig, await File.ReadAllTextAsync(aspireConfigPath));
        }
        Assert.True(evaluatedMetadata);
        Assert.Equal(1, restoreCount);
        Assert.Equal(originalConfig, await File.ReadAllBytesAsync(configFile.FullName));
    }
 
    private static async Task<FileInfo> CreateAppHostAsync(TemporaryWorkspace workspace, string fileName)
    {
        var content = fileName == "apphost.cs"
            ? """
              #:sdk Aspire.AppHost.Sdk@9.4.1
              #:package Aspire.Hosting.Redis@9.4.1
              """
            : """
              <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
                <ItemGroup>
                  <PackageReference Include="Aspire.Hosting.Redis" Version="9.4.1" />
                </ItemGroup>
              </Project>
              """;
        var file = new FileInfo(Path.Combine(workspace.Path, fileName));
        await File.WriteAllTextAsync(file.FullName, content);
        return file;
    }
 
    [Theory]
    [InlineData("AppHost.csproj", false, "daily", false, true, false)]
    [InlineData("apphost.cs", false, "daily", false, true, false)]
    [InlineData("AppHost.csproj", true, "daily", false, true, false)]
    [InlineData("apphost.cs", true, "daily", false, true, false)]
    [InlineData("AppHost.csproj", true, "staging", true, true, false)]
    [InlineData("apphost.cs", true, "staging", true, true, false)]
    [InlineData("AppHost.csproj", true, "different-channel", false, true, false)]
    [InlineData("AppHost.csproj", true, "daily", false, false, false)]
    [InlineData("apphost.cs", true, "staging", true, false, false)]
    [InlineData("AppHost.csproj", true, "stable", false, true, false)]
    [InlineData("apphost.cs", true, "stable", false, true, false)]
    [InlineData("AppHost.csproj", true, "daily", false, true, true)]
    [InlineData("apphost.cs", true, "daily", false, true, true)]
    public async Task UpdateAsync_UnchangedPolicyUsesNativeDiscoveryWithoutWritingConfiguration(
        string fileName, bool hasExplicitChannel, string channelName, bool configureCache, bool updateVersions,
        bool hasAdditionalSources)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        const string currentSource = "https://company.example/v3/index.json";
        var configFile = await WriteCurrentPolicyAsync(workspace, currentSource, configureCache);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        if (!updateVersions)
        {
            await File.WriteAllTextAsync(Path.Combine(workspace.Path, AspireConfigFile.FileName),
                JsonSerializer.Serialize(new { channel = channelName, sdkVersion = "9.4.1" }));
        }
 
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => CreatePackageEvaluation();
        if (hasAdditionalSources)
        {
            runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (file, _, properties, _, _, _) =>
            {
                if (!properties.Contains("RestoreConfigFile", StringComparer.Ordinal))
                {
                    return CreatePackageEvaluation();
                }
 
                using var output = TestDotNetCliRunner.CreateRestoreSettingsOutput(file, properties);
                var json = JsonNode.Parse(output.RootElement.GetRawText())!.AsObject();
                json["Properties"]!["RestoreAdditionalProjectSources"] = "https://additional.example/v3/index.json";
                return (0, JsonDocument.Parse(json.ToJsonString()));
            };
        }
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, explicitConfig, _, _, _) =>
        {
            Assert.Equal(projectFile.DirectoryName, directory.FullName);
            Assert.Null(explicitConfig);
            Assert.Equal([currentSource], NuGetTestHelper.GetEligiblePackageSources(directory.FullName, query));
            return (0, [new NuGetPackageCli { Id = query, Version = updateVersions ? "9.4.2" : "9.4.1", Source = currentSource }]);
        };
        using var provider = CreateServices(workspace, runner).BuildServiceProvider();
        var channel = PackageChannel.CreateExplicitChannel(
            channelName, PackageChannelQuality.Both,
            [new("Aspire*", hasExplicitChannel ? currentSource : "https://replacement.example/v3/index.json")],
            provider.GetRequiredService<INuGetPackageCache>(), new TestFeatures(), NullLogger.Instance,
            configureGlobalPackagesFolder: configureCache);
 
        var result = await provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(
            CreateContext(projectFile, channel, hasExplicitChannel), TestContext.Current.CancellationToken);
 
        Assert.Equal(updateVersions, result.UpdatedApplied);
        if (updateVersions)
        {
            AssertUpdatedSdk(provider, projectFile);
        }
        Assert.Equal(originalConfig, await File.ReadAllBytesAsync(configFile.FullName));
        var interaction = Assert.IsType<TestInteractionService>(provider.GetRequiredService<IInteractionService>());
        Assert.Empty(interaction.FilePathPromptCalls);
        Assert.Equal(updateVersions ? 1 : 0, interaction.BooleanPromptCalls.Count);
        Assert.Empty(Directory.EnumerateDirectories(Path.Combine(workspace.Path, ".aspire"), ".aspire-nuget-config-*"));
    }
 
    [Theory]
    [InlineData("AppHost.csproj", false, true)]
    [InlineData("apphost.cs", false, true)]
    [InlineData("AppHost.csproj", true, true)]
    [InlineData("apphost.cs", true, true)]
    [InlineData("AppHost.csproj", false, false)]
    [InlineData("apphost.cs", false, false)]
    public async Task UpdateAsync_ExplicitStablePreservesAmbientSourcesWithoutAddingNuGetOrg(
        string fileName, bool hasNuGetOrg, bool hasMappings)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        const string mirror = "https://company.example/v3/index.json";
        const string daily = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json";
        var channelSourceKey = $"aspire-{AppHostWorkloadId.Create(workspace.Path)}";
        var publicSource = hasNuGetOrg
            ? $$"""<add key="public" value="{{PackageSources.NuGetOrg}}" />"""
            : string.Empty;
        var publicMapping = hasNuGetOrg
            ? """<packageSource key="public"><package pattern="*" /></packageSource>"""
            : string.Empty;
        var mappings = hasMappings
            ? $$"""
              <packageSourceMapping>
                <clear />
                <packageSource key="company"><package pattern="*" /></packageSource>
                <packageSource key="{{channelSourceKey}}"><package pattern="Aspire*" /></packageSource>
                <packageSource key="private"><package pattern="Private.*" /></packageSource>
                {{publicMapping}}
              </packageSourceMapping>
              """
            : string.Empty;
        var configFile = new FileInfo(Path.Combine(workspace.Path, "NuGet.Config"));
        await File.WriteAllTextAsync(configFile.FullName, $$"""
            <configuration>
              <packageSources>
                <clear />
                <add key="company" value="{{mirror}}" />
                <add key="{{channelSourceKey}}" value="{{daily}}" />
                <add key="private" value="https://private.example/v3/index.json" />
                {{publicSource}}
              </packageSources>
              {{mappings}}
            </configuration>
            """);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        var expectedSources = hasMappings
            ? hasNuGetOrg ? new[] { mirror, PackageSources.NuGetOrg } : [mirror]
            : [mirror, "https://private.example/v3/index.json"];
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => CreatePackageEvaluation();
        runner.GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0, [configFile.FullName]);
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, explicitConfig, _, _, _) =>
        {
            Assert.Null(explicitConfig);
            Assert.Equal(expectedSources.Order(), NuGetTestHelper.GetEligiblePackageSources(directory.FullName, query).Order());
            return (0, [new NuGetPackageCli { Id = query, Version = "9.4.2", Source = mirror }]);
        };
        var restoreCount = 0;
        runner.RestoreAsyncCallback = (_, options, _) =>
        {
            Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
            Assert.NotNull(options.NuGetRestoreTargetsFile);
            Assert.Equal(expectedSources.Order(),
                NuGetTestHelper.GetEligiblePackageSources(options.NuGetRestoreTargetsFile.DirectoryName!, "Aspire.Hosting.Redis").Order());
            restoreCount++;
            return 0;
        };
        using var provider = CreateServices(workspace, runner, new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings
        }).BuildServiceProvider();
        var context = await CreateContextAsync(provider, projectFile, "stable", hasExplicitChannel: true);
 
        var result = await provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(
            context, TestContext.Current.CancellationToken);
 
        Assert.True(result.UpdatedApplied);
        Assert.Equal(1, restoreCount);
        AssertUpdatedSdk(provider, projectFile);
        Assert.Equal(expectedSources.Order(),
            NuGetTestHelper.GetEligiblePackageSources(workspace.Path, "Aspire.Hosting.Redis").Order());
        var settings = provider.GetRequiredService<BundleNuGetService>()
            .GetNuGetSettings(workspace.Path, TestContext.Current.CancellationToken);
        Assert.Equal(hasNuGetOrg, settings.Sources.Any(static source => source.Name == "public"));
        string[] expectedSourceNames = hasNuGetOrg ? ["company", "private", "public"] : ["company", "private"];
        Assert.Equal(expectedSourceNames,
            settings.Sources.Where(static source => source.IsEnabled).Select(static source => source.Name).Order());
    }
 
    [Theory]
    [InlineData("AppHost.csproj", true, false)]
    [InlineData("apphost.cs", true, false)]
    [InlineData("AppHost.csproj", true, true)]
    [InlineData("apphost.cs", true, true)]
    [InlineData("AppHost.csproj", false, false)]
    [InlineData("apphost.cs", false, false)]
    [InlineData("AppHost.csproj", false, true)]
    [InlineData("apphost.cs", false, true)]
    public async Task UpdateAsync_ServiceIndexOverrideOnlyChangesExplicitStablePolicy(
        string fileName, bool hasExplicitChannel, bool hasOverrideSource)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        const string currentSource = "https://company.example/v3/index.json";
        const string overrideSource = "https://mirror.example/v3/index.json";
        var overrideSourceEntry = hasOverrideSource
            ? $$"""<add key="mirror-alias" value="{{overrideSource}}" />"""
            : string.Empty;
        var overrideMapping = hasOverrideSource
            ? """<packageSource key="mirror-alias"><package pattern="*" /></packageSource>"""
            : string.Empty;
        var configFile = new FileInfo(Path.Combine(workspace.Path, "NuGet.Config"));
        await File.WriteAllTextAsync(configFile.FullName, $$"""
            <configuration>
              <packageSources>
                <clear />
                <add key="company" value="{{currentSource}}" />
                {{overrideSourceEntry}}
              </packageSources>
              <packageSourceMapping>
                <clear />
                <packageSource key="company"><package pattern="*" /></packageSource>
                {{overrideMapping}}
              </packageSourceMapping>
            </configuration>
            """);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        string[] expectedSources = hasExplicitChannel
            ? [overrideSource]
            : hasOverrideSource ? [currentSource, overrideSource] : [currentSource];
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => CreatePackageEvaluation();
        runner.GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0, [configFile.FullName]);
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, explicitConfig, _, _, _) =>
        {
            Assert.Null(explicitConfig);
            Assert.Equal(expectedSources.Order(),
                NuGetTestHelper.GetEligiblePackageSources(directory.FullName, query).Order());
            return (0, [new NuGetPackageCli { Id = query, Version = "9.4.2", Source = expectedSources[0] }]);
        };
        var restoreCount = 0;
        runner.RestoreAsyncCallback = (_, options, _) =>
        {
            Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
            Assert.Equal(hasExplicitChannel, options.NuGetRestoreTargetsFile is not null);
            Assert.Equal(expectedSources.Order(),
                NuGetTestHelper.GetEligiblePackageSources(
                    options.NuGetRestoreTargetsFile?.DirectoryName ?? workspace.Path, "Aspire.Hosting.Redis").Order());
            restoreCount++;
            return 0;
        };
        using var provider = CreateServices(workspace, runner, new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings
        }).AddSingleton(workspace.CreateExecutionContext(nugetServiceIndexOverride: overrideSource))
            .BuildServiceProvider();
        var context = await CreateContextAsync(provider, projectFile, "stable", hasExplicitChannel);
 
        var result = await provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(
            context, TestContext.Current.CancellationToken);
 
        Assert.True(result.UpdatedApplied);
        Assert.Equal(1, restoreCount);
        AssertUpdatedSdk(provider, projectFile);
        Assert.Equal(expectedSources.Order(),
            NuGetTestHelper.GetEligiblePackageSources(workspace.Path, "Aspire.Hosting.Redis").Order());
        var settings = provider.GetRequiredService<BundleNuGetService>()
            .GetNuGetSettings(workspace.Path, TestContext.Current.CancellationToken);
        string[] expectedConfiguredSources = hasExplicitChannel || hasOverrideSource
            ? [currentSource, overrideSource]
            : [currentSource];
        Assert.Equal(
            expectedConfiguredSources.Select(source => NuGetSourceIdentity.Compute(source, settings.SourceIdentityKey)).Order(),
            settings.Sources.Where(static source => source.IsEnabled).Select(static source => source.Identity).Order());
        if (hasOverrideSource)
        {
            var overrideIdentity = NuGetSourceIdentity.Compute(overrideSource, settings.SourceIdentityKey);
            Assert.Equal("mirror-alias", Assert.Single(settings.Sources, source => source.Identity == overrideIdentity).Name);
        }
        if (!hasExplicitChannel)
        {
            Assert.Equal(originalConfig, await File.ReadAllBytesAsync(configFile.FullName));
        }
    }
 
    [Theory]
    [InlineData("AppHost.csproj")]
    [InlineData("apphost.cs")]
    public async Task UpdateAsync_ExplicitSameChannelWithDifferentFeedRestoresWithoutVersionChanges(string fileName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var configFile = await WriteCurrentPolicyAsync(workspace, "https://old.example/v3/index.json", configureCache: false);
        const string newSource = "https://new.example/v3/index.json";
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => CreatePackageEvaluation();
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, explicitConfig, _, _, _) =>
        {
            Assert.Null(explicitConfig);
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(directory.FullName, query));
            return (0, [new NuGetPackageCli { Id = query, Version = "9.4.1", Source = newSource }]);
        };
        runner.GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0,
            [configFile.FullName, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "NuGet.Config")]);
        var restoreCount = 0;
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        runner.RestoreAsyncCallback = (file, options, _) =>
        {
            Assert.Equal(projectFile.FullName, file.FullName);
            Assert.Null(options.EnvironmentVariables);
            Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
            Assert.NotNull(options.NuGetRestoreTargetsFile);
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(options.NuGetRestoreTargetsFile.DirectoryName!, "Aspire.AppHost.Sdk"));
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(options.NuGetRestoreTargetsFile.DirectoryName!, "Aspire.Hosting.Redis"));
            restoreCount++;
            return 0;
        };
        using var provider = CreateServices(workspace, runner, new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings
        }).BuildServiceProvider();
        var channel = PackageChannel.CreateExplicitChannel(
            "staging", PackageChannelQuality.Both, [new("Aspire*", newSource)],
            provider.GetRequiredService<INuGetPackageCache>(), new TestFeatures(), NullLogger.Instance);
        await File.WriteAllTextAsync(Path.Combine(workspace.Path, AspireConfigFile.FileName),
            """{ "channel": "staging", "sdkVersion": "9.4.1" }""");
 
        var result = await provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(
            CreateContext(projectFile, channel, hasExplicitChannel: true), TestContext.Current.CancellationToken);
 
        Assert.True(result.UpdatedApplied);
        Assert.Equal(1, restoreCount);
        Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
        Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(projectFile.DirectoryName!, "Aspire.Hosting.Redis"));
    }
 
    [Theory]
    [InlineData("AppHost.csproj")]
    [InlineData("apphost.cs")]
    public async Task UpdateAsync_DecliningConfigurationStopsAllFileUpdates(string fileName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var configFile = await WriteCurrentPolicyAsync(workspace, "https://old.example/v3/index.json", configureCache: false);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        var aspireConfigPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        const string originalAspireConfig = """{ "channel": "staging", "sdkVersion": "9.4.1" }""";
        await File.WriteAllTextAsync(aspireConfigPath, originalAspireConfig);
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => CreatePackageEvaluation();
        runner.SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) => (0,
            [new NuGetPackageCli
            {
                Id = query,
                Version = "9.4.2",
                Source = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"
            }]);
        runner.GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0,
            [configFile.FullName, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "NuGet.Config")]);
        runner.AddPackageAsyncCallback = (_, _, _, _, _, _, _) =>
            throw new InvalidOperationException("Declining the configuration must not apply package edits.");
        runner.RestoreAsyncCallback = (_, _, _) =>
            throw new InvalidOperationException("Declining the configuration must not restore.");
        using var provider = CreateServices(workspace, runner, new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings
        }).BuildServiceProvider();
        var interaction = Assert.IsType<TestInteractionService>(provider.GetRequiredService<IInteractionService>());
        interaction.ConfirmCallback = (prompt, _) => prompt == UpdateCommandStrings.PerformUpdatesPrompt;
        var channel = PackageChannel.CreateExplicitChannel(
            "daily", PackageChannelQuality.Both,
            [new("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json")],
            provider.GetRequiredService<INuGetPackageCache>(), new TestFeatures(), NullLogger.Instance);
        var additionalEditApplied = false;
        var context = new UpdatePackagesContext
        {
            AppHostFile = projectFile,
            Channel = channel,
            HasExplicitChannel = true,
            ConfirmBinding = PromptBinding.CreateDefault(true),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
            AdditionalUpdateSteps = [new PackageUpdateStep("Update repository tool", () =>
            {
                additionalEditApplied = true;
                return Task.CompletedTask;
            }, "Aspire.Cli", "9.4.1", "9.4.2", projectFile)]
        };
 
        var result = await provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(context, TestContext.Current.CancellationToken);
 
        Assert.False(result.UpdatedApplied);
        Assert.False(additionalEditApplied);
        Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
        Assert.Equal(originalConfig, await File.ReadAllBytesAsync(configFile.FullName));
        Assert.Equal(originalAspireConfig, await File.ReadAllTextAsync(aspireConfigPath));
        Assert.Equal(2, interaction.BooleanPromptCalls.Count);
    }
 
    [Theory]
    [InlineData("success", false, false)]
    [InlineData("decline", false, false)]
    [InlineData("restore", false, false)]
    [InlineData("success", true, false)]
    [InlineData("success", false, true)]
    [InlineData("mapping-only", false, false)]
    public async Task UpdateAsync_AspireOnlyMappingsRepairUsesExistingConfigurationApproval(
        string outcome, bool hasUnrelatedMappings, bool stable)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, "AppHost.csproj");
        projectFile.MoveTo(Path.Combine(workspace.CreateDirectory("AppHost").FullName, projectFile.Name));
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        const string companySource = "https://company.example/v3/index.json";
        const string newSource = "https://new.example/v3/index.json";
        var oldHive = workspace.CreateDirectory(".aspire/hives/pr-old/packages").FullName;
        var parentConfig = new FileInfo(Path.Combine(workspace.Path, "NuGet.Config"));
        await File.WriteAllTextAsync(parentConfig.FullName, $$"""
            <configuration>
              <packageSources>
                <clear />
                <add key="company" value="{{companySource}}" />
                <add key="disabled" value="https://disabled.example/v3/index.json" />
              </packageSources>
              <disabledPackageSources><add key="disabled" value="true" /></disabledPackageSources>
            </configuration>
            """);
        var originalParent = await File.ReadAllBytesAsync(parentConfig.FullName);
        var configFile = new FileInfo(Path.Combine(projectFile.DirectoryName!, "NuGet.Config"));
        var unrelatedMapping = hasUnrelatedMappings
            ? """<packageSource key="company"><package pattern="Contoso.*" /></packageSource>"""
            : string.Empty;
        await File.WriteAllTextAsync(configFile.FullName, $$"""
            <configuration>
              <packageSources><add key="old-hive" value="{{oldHive}}" /></packageSources>
              <packageSourceMapping>
                <clear />
                <packageSource key="old-hive"><package pattern="Aspire*" /></packageSource>
                {{unrelatedMapping}}
              </packageSourceMapping>
            </configuration>
            """);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        var mappingOnly = outcome == "mapping-only";
        if (mappingOnly)
        {
            await File.WriteAllTextAsync(Path.Combine(projectFile.DirectoryName!, AspireConfigFile.FileName),
                """{ "channel": "daily", "sdkVersion": "9.4.1" }""");
        }
        string[] expectedDependencies = hasUnrelatedMappings ? [] : [companySource];
        string[] expectedAspireSources = [stable ? companySource : mappingOnly ? oldHive : newSource];
        var restoreCount = 0;
        var packageEditCount = 0;
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => CreatePackageEvaluation();
        runner.GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0, [configFile.FullName, parentConfig.FullName]);
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, _, _, _, _) =>
        {
            Assert.Equal(expectedAspireSources, NuGetTestHelper.GetEligiblePackageSources(directory.FullName, query));
            Assert.Equal(expectedDependencies, NuGetTestHelper.GetEligiblePackageSources(directory.FullName, "Example.Dependency"));
            return (0, [new NuGetPackageCli { Id = query, Version = mappingOnly ? "9.4.1" : "9.4.2", Source = expectedAspireSources[0] }]);
        };
        runner.AddPackageAsyncCallback = (file, _, version, _, noRestore, _, _) =>
        {
            Assert.True(noRestore);
            File.WriteAllText(file.FullName, File.ReadAllText(file.FullName).Replace("9.4.1", version, StringComparison.Ordinal));
            packageEditCount++;
            return 0;
        };
        runner.RestoreAsyncCallback = (_, options, _) =>
        {
            Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
            Assert.NotNull(options.NuGetRestoreTargetsFile);
            Assert.Equal(expectedDependencies, NuGetTestHelper.GetEligiblePackageSources(
                options.NuGetRestoreTargetsFile.DirectoryName!, "Example.Dependency"));
            restoreCount++;
            return outcome == "restore" ? 1 : 0;
        };
        using var provider = CreateServices(workspace, runner, new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings
        }).BuildServiceProvider();
        var interaction = Assert.IsType<TestInteractionService>(provider.GetRequiredService<IInteractionService>());
        interaction.ConfirmCallback = (prompt, _) => prompt == UpdateCommandStrings.PerformUpdatesPrompt || outcome != "decline";
        var channel = PackageChannel.CreateExplicitChannel(
            stable ? "stable" : "daily", PackageChannelQuality.Both, [new("Aspire*", mappingOnly ? oldHive : newSource)],
            provider.GetRequiredService<INuGetPackageCache>(), new TestFeatures(), NullLogger.Instance);
        var context = new UpdatePackagesContext
        {
            AppHostFile = projectFile,
            Channel = channel,
            HasExplicitChannel = true,
            ConfirmBinding = PromptBinding.CreateDefault(true),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(projectFile.DirectoryName)
        };
        var updater = provider.GetRequiredService<IProjectUpdater>();
        if (outcome == "restore")
        {
            await Assert.ThrowsAsync<ProjectUpdaterException>(() => updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken));
        }
        else
        {
            var result = await updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken);
            Assert.Equal(outcome is "success" or "mapping-only", result.UpdatedApplied);
        }
 
        Assert.Equal([UpdateCommandStrings.PerformUpdatesPrompt, UpdateCommandStrings.ApplyChangesToNuGetConfig],
            interaction.BooleanPromptCalls.Select(static call => call.PromptText));
        Assert.Equal(!hasUnrelatedMappings && !stable ? 1 : 0,
            interaction.DisplayedPlainText.Count(text => text == string.Format(
                CultureInfo.InvariantCulture, UpdateCommandStrings.MappingAddedFormat, "*")));
        if (!hasUnrelatedMappings && !stable)
        {
            Assert.Contains(string.Format(CultureInfo.InvariantCulture,
                UpdateCommandStrings.RetainedFeedFormat, "company"), interaction.DisplayedPlainText);
        }
        Assert.Equal(originalParent, await File.ReadAllBytesAsync(parentConfig.FullName));
        Assert.Equal(outcome == "decline" ? 0 : 1, restoreCount);
        Assert.Equal(outcome == "decline" || mappingOnly ? 0 : 1, packageEditCount);
        if (outcome is "success" or "mapping-only")
        {
            if (mappingOnly)
            {
                Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
            }
            else
            {
                AssertUpdatedSdk(provider, projectFile);
            }
            Assert.Equal(expectedDependencies, NuGetTestHelper.GetEligiblePackageSources(projectFile.DirectoryName!, "Example.Dependency"));
            Assert.Equal([companySource], NuGetTestHelper.GetEligiblePackageSources(projectFile.DirectoryName!, "Contoso.Package"));
            Assert.Equal(expectedAspireSources, NuGetTestHelper.GetEligiblePackageSources(projectFile.DirectoryName!, "Aspire.Hosting.Redis"));
            var document = new XmlDocument();
            document.Load(configFile.FullName);
            Assert.Equal(stable ? [] : expectedAspireSources,
                document.SelectNodes("/configuration/packageSources/add")!.OfType<XmlElement>()
                    .Select(static source => source.GetAttribute("value")));
        }
        else
        {
            Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
            Assert.Equal(originalConfig, await File.ReadAllBytesAsync(configFile.FullName));
        }
    }
 
    [Theory]
    [InlineData("AppHost.csproj", "success")]
    [InlineData("apphost.cs", "success")]
    [InlineData("AppHost.csproj", "restore")]
    [InlineData("apphost.cs", "restore")]
    [InlineData("AppHost.csproj", "cancel")]
    [InlineData("apphost.cs", "cancel")]
    [InlineData("AppHost.csproj", "edit")]
    [InlineData("apphost.cs", "edit")]
    [InlineData("AppHost.csproj", "bootstrap")]
    [InlineData("apphost.cs", "bootstrap")]
    [InlineData("AppHost.csproj", "commit")]
    [InlineData("apphost.cs", "commit")]
    [InlineData("AppHost.csproj", "stale-config")]
    [InlineData("apphost.cs", "stale-config")]
    [InlineData("AppHost.csproj", "stale-project")]
    [InlineData("apphost.cs", "stale-project")]
    public async Task UpdateAsync_ValidatesCompleteCandidateBeforePersisting(string fileName, string outcome)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, fileName);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var configFile = await WriteCurrentPolicyAsync(workspace, "https://old.example/v3/index.json", configureCache: false);
        var originalConfig = await File.ReadAllBytesAsync(configFile.FullName);
        var aspireConfigPath = Path.Combine(workspace.Path, AspireConfigFile.FileName);
        const string originalAspireConfig = """{ "channel": "staging", "sdkVersion": "9.4.1" }""";
        await File.WriteAllTextAsync(aspireConfigPath, originalAspireConfig);
        const string newSource = "https://new.example/v3/index.json";
        using var cancellation = new CancellationTokenSource();
        List<string> calls = [];
        var client = new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings,
            RestoreCallback = (packages, _, _, _, _, _, directory, _, _, _) =>
            {
                Assert.Equal([("Aspire.AppHost.Sdk", "9.4.2")], packages);
                Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(directory, "Aspire.AppHost.Sdk"));
                Assert.Equal(originalProject, File.ReadAllBytes(projectFile.FullName));
                Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
                calls.Add("bootstrap");
                return outcome == "bootstrap"
                    ? Task.FromException(new InvalidOperationException("SDK acquisition failed."))
                    : Task.CompletedTask;
            }
        };
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, options, _) =>
        {
            Assert.True(options.NoRestore);
            Assert.True(options.ExcludeRestorePackageImports);
            return CreatePackageEvaluation();
        };
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, _, _, _, _) =>
        {
            Assert.Equal(originalProject, File.ReadAllBytes(projectFile.FullName));
            Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(directory.FullName, query));
            var call = $"discover:{query}";
            if (calls.Count == 0 || calls[^1] != call)
            {
                calls.Add(call);
            }
            return (0, [new NuGetPackageCli { Id = query, Version = "9.4.2", Source = newSource }]);
        };
        runner.GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0,
            [configFile.FullName, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "NuGet.Config")]);
        runner.AddPackageAsyncCallback = (file, id, version, _, noRestore, _, _) =>
        {
            Assert.True(noRestore);
            Assert.Equal("Aspire.Hosting.Redis", id);
            Assert.Equal("9.4.2", version);
            Assert.Contains("Aspire.AppHost.Sdk", File.ReadAllText(file.FullName));
            var content = File.ReadAllText(file.FullName).Replace("9.4.1", version, StringComparison.Ordinal);
            File.WriteAllText(file.FullName, content);
            calls.Add("package");
            return outcome == "edit" ? 1 : 0;
        };
        runner.RestoreAsyncCallback = (file, options, _) =>
        {
            Assert.Equal(projectFile.FullName, file.FullName);
            using var parsed = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance).ParseProject(file);
            Assert.Equal("9.4.2", parsed.RootElement.GetProperty("Properties").GetProperty("AspireHostingSDKVersion").GetString());
            var package = Assert.Single(parsed.RootElement.GetProperty("Items").GetProperty("PackageReference").EnumerateArray());
            Assert.Equal("9.4.2", package.GetProperty("Version").GetString());
            Assert.NotNull(options.NuGetRestoreTargetsFile);
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(options.NuGetRestoreTargetsFile.DirectoryName!, "Aspire.Hosting.Redis"));
            Assert.Equal(originalConfig, File.ReadAllBytes(configFile.FullName));
            Assert.Equal(originalAspireConfig, File.ReadAllText(aspireConfigPath));
            calls.Add("restore");
            if (outcome == "cancel")
            {
                cancellation.Cancel();
                cancellation.Token.ThrowIfCancellationRequested();
            }
            if (outcome == "stale-config")
            {
                File.WriteAllText(configFile.FullName, "<configuration><external /></configuration>");
            }
            if (outcome == "stale-project")
            {
                File.WriteAllText(projectFile.FullName, "// External edit.");
            }
            return outcome == "restore" ? 1 : 0;
        };
        using var provider = CreateServices(workspace, runner, client).BuildServiceProvider();
        var channel = PackageChannel.CreateExplicitChannel(
            "staging", PackageChannelQuality.Both, [new("Aspire*", newSource)],
            provider.GetRequiredService<INuGetPackageCache>(), new TestFeatures(), NullLogger.Instance);
        var context = new UpdatePackagesContext
        {
            AppHostFile = projectFile,
            Channel = channel,
            HasExplicitChannel = true,
            ConfirmBinding = PromptBinding.CreateDefault(true),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
            AdditionalUpdateSteps = [new PackageUpdateStep("Tool update", () =>
            {
                Assert.Equal("restore", calls[^1]);
                Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(projectFile.DirectoryName!, "Aspire.Hosting.Redis"));
                calls.Add("tool");
                return outcome == "commit"
                    ? Task.FromException(new InvalidOperationException("Tool edit failed."))
                    : Task.CompletedTask;
            }, "Aspire.Cli", "9.4.1", "9.4.2", projectFile)]
        };
        var updater = provider.GetRequiredService<IProjectUpdater>();
        if (outcome == "success")
        {
            var result = await updater.UpdateProjectAsync(context, cancellation.Token);
            Assert.True(result.UpdatedApplied);
            AssertUpdatedSdk(provider, projectFile);
            Assert.Equal([newSource], NuGetTestHelper.GetEligiblePackageSources(projectFile.DirectoryName!, "Aspire.Hosting.Redis"));
            Assert.Equal(["discover:Aspire.AppHost.Sdk", "discover:Aspire.Hosting.Redis", "bootstrap", "package", "restore", "tool"], calls);
        }
        else
        {
            if (outcome == "cancel")
            {
                await Assert.ThrowsAnyAsync<OperationCanceledException>(() => updater.UpdateProjectAsync(context, cancellation.Token));
            }
            else if (outcome is "restore" or "edit")
            {
                await Assert.ThrowsAsync<ProjectUpdaterException>(() => updater.UpdateProjectAsync(context, cancellation.Token));
            }
            else if (outcome == "stale-project")
            {
                var failure = await Assert.ThrowsAsync<AggregateException>(() => updater.UpdateProjectAsync(context, cancellation.Token));
                Assert.Equal(2, failure.InnerExceptions.Count);
            }
            else
            {
                await Assert.ThrowsAsync<InvalidOperationException>(() => updater.UpdateProjectAsync(context, cancellation.Token));
            }
            Assert.Equal(outcome == "stale-project" ? System.Text.Encoding.UTF8.GetBytes("// External edit.") : originalProject,
                await File.ReadAllBytesAsync(projectFile.FullName));
            Assert.Equal(outcome == "stale-config" ? System.Text.Encoding.UTF8.GetBytes("<configuration><external /></configuration>") : originalConfig,
                await File.ReadAllBytesAsync(configFile.FullName));
            Assert.Equal(originalAspireConfig, await File.ReadAllTextAsync(aspireConfigPath));
        }
        Assert.Empty(Directory.EnumerateFiles(workspace.Path, ".aspire-nuget-candidate-*"));
        Assert.Empty(Directory.EnumerateDirectories(Path.Combine(workspace.Path, ".aspire"), ".aspire-nuget-config-*"));
    }
 
    [Theory]
    [InlineData("AppHost.csproj", true, false)]
    [InlineData("AppHost.csproj", false, false)]
    [InlineData("AppHost.csproj", true, true)]
    [InlineData("AppHost.csproj", false, true)]
    [InlineData("apphost.cs", true, false)]
    [InlineData("apphost.cs", false, false)]
    [InlineData("apphost.cs", true, true)]
    [InlineData("apphost.cs", false, true)]
    public async Task UpdateAsync_ConfigurationLocationFollowsAppHostHierarchyFromUnrelatedDirectory(
        string fileName, bool succeeds, bool useAncestorConfig)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var invocationDirectory = workspace.CreateDirectory("invocation");
        var repositoryDirectory = workspace.CreateDirectory("repository");
        var appHostDirectory = repositoryDirectory.CreateSubdirectory("AppHost");
        var project = await CreateAppHostAsync(workspace, fileName);
        project.MoveTo(Path.Combine(appHostDirectory.FullName, fileName));
        var original = await File.ReadAllBytesAsync(project.FullName);
        const string ancestorConfiguration = """<configuration><packageSources><clear /><add key="company" value="https://company.example/v3/index.json" /></packageSources></configuration>""";
        var configPath = Path.Combine(
            useAncestorConfig ? repositoryDirectory.FullName : appHostDirectory.FullName, "nuget.config");
        if (useAncestorConfig)
        {
            await File.WriteAllTextAsync(configPath, ancestorConfiguration);
        }
        var runner = CreateRunner(project, hasExplicitChannel: false);
        runner.SearchPackagesAsyncCallback = (directory, query, _, _, _, _, _, _, _, _) =>
        {
            Assert.StartsWith(Path.Combine(appHostDirectory.FullName, ".aspire") + Path.DirectorySeparatorChar, directory.FullName);
            Assert.Equal(["https://new.example/v3/index.json"],
                NuGetTestHelper.GetEligiblePackageSources(directory.FullName, query));
            return (0, [new NuGetPackageCli { Id = query, Version = "9.4.2", Source = "https://new.example/v3/index.json" }]);
        };
        var globalConfigDirectory = OperatingSystem.IsWindows()
            ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "NuGet")
            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "NuGet");
        runner.GetNuGetConfigPathsAsyncCallback = (directory, _, _) =>
        {
            Assert.Equal(appHostDirectory.FullName, directory.FullName);
            return (0, useAncestorConfig
                ? [configPath]
                : [Path.Combine(globalConfigDirectory, "NuGet.Config")]);
        };
        runner.AddPackageAsyncCallback = (file, _, version, _, noRestore, _, _) =>
        {
            Assert.True(noRestore);
            File.WriteAllText(file.FullName, File.ReadAllText(file.FullName).Replace("9.4.1", version, StringComparison.Ordinal));
            return 0;
        };
        var restored = false;
        runner.RestoreAsyncCallback = (_, options, _) =>
        {
            if (useAncestorConfig)
            {
                Assert.Equal(ancestorConfiguration, File.ReadAllText(configPath));
            }
            else
            {
                Assert.False(File.Exists(configPath));
            }
            Assert.NotNull(options.NuGetRestoreTargetsFile);
            Assert.StartsWith(Path.Combine(appHostDirectory.FullName, ".aspire") + Path.DirectorySeparatorChar,
                options.NuGetRestoreTargetsFile.FullName);
            Assert.Contains("9.4.2", File.ReadAllText(project.FullName));
            restored = true;
            return succeeds ? 0 : 1;
        };
        var services = CreateServices(workspace, runner, new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings
        });
        services.AddSingleton(TestExecutionContextHelper.CreateExecutionContext(invocationDirectory));
        using var provider = services.BuildServiceProvider();
        var channel = PackageChannel.CreateExplicitChannel(
            "daily", PackageChannelQuality.Both, [new("Aspire*", "https://new.example/v3/index.json")],
            provider.GetRequiredService<INuGetPackageCache>(), new TestFeatures(), NullLogger.Instance);
        var context = CreateContext(project, channel, hasExplicitChannel: true);
        var updater = provider.GetRequiredService<IProjectUpdater>();
        if (succeeds)
        {
            var result = await updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken);
            Assert.True(result.UpdatedApplied);
            Assert.Equal(["https://new.example/v3/index.json"],
                NuGetTestHelper.GetEligiblePackageSources(project.DirectoryName!, "Aspire.Hosting.Redis"));
            Assert.True(File.Exists(configPath));
        }
        else
        {
            await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
                updater.UpdateProjectAsync(context, TestContext.Current.CancellationToken));
            Assert.Equal(original, await File.ReadAllBytesAsync(project.FullName));
            if (useAncestorConfig)
            {
                Assert.Equal(ancestorConfiguration, await File.ReadAllTextAsync(configPath));
            }
            else
            {
                Assert.False(File.Exists(configPath));
            }
        }
        var interaction = Assert.IsType<TestInteractionService>(provider.GetRequiredService<IInteractionService>());
        Assert.Equal(Path.GetDirectoryName(configPath),
            Assert.Single(interaction.FilePathPromptCalls).DefaultValue);
        Assert.Empty(invocationDirectory.EnumerateFileSystemInfos());
        Assert.True(restored);
        Assert.Empty(Directory.EnumerateFiles(workspace.Path, ".aspire-nuget-candidate-*"));
    }
 
    [Fact]
    public async Task UpdateAsync_FailedRestoreRollsBackCentralPackageAndSdkMigration()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = await CreateAppHostAsync(workspace, "AppHost.csproj");
        await File.WriteAllTextAsync(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
              <ItemGroup>
                <PackageReference Include="Aspire.Hosting.AppHost" />
                <PackageReference Include="Aspire.Hosting.Redis" />
              </ItemGroup>
            </Project>
            """);
        var propsFile = new FileInfo(Path.Combine(workspace.Path, "Directory.Packages.props"));
        await File.WriteAllTextAsync(propsFile.FullName, """
            <Project>
              <PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup>
              <ItemGroup>
                <PackageVersion Include="Aspire.Hosting.AppHost" Version="9.4.1" />
                <PackageVersion Include="Aspire.Hosting.Redis" Version="9.4.1" />
              </ItemGroup>
            </Project>
            """);
        var originalProject = await File.ReadAllBytesAsync(projectFile.FullName);
        var originalProps = await File.ReadAllBytesAsync(propsFile.FullName);
        var runner = CreateRunner(projectFile, hasExplicitChannel: false);
        runner.GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => CreatePackageEvaluation();
        runner.RestoreAsyncCallback = (_, _, _) =>
        {
            var props = System.Xml.Linq.XDocument.Load(propsFile.FullName);
            var package = Assert.Single(props.Descendants("PackageVersion"));
            Assert.Equal("Aspire.Hosting.Redis", (string?)package.Attribute("Include"));
            Assert.Equal("9.4.2", (string?)package.Attribute("Version"));
            return 1;
        };
        using var provider = CreateServices(workspace, runner).BuildServiceProvider();
        var context = await CreateContextAsync(provider, projectFile, "default", hasExplicitChannel: false);
 
        await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
            provider.GetRequiredService<IProjectUpdater>().UpdateProjectAsync(context, TestContext.Current.CancellationToken));
 
        Assert.Equal(originalProject, await File.ReadAllBytesAsync(projectFile.FullName));
        Assert.Equal(originalProps, await File.ReadAllBytesAsync(propsFile.FullName));
    }
 
    private static async Task<FileInfo> WriteCurrentPolicyAsync(TemporaryWorkspace workspace, string source, bool configureCache)
    {
        var configFile = new FileInfo(Path.Combine(workspace.Path, "NuGet.Config"));
        var cacheSetting = configureCache
            ? """<config><add key="globalPackagesFolder" value=".nugetpackages" /></config>"""
            : string.Empty;
        await File.WriteAllTextAsync(configFile.FullName, $$"""
            <configuration>
              <packageSources>
                <clear />
                <add key="company" value="{{source}}" />
              </packageSources>
              <packageSourceMapping>
                <clear />
                <packageSource key="company"><package pattern="Aspire*" /></packageSource>
              </packageSourceMapping>
              {{cacheSetting}}
            </configuration>
            """);
        return configFile;
    }
 
    private static TestDotNetCliRunner CreateRunner(FileInfo projectFile, bool hasExplicitChannel) => new()
    {
        SearchPackagesAsyncCallback = (directory, query, _, _, _, _, configFile, _, _, _) =>
        {
            Assert.False(hasExplicitChannel, "A rejected explicit channel request must not discover packages.");
            Assert.Equal(projectFile.DirectoryName, directory.FullName);
            Assert.Null(configFile);
            return (0, [new NuGetPackageCli { Id = query, Version = "9.4.2", Source = PackageSources.NuGetOrg }]);
        },
        AddPackageAsyncCallback = (_, _, _, _, noRestore, _, _) =>
        {
            Assert.True(noRestore);
            return 0;
        },
        GetNuGetConfigPathsAsyncCallback = (_, _, _) =>
            throw new InvalidOperationException("Customized or unknown NuGet policy must not be persisted."),
        RestoreAsyncCallback = (_, options, _) =>
        {
            Assert.Null(options.EnvironmentVariables);
            return 0;
        }
    };
 
    private IServiceCollection CreateServices(TemporaryWorkspace workspace, TestDotNetCliRunner runner)
        => CreateServices(workspace, runner, new FakeNuGetClient
        {
            GetSettingsCallback = NuGetTestHelper.CreateClient().GetSettings,
            WriteNuGetConfigCallback = (_, _) =>
                throw new InvalidOperationException("Customized or unchanged NuGet policy must not create a preview or durable configuration.")
        });
 
    private IServiceCollection CreateServices(TemporaryWorkspace workspace, TestDotNetCliRunner runner, FakeNuGetClient client)
        => CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DotNetCliRunnerFactory = _ => runner;
            options.NuGetClientFactory = _ => client;
            options.InteractionServiceFactory = _ => new TestInteractionService();
        });
 
    private static (int, JsonDocument) CreatePackageEvaluation()
    {
        var properties = new JsonObject();
        properties.WithSdkVersion("9.4.1");
        properties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
        return (0, JsonDocument.Parse(properties.ToJsonString()));
    }
 
    private static async Task<UpdatePackagesContext> CreateContextAsync(
        IServiceProvider provider, FileInfo projectFile, string channelName, bool hasExplicitChannel)
    {
        var channels = await provider.GetRequiredService<IPackagingService>()
            .GetChannelsAsync(TestContext.Current.CancellationToken);
        return CreateContext(projectFile, channels.Single(channel => channel.Name == channelName), hasExplicitChannel);
    }
 
    private static UpdatePackagesContext CreateContext(FileInfo projectFile, PackageChannel channel, bool hasExplicitChannel)
        => new()
        {
            AppHostFile = projectFile,
            Channel = channel,
            HasExplicitChannel = hasExplicitChannel,
            ConfirmBinding = PromptBinding.CreateDefault(true),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null)
        };
 
    private static void AssertUpdatedSdk(IServiceProvider provider, FileInfo projectFile)
    {
        using var project = provider.GetRequiredService<FallbackProjectParser>().ParseProject(projectFile);
        Assert.Equal("9.4.2", project.RootElement.GetProperty("Properties").GetProperty("AspireHostingSDKVersion").GetString());
    }
}