File: Templating\TemplateNuGetConfigServiceTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging.Abstractions;
using System.Globalization;
using System.Xml.Linq;
 
namespace Aspire.Cli.Tests.Templating;
 
/// <summary>
/// Channel-resolution behavior for <see cref="TemplateNuGetConfigService"/>.
/// Unqualified local CLI template resolution uses directory-backed channels, while other
/// unqualified template resolution retains the implicit-channel and opted-in hive rules.
/// Explicit channel input for every entry point comes from its caller-supplied argument.
/// </summary>
public class TemplateNuGetConfigServiceTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_CreatesSelfContainedConfigWithoutAmbientSources()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output");
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"),
            """
            <?xml version="1.0" encoding="utf-8"?>
            <configuration>
              <packageSources>
                <add key="ambient-private" value="https://private.example/v3/index.json" />
              </packageSources>
              <disabledPackageSources>
                <add key="ambient-private" value="true" />
              </disabledPackageSources>
              <packageSourceCredentials>
                <ambient-private>
                  <add key="Username" value="user" />
                  <add key="ClearTextPassword" value="secret" />
                </ambient-private>
              </packageSourceCredentials>
              <packageSourceMapping>
                <packageSource key="ambient-private">
                  <package pattern="Contoso.*" />
                </packageSource>
              </packageSourceMapping>
            </configuration>
            """);
 
        var service = CreateService();
        const string sourceOverride = "/tmp/aspire-pr-hive/packages";
 
        Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, outputDirectory.FullName, CancellationToken.None));
 
        var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config"));
        Assert.Contains(doc.Root!.Element("packageSources")!.Elements("clear"), _ => true);
        Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == sourceOverride);
        Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == PackageSources.NuGetOrg);
        Assert.DoesNotContain(doc.Descendants("add"), e => (string?)e.Attribute("value") == "https://private.example/v3/index.json");
        Assert.Null(doc.Root!.Element("disabledPackageSources"));
        Assert.Null(doc.Root!.Element("packageSourceCredentials"));
        Assert.Empty(GetPackagePatternsForSource(doc, "ambient-private"));
    }
 
    [Fact]
    public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_PreservesRequestedChannelFallbackMappings()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output");
        const string sourceOverride = "/tmp/aspire-pr-hive/packages";
        const string channelAspireSource = "https://example.invalid/aspire";
        const string communitySource = "https://example.invalid/community";
        const string fallbackSource = "https://example.invalid/fallback";
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var channel = PackageChannel.CreateExplicitChannel(
                    "daily",
                    PackageChannelQuality.Both,
                    [
                        new PackageMapping("Aspire*", channelAspireSource),
                        new PackageMapping("CommunityToolkit*", communitySource),
                        new PackageMapping(PackageMapping.AllPackages, fallbackSource),
                    ],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([channel]);
            }
        };
        var service = CreateService(packagingService: packagingService);
 
        Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: "daily", outputDirectory.FullName, CancellationToken.None));
 
        var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config"));
        Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride));
        Assert.Equal(["CommunityToolkit*"], GetPackagePatternsForSource(doc, communitySource));
        Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, fallbackSource));
        Assert.Empty(GetPackagePatternsForSource(doc, channelAspireSource));
        Assert.Empty(GetPackagePatternsForSource(doc, PackageSources.NuGetOrg));
    }
 
    [Fact]
    public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_UpdatesOnlyProjectLocalConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output");
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"),
            """
            <?xml version="1.0" encoding="utf-8"?>
            <configuration>
              <packageSources>
                <add key="parent-private" value="https://parent.example/v3/index.json" />
              </packageSources>
            </configuration>
            """);
        await File.WriteAllTextAsync(
            Path.Combine(outputDirectory.FullName, "nuget.config"),
            """
            <?xml version="1.0" encoding="utf-8"?>
            <configuration>
              <packageSources>
                <add key="project-local" value="https://project.example/v3/index.json" />
              </packageSources>
              <packageSourceMapping>
                <packageSource key="project-local">
                  <package pattern="Project.*" />
                </packageSource>
              </packageSourceMapping>
            </configuration>
            """);
 
        var service = CreateService();
        const string sourceOverride = "/tmp/aspire-pr-hive/packages";
 
        Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, outputDirectory.FullName, CancellationToken.None));
 
        var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config"));
        Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == "https://project.example/v3/index.json");
        Assert.DoesNotContain(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == "https://parent.example/v3/index.json");
        Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride));
        Assert.Equal(["Project.*"], GetPackagePatternsForSource(doc, "project-local"));
    }
 
    [Fact]
    public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_NullSourceShortCircuits()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Channel lookup should not run without a source override.")
        };
        var service = CreateService(packagingService: packagingService);
 
        Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: null, channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None));
        Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: "", channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None));
        Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: "   ", channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None));
    }
 
    [Theory]
    [InlineData("https://user:token@example.invalid/v3/index.json")]
    [InlineData("https://example.invalid/v3/index.json?sig=token")]
    [InlineData("https://example.invalid/v3/index.json#token")]
    public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_CredentialBearingHttpSourceThrows(string sourceOverride)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var service = CreateService();
 
        await Assert.ThrowsAsync<ArgumentException>(
            async () => await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, workspace.WorkspaceRoot.FullName, CancellationToken.None));
 
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
    }
 
    [Fact]
    public async Task PromptToCreateOrUpdateNuGetConfigAsync_NullChannelName_ShortCircuits()
    {
        // Null/whitespace channelName must short-circuit without consulting any
        // ambient channel source. No exception, no implicit-channel work requested.
        var service = CreateService();
 
        await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: null, outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None);
        await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "", outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None);
        await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "   ", outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None);
    }
 
    [Fact]
    public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync_NullChannelName_ShortCircuits()
    {
        var service = CreateService();
 
        var dir = Directory.CreateTempSubdirectory();
        try
        {
            // Null/whitespace inputs must short-circuit and return false without
            // resolving a channel from any ambient source.
            Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: null, outputPath: dir.FullName, CancellationToken.None));
            Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: "", outputPath: dir.FullName, CancellationToken.None));
            Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: "   ", outputPath: dir.FullName, CancellationToken.None));
        }
        finally
        {
            dir.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_NullRequestedChannel_UsesImplicitChannelOnly()
    {
        // No explicit RequestedChannel: the resolver picks the implicit channel only.
        // We exercise the production codepath with a tracking packaging service so the
        // assertion is that the resolver completes (no exception is thrown by
        // an unexpected channel-lookup path) and only the implicit channel is in play.
        var requestedChannels = new List<PackageChannelType>();
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache
                {
                    GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult(Enumerable.Empty<Aspire.Shared.NuGetPackageCli>())
                }, new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService);
 
        var query = new TemplatePackageQuery(
            RequestedChannel: null,
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: false);
 
        // No packages were staged on the implicit channel, so the resolver throws
        // EmptyChoicesException — that's the expected terminal state for "implicit was
        // tried, nothing matched". The assertion is that this exception is the one that
        // surfaces (not ChannelNotFoundException from a different lookup path).
        await Assert.ThrowsAsync<Aspire.Cli.Interaction.EmptyChoicesException>(
            async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None));
    }
 
    [Fact]
    public async Task ResolveTemplatePackage_RequestedChannel_NotFound_Throws()
    {
        // The resolver no longer special-cases "local". A request for any unrecognized
        // channel name — including "local" with no matching named channel — must
        // throw ChannelNotFoundException. The local-identity fallback that previously
        // lived here has moved to InitCommand so that an explicit `aspire new --channel local`
        // without the hive still errors cleanly instead of silently switching feeds.
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService);
 
        var query = new TemplatePackageQuery(
            RequestedChannel: PackageChannelNames.Local,
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: false);
 
        await Assert.ThrowsAsync<Aspire.Cli.Exceptions.ChannelNotFoundException>(
            async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None));
    }
 
    [Fact]
    public async Task ResolveTemplatePackage_RequestedChannel_Matches_ReturnsThatChannel()
    {
        // Positive parity: a request for a registered named channel must resolve to that
        // exact channel. Verifies the rename + collapsed selection block didn't regress
        // the matching path.
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
                var stableCh = PackageChannel.CreateExplicitChannel(
                    "stable",
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", "stable-src")],
                    new FakeNuGetPackageCache
                    {
                        GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                        [
                            new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "13.3.0", Source = "stable-src" }
                        ])
                    },
                    features: new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh, stableCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService);
 
        var query = new TemplatePackageQuery(
            RequestedChannel: "stable",
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: false);
 
        var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
 
        Assert.Equal("stable", selection.Channel.Name);
        Assert.Equal(PackageChannelType.Explicit, selection.Channel.Type);
        Assert.Equal("13.3.0", selection.Package.Version);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_RequestedChannelWithSourceOverride_ReplacesAspireSource()
    {
        const string channelName = "staging";
        const string channelSource = "https://channel.example/v3/index.json";
        const string sourceOverride = "https://proxy.example/v3/index.json";
 
        var packageCache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, nugetConfigFile, _) =>
            {
                Assert.NotNull(nugetConfigFile);
                var doc = XDocument.Load(nugetConfigFile.FullName);
                var sources = doc.Root!.Element("packageSources")!.Elements("add")
                    .Select(e => (string)e.Attribute("value")!)
                    .ToArray();
                Assert.Equal([sourceOverride], sources);
                Assert.Equal(["Aspire*", PackageMapping.AllPackages], GetPackagePatternsForSource(doc, sourceOverride));
 
                return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli
                    {
                        Id = TemplateNuGetConfigService.TemplatesPackageName,
                        Version = "13.5.0-preview.1",
                        Source = sourceOverride
                    }
                ]);
            }
        };
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var channel = PackageChannel.CreateExplicitChannel(
                    channelName,
                    PackageChannelQuality.Prerelease,
                    [
                        new PackageMapping("Aspire*", channelSource),
                        new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
                    ],
                    packageCache,
                    features: new TestFeatures(),
                    NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([channel]);
            }
        };
        var service = CreateService(packagingService: packagingService);
        var query = new TemplatePackageQuery(
            RequestedChannel: channelName,
            VersionOverride: null,
            SourceOverride: sourceOverride,
            IncludePrHives: false);
 
        await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_SourceOverrideWithoutRequestedChannel_UsesImplicitChannelOnly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var hivesDirectory = workspace.CreateDirectory("hives");
        hivesDirectory.CreateSubdirectory("pr-12345");
        var executionContext = CreateExecutionContextWithHives(workspace.WorkspaceRoot, hivesDirectory);
        const string sourceOverride = "https://proxy.example/v3/index.json";
 
        var packageCache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, nugetConfigFile, _) =>
            {
                Assert.NotNull(nugetConfigFile);
                var doc = XDocument.Load(nugetConfigFile.FullName);
                var sources = doc.Root!.Element("packageSources")!.Elements("add")
                    .Select(e => (string)e.Attribute("value")!)
                    .ToArray();
                Assert.Equal([sourceOverride], sources);
 
                return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli
                    {
                        Id = TemplateNuGetConfigService.TemplatesPackageName,
                        Version = "13.5.0",
                        Source = sourceOverride
                    }
                ]);
            }
        };
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitChannel = PackageChannel.CreateImplicitChannel(packageCache, new TestFeatures(), NullLogger.Instance);
                var hiveChannel = PackageChannel.CreateExplicitChannel(
                    "pr-12345",
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", "pr-source")],
                    new FakeNuGetPackageCache(),
                    new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "99.0.0");
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel, hiveChannel]);
            }
        };
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
        var query = new TemplatePackageQuery(
            RequestedChannel: null,
            VersionOverride: null,
            SourceOverride: sourceOverride,
            IncludePrHives: true);
 
        var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
 
        Assert.Equal("13.5.0", selection.Package.Version);
        Assert.Equal(sourceOverride, selection.Package.Source);
        Assert.Equal(PackageChannelType.Implicit, selection.Channel.Type);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_LocalSourceOverrideDoesNotFilterToChannelPin()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        var nestedDirectory = Directory.CreateDirectory(Path.Combine(
            packagesDirectory.FullName,
            "aspire.projecttemplates",
            "13.5.0"));
        File.WriteAllText(
            Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"),
            string.Empty);
 
        var sourceOverride = packagesDirectory.FullName.Replace('\\', '/');
        var channel = PackageChannel.CreateExplicitChannel(
            "staging",
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
            new FakeNuGetPackageCache(),
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "13.6.0");
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
        };
        var service = CreateService(packagingService: packagingService);
 
        var selection = await service.ResolveTemplatePackageAsync(
            new TemplatePackageQuery(
                RequestedChannel: "staging",
                VersionOverride: null,
                SourceOverride: sourceOverride,
                IncludePrHives: true),
            CancellationToken.None);
 
        Assert.Equal("13.5.0", selection.Package.Version);
        Assert.Equal(sourceOverride, selection.Package.Source);
    }
 
    [Fact]
    public async Task InstallTemplatePackageAsync_SourceOverride_UsesExclusiveSource()
    {
        const string channelSource = "https://channel.example/v3/index.json";
        const string sourceOverride = "https://proxy.example/v3/index.json";
        const string packageVersion = "13.5.0-preview.1";
        var channel = PackageChannel.CreateExplicitChannel(
            "staging",
            PackageChannelQuality.Prerelease,
            [
                new PackageMapping("Aspire*", channelSource),
                new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
            ],
            new FakeNuGetPackageCache(),
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: packageVersion);
        var selection = new TemplatePackageSelection(
            new Aspire.Shared.NuGetPackageCli
            {
                Id = TemplateNuGetConfigService.TemplatesPackageName,
                Version = packageVersion,
                Source = channelSource
            },
            channel);
        var runner = new TestDotNetCliRunner
        {
            InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, _, _, _) =>
            {
                Assert.Equal(TemplateNuGetConfigService.TemplatesPackageName, packageName);
                Assert.Equal(packageVersion, version);
                Assert.Equal(sourceOverride, nugetSource);
                Assert.NotNull(nugetConfigFile);
 
                var doc = XDocument.Load(nugetConfigFile.FullName);
                var sources = doc.Root!.Element("packageSources")!.Elements("add")
                    .Select(e => (string)e.Attribute("value")!)
                    .ToArray();
                Assert.Equal([sourceOverride], sources);
                Assert.Equal(["Aspire*", PackageMapping.AllPackages], GetPackagePatternsForSource(doc, sourceOverride));
 
                return (0, packageVersion);
            }
        };
        var service = CreateService();
 
        var outcome = await service.InstallTemplatePackageAsync(
            selection,
            sourceOverride,
            runner,
            "Installing templates",
            statusEmoji: null,
            CancellationToken.None);
 
        Assert.Equal(0, outcome.ExitCode);
        Assert.Equal(packageVersion, outcome.TemplateVersion);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_NonExistentRequestedChannel_NotLocal_StillThrowsChannelNotFound()
    {
        // Companion to RequestedChannel_NotFound_Throws: any unrecognized name (typo
        // such as "stalbe") must still surface a ChannelNotFoundException so users see
        // the failure instead of silently falling through to the implicit channel.
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService);
 
        var query = new TemplatePackageQuery(
            RequestedChannel: "stalbe",
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: false);
 
        await Assert.ThrowsAsync<Aspire.Cli.Exceptions.ChannelNotFoundException>(
            async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None));
    }
 
    [Theory]
    // The `aspire new` code path opts into hives via IncludePrHives: true. When a hive
    // is actually on disk (GetHiveCount() > 0), the resolver must consider every
    // registered channel — and the explicit pr-12345 channel (pinnedVersion 2.0.0) wins
    // over the implicit channel (version 1.0.0).
    [InlineData(true, true, "2.0.0", false, "pr-12345")]
    // Opt-in alone is not enough: the user must also have hive directories on disk
    // (GetHiveCount() > 0). Without them, even with IncludePrHives: true the resolver
    // must restrict to the implicit channel. This is what protects developers running
    // `aspire new` on a clean machine from accidentally pulling from an explicit channel
    // that was registered but never installed.
    [InlineData(true, false, "1.0.0", true, null)]
    // The `aspire init` code path passes IncludePrHives: false intentionally so a
    // developer with stale ~/.aspire/hives/* doesn't get a different template than on
    // a clean machine. Even with a hive present, the resolver must restrict to the
    // implicit channel.
    [InlineData(false, true, "1.0.0", true, null)]
    public async Task ResolveTemplatePackageAsync_IncludePrHives_RespectsHiveGate(
        bool includePrHives,
        bool createHiveOnDisk,
        string expectedVersion,
        bool expectImplicitChannel,
        string? expectedChannelName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
        if (createHiveOnDisk)
        {
            hivesDir.Create();
            hivesDir.CreateSubdirectory("pr-12345");
        }
 
        var executionContext = CreateExecutionContextWithHives(workspace.WorkspaceRoot, hivesDir);
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                    [
                        new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "1.0.0", Source = "implicit-src" }
                    ])
                }, new TestFeatures(), NullLogger.Instance);
                var hiveCh = PackageChannel.CreateExplicitChannel(
                    "pr-12345",
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", "pr-src")],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "2.0.0");
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh, hiveCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
 
        var query = new TemplatePackageQuery(
            RequestedChannel: null,
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: includePrHives);
 
        var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
 
        Assert.Equal(expectedVersion, selection.Package.Version);
        Assert.Equal(expectImplicitChannel ? PackageChannelType.Implicit : PackageChannelType.Explicit, selection.Channel.Type);
        if (expectedChannelName is not null)
        {
            Assert.Equal(expectedChannelName, selection.Channel.Name);
        }
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_UnqualifiedLocalIdentityWithoutMatchingIdentityChannelPackage_ThrowsActionableError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var hivesDirectory = workspace.CreateDirectory("hives");
        hivesDirectory.CreateSubdirectory("unrelated");
        var packagesDirectory = workspace.CreateDirectory("packages");
        var prPackagesDirectory = workspace.CreateDirectory("pr-packages");
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(prPackagesDirectory.FullName, "Aspire.ProjectTemplates.13.6.0-dev.nupkg"), string.Empty);
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: PackageChannelNames.Local,
            hivesDirectory: hivesDirectory,
            identityVersion: "13.6.0-dev");
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitChannel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                    [
                        new Aspire.Shared.NuGetPackageCli
                        {
                            Id = TemplateNuGetConfigService.TemplatesPackageName,
                            Version = "13.5.0",
                            Source = PackageSources.NuGetOrg
                        }
                    ])
                }, new TestFeatures(), NullLogger.Instance);
                var stagingChannel = PackageChannel.CreateExplicitChannel(
                    "staging",
                    PackageChannelQuality.Prerelease,
                    [new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "13.6.0-dev");
                var prChannel = PackageChannel.CreateExplicitChannel(
                    "pr-12345",
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", prPackagesDirectory.FullName.Replace('\\', '/'))],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "13.6.0-dev");
                var localChannel = PackageChannel.CreateExplicitChannel(
                    PackageChannelNames.Local,
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "13.6.0-dev");
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel, stagingChannel, prChannel, localChannel]);
            }
        };
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
        var query = new TemplatePackageQuery(
            RequestedChannel: null,
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: true);
 
        var exception = await Assert.ThrowsAsync<Aspire.Cli.Interaction.EmptyChoicesException>(
            async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None));
 
        Assert.Equal(
            string.Format(
                CultureInfo.CurrentCulture,
                TemplatingStrings.NoMatchingLocalTemplatePackage,
                "13.6.0-dev"),
            exception.Message);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_LocalIdentityWithExplicitLocalChannel_UsesRequestedChannelVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.6.0.nupkg"), string.Empty);
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: PackageChannelNames.Local,
            identityVersion: "13.6.0-dev");
        var localChannel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Local,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
            new FakeNuGetPackageCache(),
            features: new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "13.5.0");
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([localChannel])
        };
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
 
        var selection = await service.ResolveTemplatePackageAsync(
            new TemplatePackageQuery(
                RequestedChannel: PackageChannelNames.Local,
                VersionOverride: null,
                SourceOverride: null,
                IncludePrHives: true),
            CancellationToken.None);
 
        Assert.Equal(PackageChannelNames.Local, selection.Channel.Name);
        Assert.Equal("13.5.0", selection.Package.Version);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_InitLocalChannelDoesNotFilterToPinnedVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        var nestedDirectory = Directory.CreateDirectory(Path.Combine(
            packagesDirectory.FullName,
            "aspire.projecttemplates",
            "13.6.0"));
        File.WriteAllText(
            Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.13.6.0.nupkg"),
            string.Empty);
 
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: PackageChannelNames.Local,
            identityVersion: "13.7.0");
        var localChannel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Local,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
            new FakeNuGetPackageCache(),
            features: new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "13.7.0");
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([localChannel])
        };
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
 
        var selection = await service.ResolveTemplatePackageAsync(
            new TemplatePackageQuery(
                RequestedChannel: PackageChannelNames.Local,
                VersionOverride: null,
                SourceOverride: null,
                IncludePrHives: false),
            CancellationToken.None);
 
        Assert.Equal("13.6.0", selection.Package.Version);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_ExplicitVersionFindsExactLocalPackageBelowNewerPinnedVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.6.0-dev.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.99.0.0.nupkg"), string.Empty);
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: PackageChannelNames.Local,
            identityVersion: "13.6.0-dev");
        var localChannel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Local,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
            new FakeNuGetPackageCache(),
            features: new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "99.0.0");
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([localChannel])
        };
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
 
        var selection = await service.ResolveTemplatePackageAsync(
            new TemplatePackageQuery(
                RequestedChannel: null,
                VersionOverride: "13.6.0-dev",
                SourceOverride: null,
                IncludePrHives: true),
            CancellationToken.None);
 
        Assert.Equal("13.6.0-dev", selection.Package.Version);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_ExplicitVersionNotFound_ThrowsInsteadOfSelectingAnotherVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.99.0.0.nupkg"), string.Empty);
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: PackageChannelNames.Local,
            identityVersion: "13.6.0-dev");
        var localChannel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Local,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
            new FakeNuGetPackageCache(),
            features: new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "99.0.0");
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([localChannel])
        };
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
 
        var exception = await Assert.ThrowsAsync<Aspire.Cli.Interaction.EmptyChoicesException>(
            async () => await service.ResolveTemplatePackageAsync(
                new TemplatePackageQuery(
                    RequestedChannel: null,
                    VersionOverride: "13.5.0-dev",
                    SourceOverride: null,
                    IncludePrHives: true),
                CancellationToken.None));
 
        Assert.Equal(
            string.Format(
                CultureInfo.CurrentCulture,
                TemplatingStrings.TemplateVersionNotFound,
                "13.5.0-dev"),
            exception.Message);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_UnqualifiedLocalIdentity_PrefersMatchingLocalPackage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.6.0-dev.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.7.0-dev.nupkg"), string.Empty);
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: PackageChannelNames.Local,
            identityVersion: "13.6.0-dev");
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var stagingChannel = PackageChannel.CreateExplicitChannel(
                    "staging",
                    PackageChannelQuality.Prerelease,
                    [new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "13.6.0-dev");
                var localChannel = PackageChannel.CreateExplicitChannel(
                    PackageChannelNames.Local,
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "13.7.0-dev");
                return Task.FromResult<IEnumerable<PackageChannel>>([stagingChannel, localChannel]);
            }
        };
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
        var query = new TemplatePackageQuery(
            RequestedChannel: null,
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: true);
 
        var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
 
        Assert.Equal(PackageChannelNames.Local, selection.Channel.Name);
        Assert.True(selection.Channel.IsBackedByLocalPackageDirectory);
        Assert.Equal("13.6.0-dev", selection.Package.Version);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_LocalIdentityWithExplicitChannel_UsesRequestedChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: PackageChannelNames.Local,
            identityVersion: "13.6.0-dev");
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var stagingChannel = PackageChannel.CreateExplicitChannel(
                    "staging",
                    PackageChannelQuality.Prerelease,
                    [new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "13.6.0-dev");
                return Task.FromResult<IEnumerable<PackageChannel>>([stagingChannel]);
            }
        };
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
        var query = new TemplatePackageQuery(
            RequestedChannel: "staging",
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: true);
 
        var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
 
        Assert.Equal("staging", selection.Channel.Name);
        Assert.Equal("13.6.0-dev", selection.Package.Version);
    }
 
    private static CliExecutionContext CreateExecutionContextWithHives(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory)
    {
        return TestExecutionContextHelper.CreateExecutionContext(
            workingDirectory,
            identityChannel: PackageChannelNames.Daily,
            hivesDirectory: hivesDirectory);
    }
 
    // Emulating a released build via ASPIRE_CLI_PACKAGES / the sidecar `packages` field: the
    // synthesized channel is named after the emulated identity (here "stable", a NON-local-build
    // name) and points Aspire.* at a local directory. Even with IncludePrHives:false (the
    // `aspire init` shape) and no hive on disk, the deliberate local-packages override must be
    // honored so templates resolve from the local directory instead of silently falling back to
    // nuget.org. Regression guard for the identity-sidecar emulation bug where a stable/daily/
    // staging emulated name caused the local channel to be filtered out of template resolution.
    [Fact]
    public async Task ResolveTemplatePackageAsync_IdentityPackagesOverride_IncludesLocalChannelEvenWhenPrHivesSuppressed()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDir = workspace.CreateDirectory("identity-packages");
        File.WriteAllText(Path.Combine(packagesDir.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"), string.Empty);
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: "stable",
            identityVersion: "13.5.0",
            identityOverridden: true,
            identityPackagesDirectory: packagesDir);
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache
                {
                    GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                    [
                        new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "13.4.3", Source = "nuget.org" }
                    ])
                }, new TestFeatures(), NullLogger.Instance);
                var localStableCh = PackageChannel.CreateExplicitChannel(
                    "stable",
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", packagesDir.FullName.Replace('\\', '/'))],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(),
                    NullLogger.Instance,
                    pinnedVersion: "13.5.0");
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh, localStableCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
 
        var query = new TemplatePackageQuery(
            RequestedChannel: null,
            VersionOverride: null,
            SourceOverride: null,
            IncludePrHives: false);
 
        var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
 
        Assert.Equal("13.5.0", selection.Package.Version);
        Assert.Equal(PackageChannelType.Explicit, selection.Channel.Type);
        Assert.Equal("stable", selection.Channel.Name);
    }
 
    [Fact]
    public async Task ResolveTemplatePackageAsync_ImplicitDiscovery_FindsHierarchicalTemplateFromFileUriMappedLocalChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDir = workspace.CreateDirectory("local-feed");
        var templatePackagePath = Path.Combine(
            packagesDir.FullName,
            "aspire.projecttemplates",
            "13.4.0",
            "Aspire.ProjectTemplates.13.4.0.nupkg");
        Directory.CreateDirectory(Path.GetDirectoryName(templatePackagePath)!);
        await File.WriteAllTextAsync(templatePackagePath, string.Empty);
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            identityChannel: "pr-12345",
            identityVersion: "13.4.0");
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
                var localChannel = PackageChannel.CreateExplicitChannel(
                    "pr-12345",
                    PackageChannelQuality.Both,
                    [new PackageMapping("Aspire*", new Uri(packagesDir.FullName).AbsoluteUri)],
                    new FakeNuGetPackageCache
                    {
                        GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
                    },
                    features: new TestFeatures(),
                    NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh, localChannel]);
            }
        };
 
        var service = CreateService(packagingService: packagingService, executionContext: executionContext);
 
        var selection = await service.ResolveTemplatePackageAsync(
            new TemplatePackageQuery(
                RequestedChannel: null,
                VersionOverride: null,
                SourceOverride: null,
                IncludePrHives: true),
            CancellationToken.None);
 
        Assert.Equal("Aspire.ProjectTemplates", selection.Package.Id);
        Assert.Equal("13.4.0", selection.Package.Version);
        Assert.Equal(PackageChannelType.Explicit, selection.Channel.Type);
        Assert.Equal("pr-12345", selection.Channel.Name);
    }
 
    private static string[] GetPackagePatternsForSource(XDocument doc, string source)
    {
        var packageSourceMapping = doc.Root!.Element("packageSourceMapping");
        if (packageSourceMapping is null)
        {
            return [];
        }
 
        return packageSourceMapping
            .Elements("packageSource")
            .Where(e => string.Equals((string?)e.Attribute("key"), source, StringComparison.OrdinalIgnoreCase))
            .Elements("package")
            .Select(e => (string?)e.Attribute("pattern"))
            .Where(pattern => pattern is not null)
            .Select(pattern => pattern!)
            .ToArray();
    }
 
    [Fact]
    public async Task PromptToCreateOrUpdateNuGetConfigAsync_ExplicitChannelRequiringConfig_CreatesConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputDir = workspace.WorkspaceRoot.CreateSubdirectory("output");
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                // Simulate the daily channel: explicit, custom feed → drops a config.
                var dailyCh = PackageChannel.CreateExplicitChannel(
                    "daily",
                    PackageChannelQuality.Prerelease,
                    [
                        new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"),
                        new PackageMapping("*", "https://api.nuget.org/v3/index.json")
                    ],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([dailyCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService);
 
        await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "daily", outputDir.FullName, CancellationToken.None);
 
        // nuget.config should be created because the daily channel routes Aspire* to a custom feed
        var configPath = Path.Combine(outputDir.FullName, "nuget.config");
        Assert.True(File.Exists(configPath));
        var doc = XDocument.Load(configPath);
        var sources = doc.Root!.Element("packageSources")!.Elements("add")
            .Select(e => (string)e.Attribute("value")!)
            .ToArray();
        Assert.Equal(
            ["https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", "https://api.nuget.org/v3/index.json"],
            sources);
    }
 
    [Fact]
    public async Task PromptToCreateOrUpdateNuGetConfigAsync_StableChannel_DoesNotCreateConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputDir = workspace.WorkspaceRoot.CreateSubdirectory("output");
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                // Simulate the stable channel: explicit but mapped to nuget.org (ambient default),
                // so ShouldCreateNuGetConfig() is false (excluded by name).
                var stableCh = PackageChannel.CreateExplicitChannel(
                    "stable",
                    PackageChannelQuality.Stable,
                    [new PackageMapping("*", "https://api.nuget.org/v3/index.json")],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([stableCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService);
 
        await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "stable", outputDir.FullName, CancellationToken.None);
 
        // No nuget.config should be created because the stable channel maps to nuget.org
        Assert.False(File.Exists(Path.Combine(outputDir.FullName, "nuget.config")));
    }
 
    [Fact]
    public async Task PromptToCreateOrUpdateNuGetConfigAsync_StableChannel_UpdatesExistingConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputDir = workspace.WorkspaceRoot.CreateSubdirectory("output");
 
        // Pre-existing nuget.config from a previous daily channel
        await File.WriteAllTextAsync(
            Path.Combine(outputDir.FullName, "nuget.config"),
            """
            <?xml version="1.0" encoding="utf-8"?>
            <configuration>
              <packageSources>
                <add key="aspire-daily" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
              </packageSources>
              <packageSourceMapping>
                <packageSource key="aspire-daily">
                  <package pattern="Aspire*" />
                </packageSource>
              </packageSourceMapping>
            </configuration>
            """);
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var stableCh = PackageChannel.CreateExplicitChannel(
                    "stable",
                    PackageChannelQuality.Stable,
                    [new PackageMapping("*", "https://api.nuget.org/v3/index.json")],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([stableCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService);
 
        await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "stable", outputDir.FullName, CancellationToken.None);
 
        // Existing nuget.config should still exist (it was updated)
        var configPath = Path.Combine(outputDir.FullName, "nuget.config");
        Assert.True(File.Exists(configPath));
 
        // The stable channel's mapping (* → nuget.org) should now be present
        var doc = XDocument.Load(configPath);
        var sources = doc.Root!.Element("packageSources")!.Elements("add")
            .Select(e => (string)e.Attribute("value")!)
            .ToArray();
        Assert.Equal(["https://api.nuget.org/v3/index.json"], sources);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync_StableChannel_RespectsExistingConfig(bool hasExistingConfig)
    {
        // The "without prompt" path is used by aspire init. When the channel does not
        // require a project-level nuget.config (e.g. stable → nuget.org only):
        //   - No existing config → skip creation entirely (return false)
        //   - Existing config → update it to clean up stale feeds (return true)
        // See: https://github.com/microsoft/aspire/issues/18124
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputDir = workspace.WorkspaceRoot.CreateSubdirectory("output");
 
        if (hasExistingConfig)
        {
            await File.WriteAllTextAsync(
                Path.Combine(outputDir.FullName, "nuget.config"),
                """
                <?xml version="1.0" encoding="utf-8"?>
                <configuration>
                  <packageSources>
                    <add key="aspire-daily" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
                  </packageSources>
                </configuration>
                """);
        }
 
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var stableCh = PackageChannel.CreateExplicitChannel(
                    "stable",
                    PackageChannelQuality.Stable,
                    [new PackageMapping("*", "https://api.nuget.org/v3/index.json")],
                    new FakeNuGetPackageCache(),
                    features: new TestFeatures(), NullLogger.Instance);
                return Task.FromResult<IEnumerable<PackageChannel>>([stableCh]);
            }
        };
 
        var service = CreateService(packagingService: packagingService);
 
        var result = await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: "stable", outputDir.FullName, CancellationToken.None);
 
        var configPath = Path.Combine(outputDir.FullName, "nuget.config");
 
        if (hasExistingConfig)
        {
            Assert.True(result);
            Assert.True(File.Exists(configPath));
            var doc = XDocument.Load(configPath);
            var sources = doc.Root!.Element("packageSources")!.Elements("add")
                .Select(e => (string)e.Attribute("value")!)
                .ToArray();
            Assert.Equal(["https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", "https://api.nuget.org/v3/index.json"], sources);
        }
        else
        {
            Assert.False(result);
            Assert.False(File.Exists(configPath));
        }
    }
 
    private static TemplateNuGetConfigService CreateService(
        TestPackagingService? packagingService = null,
        CliExecutionContext? executionContext = null)
    {
        return new TemplateNuGetConfigService(
            new TestInteractionService(),
            executionContext ?? TestExecutionContextFactory.CreateTestContext(),
            packagingService ?? MockPackagingServiceFactory.Create(),
            new StubTemplateVersionPrompter(),
            new StubCliHostEnvironment());
    }
 
    private sealed class StubTemplateVersionPrompter : Aspire.Cli.Commands.ITemplateVersionPrompter
    {
        public Task<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> PromptForTemplatesVersionAsync(
            IEnumerable<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> candidatePackages,
            CancellationToken cancellationToken)
        {
            throw new InvalidOperationException(
                "TemplateNuGetConfigService unexpectedly entered the version-prompt path; this stub is wired in tests where the prompt should never be reached.");
        }
    }
 
    private sealed class StubCliHostEnvironment : ICliHostEnvironment
    {
        public bool SupportsInteractiveInput => false;
        public bool SupportsInteractiveOutput => false;
        public bool SupportsAnsi => false;
    }
}