File: Packaging\PackageChannel.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Compression;
using System.Xml.Linq;
using Aspire.Cli.Configuration;
using Aspire.Cli.NuGet;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Semver;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.Packaging;
 
internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, IFeatures features, ILogger logger, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, string? currentCliVersion = null, Action? validateTemplatePackageMetadataPrefetching = null)
{
    // Threaded so the local-folder integration listing can honor the same
    // ShowDeprecatedPackages flag that NuGetPackageCache honors on the feed-based path.
    // Without this, flipping the flag silently has no effect on local hive / PR hive listings
    // (https://github.com/microsoft/aspire/issues — divergence between two paths through the same intent).
    private readonly IFeatures _features = features;
 
    // The running CLI's identity SDK version (CliExecutionContext.IdentitySdkVersion), threaded in
    // by PackagingService so the template-package filter below honors ASPIRE_CLI_VERSION / sidecar
    // overrides. Null when a channel is constructed directly (e.g. in tests) without identity, in
    // which case the filter falls back to the assembly version.
    private readonly string? _currentCliVersion = currentCliVersion;
 
    private const string GuestAppHostSdkPackageId = "Aspire.Hosting";
    private const string TemplatePackageId = "Aspire.ProjectTemplates";
 
    // The NuGet 'polyglot' tag added by default to Aspire.Hosting integrations that run the export
    // analyzer; authors opt out with <IsAspirePolyglotCompatible>false</IsAspirePolyglotCompatible> (see
    // src/Directory.Build.targets and Aspire.Hosting/buildTransitive/Aspire.Hosting.targets). Its presence
    // identifies an integration whose API surface is projected to non-C# AppHosts via [AspireExport] (ATS)
    // coverage, so `aspire add` can hide integrations that a polyglot AppHost cannot consume.
    private const string PolyglotTag = "polyglot";
 
    // NuGet search query scoping that restricts results to packages carrying the polyglot tag, e.g.
    //   dotnet package search "tags:polyglot" --source https://api.nuget.org/v3/index.json
    //
    // Support for this scoping is inconsistent across feed implementations, and no remote feed answers it
    // usefully today, which is why the polyglot filter is gated behind the off-by-default
    // KnownFeatures.PolyglotIntegrationFilterEnabled flag:
    //  - nuget.org honours the scoping, but `dotnet package search` broadens the query before it gets there
    //    (68 results via the CLI versus 29 from the raw search service), so the response is a relevance
    //    ranking rather than an exact tag set. Either way the result contains no first-party integration:
    //    only Aspire.TypeSystem and Aspire.Hosting.Integration.Analyzers carry the tag on nuget.org, and
    //    PackageIdFilters.IsIntegrationPackageId excludes both.
    //  - Azure DevOps Artifacts feeds (the daily, staging, and PR channels) silently ignore it and return
    //    zero results, even for packages whose search payload contains the tag. There is no error to
    //    detect, so the caller cannot distinguish "no compatible packages" from "this feed cannot answer
    //    the question". See https://github.com/microsoft/aspire/issues/19161.
    //  - Local folder feeds do not support tag search at all, which is why the local-source path reads the
    //    nuspec <tags> directly instead. That path is the only one that resolves a correct allow-list.
    private const string PolyglotTagSearchTerm = "tags:polyglot";
 
    public string Name { get; } = name;
    public PackageChannelQuality Quality { get; } = quality;
    public PackageMapping[]? Mappings { get; } = mappings;
    public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit;
    public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder;
    public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl;
    public string? PinnedVersion { get; } = pinnedVersion;
 
    public string SourceDetails { get; } = ComputeSourceDetails(mappings);
 
    public bool ShouldPersistChannelName() =>
        Type is PackageChannelType.Explicit && !string.Equals(Name, PackageChannelNames.Stable, StringComparisons.ChannelName);
 
    /// <summary>
    /// Whether scaffolding (<c>aspire new</c>/<c>aspire init</c>) should drop a per-project
    /// <c>NuGet.config</c> that pins Aspire packages to this channel's feed(s).
    /// </summary>
    /// <remarks>
    /// Only channels that route Aspire packages to a <em>custom</em> feed need a per-project
    /// <c>NuGet.config</c>. The <c>stable</c> channel maps everything to nuget.org — the ambient
    /// default source — so emitting a <c>&lt;clear/&gt;</c>-based config would be redundant and,
    /// worse, would wipe any additional feeds the user already relies on. Daily
    /// (<c>dnceng/dotnet9</c>), staging (<c>darc-pub-microsoft-aspire-&lt;sha&gt;</c>), and
    /// <c>pr-&lt;N&gt;</c>/local-hive channels all point at custom feeds, so their mappings must be
    /// persisted for restore to succeed. This deliberately mirrors <see cref="ShouldPersistChannelName"/>:
    /// the set of channels whose name we pin is exactly the set we drop a config for (those that
    /// additionally carry feed mappings).
    /// </remarks>
    public bool ShouldCreateNuGetConfig() =>
        ShouldPersistChannelName() && Mappings is { Length: > 0 };
 
    /// <summary>
    /// Whether this channel resolves Aspire.* packages from a local directory of <c>.nupkg</c>
    /// files (a <c>~/.aspire/hives/&lt;name&gt;</c> hive, a PR-install hive, or an
    /// <c>ASPIRE_CLI_PACKAGES</c> / sidecar <c>packages</c> override) rather than a remote feed.
    /// </summary>
    /// <remarks>
    /// Unlike <see cref="VersionHelper.IsLocalBuildChannel(string)"/> — which keys off the channel
    /// <em>name</em> (<c>local</c>/<c>pr-&lt;N&gt;</c>/<c>run-&lt;N&gt;</c>) — this inspects the actual
    /// mappings via <see cref="PackageMapping.IsAspireDirectoryMapping"/>. That matters when a locally
    /// built CLI emulates a released build: the synthesized channel is named after the emulated
    /// identity (<c>stable</c>/<c>daily</c>/<c>staging</c>) yet points Aspire.* at a local directory.
    /// The name check would misclassify it as remote and silently fall back to nuget.org; this
    /// property recognizes it as locally backed so <c>aspire new</c>/<c>aspire add</c> resolve those
    /// local packages. See <c>docs/specs/cli-identity-sidecar.md</c>.
    /// </remarks>
    public bool IsBackedByLocalPackageDirectory =>
        Type is PackageChannelType.Explicit &&
        Mappings?.Any(static mapping => mapping.IsAspireDirectoryMapping) == true;
 
    private static string ComputeSourceDetails(PackageMapping[]? mappings)
    {
        if (mappings is null)
        {
            return PackagingStrings.BasedOnNuGetConfig;
        }
 
        var aspireMapping = mappings.FirstOrDefault(m => m.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase));
        var allPackagesMapping = mappings.FirstOrDefault(m => m.PackageFilter == PackageMapping.AllPackages);
 
        if (aspireMapping is not null)
        {
            return aspireMapping.Source;
        }
        else
        {
            return allPackagesMapping?.Source ?? PackagingStrings.BasedOnNuGetConfig;
        }
    }
 
    public Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        return GetTemplatePackagesAsync(workingDirectory, Mappings, filterLocalPackagesToPinnedVersion: true, cancellationToken);
    }
 
    /// <summary>
    /// Gets template packages using the specified mappings without changing this channel's identity.
    /// </summary>
    public Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, PackageMapping[]? mappings, CancellationToken cancellationToken)
    {
        return GetTemplatePackagesAsync(workingDirectory, mappings, filterLocalPackagesToPinnedVersion: false, cancellationToken);
    }
 
    /// <summary>
    /// Gets template packages using the specified mappings, optionally retaining every version
    /// found in a local package directory instead of applying this channel's pinned version.
    /// </summary>
    public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(
        DirectoryInfo workingDirectory,
        PackageMapping[]? mappings,
        bool filterLocalPackagesToPinnedVersion,
        CancellationToken cancellationToken)
    {
        validateTemplatePackageMetadataPrefetching?.Invoke();
 
        // Local directory discovery must follow the caller-supplied mappings (e.g. `--source <dir>`),
        // not this channel's own mappings, otherwise the override is silently ignored.
        if (GetLocalAspirePackageSource(mappings) is { } localPackageSource)
        {
            var localPackages = GetTemplatePackagesFromLocalPackageSource(localPackageSource.Source, localPackageSource.PackageSource, cancellationToken);
            return PinnedVersion is null || !filterLocalPackagesToPinnedVersion
                ? localPackages
                : localPackages.Where(package => string.Equals(package.Version, PinnedVersion, StringComparison.OrdinalIgnoreCase));
        }
 
        if (PinnedVersion is not null)
        {
            return [new NuGetPackage { Id = TemplatePackageId, Version = PinnedVersion, Source = ComputeSourceDetails(mappings) }];
        }
 
        var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
 
        using var tempNuGetConfig = mappings is not null ? await TemporaryNuGetConfig.CreateAsync(mappings) : null;
 
        if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, false, tempNuGetConfig?.ConfigFile, cancellationToken));
        }
 
        if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetTemplatePackagesAsync(workingDirectory, true, tempNuGetConfig?.ConfigFile, cancellationToken));
        }
 
        var packageResults = await Task.WhenAll(tasks);
 
        var packages = packageResults
            .SelectMany(p => p)
            .DistinctBy(p => $"{p.Id}-{p.Version}");
 
        // When doing a `dotnet package search` the results may include stable packages even when searching for
        // prerelease packages. Keep the current CLI/SDK version so shipped CLIs can resolve their
        // matching template package from daily/staging feeds, then filter out the remaining noise.
        // Falls back to the assembly version only when no identity was threaded in (direct construction);
        // production channels created by PackagingService always supply CliExecutionContext.IdentitySdkVersion.
        var currentCliVersion = _currentCliVersion ?? VersionHelper.GetDefaultSdkVersion();
        var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch
        {
            { Quality: PackageChannelQuality.Both } => true,
            { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
            { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
            { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: false } } when string.Equals(p.Version, currentCliVersion, StringComparison.OrdinalIgnoreCase) => true,
            _ => false
        });
 
        return filteredPackages;
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        if (GetLocalAspirePackageSource() is { } localPackageSource)
        {
            return GetIntegrationPackagesFromLocalPackageSource(
                localPackageSource.Source,
                localPackageSource.PackageSource,
                GetLocalPackageVersion(workingDirectory),
                cancellationToken);
        }
 
        var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
 
        using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null;
 
        if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetIntegrationPackagesAsync(workingDirectory, false, tempNuGetConfig?.ConfigFile, cancellationToken));
        }
 
        if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetIntegrationPackagesAsync(workingDirectory, true, tempNuGetConfig?.ConfigFile, cancellationToken));
        }
 
        var packageResults = await Task.WhenAll(tasks);
 
        var packages = packageResults
            .SelectMany(p => p)
            .DistinctBy(p => $"{p.Id}-{p.Version}");
 
        // When doing a `dotnet package search` the results may include stable packages even when searching for
        // prerelease packages. This filters out this noise.
        var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch
        {
            { Quality: PackageChannelQuality.Both } => true,
            { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
            { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
            _ => false
        });
 
        // When pinned to a specific version, override the version on each discovered package
        // so the correct version gets installed regardless of what the feed reports as latest.
        if (PinnedVersion is not null)
        {
            return filteredPackages.Select(p => new NuGetPackage { Id = p.Id, Version = PinnedVersion, Source = p.Source });
        }
 
        return filteredPackages;
    }
 
    private (string Source, DirectoryInfo PackageSource)? GetLocalAspirePackageSource() => GetLocalAspirePackageSource(Mappings);
 
    private static (string Source, DirectoryInfo PackageSource)? GetLocalAspirePackageSource(PackageMapping[]? mappings)
    {
        if (mappings is null)
        {
            return null;
        }
 
        foreach (var mapping in mappings)
        {
            if (!mapping.IsAspireDirectoryMapping)
            {
                continue;
            }
 
            return (mapping.Source, new DirectoryInfo(PackageSourceOverrideMappings.GetNormalizedLocalDirectory(mapping.Source)!));
        }
 
        return null;
    }
 
    private IEnumerable<NuGetPackage> GetTemplatePackagesFromLocalPackageSource(string source, DirectoryInfo packageSource, CancellationToken cancellationToken)
    {
        var currentCliVersion = _currentCliVersion ?? VersionHelper.GetDefaultSdkVersion();
 
        return EnumerateLocalPackageFiles(packageSource, cancellationToken)
            .Select(file => GetPackageFileMetadata(file.FullName))
            .OfType<PackageFileMetadata>()
            .Where(metadata => string.Equals(metadata.PackageId, TemplatePackageId, StringComparisons.NuGetPackageId))
            .Where(metadata => new { metadata.Version, Quality } switch
            {
                { Quality: PackageChannelQuality.Both } => true,
                { Quality: PackageChannelQuality.Stable, Version: { IsPrerelease: false } } => true,
                { Quality: PackageChannelQuality.Prerelease, Version: { IsPrerelease: true } } => true,
                { Quality: PackageChannelQuality.Prerelease, Version: { IsPrerelease: false } } when string.Equals(metadata.Version.ToString(), currentCliVersion, StringComparison.OrdinalIgnoreCase) => true,
                _ => false
            })
            .DistinctBy(metadata => metadata.Version)
            .Select(metadata => new NuGetPackage { Id = metadata.PackageId, Version = metadata.Version.ToString(), Source = source })
            .ToArray();
    }
 
    private string? GetLocalPackageVersion(DirectoryInfo workingDirectory)
    {
        // `aspire new` records an exact local selection as:
        //   { "channel": "local", "sdk": { "version": "13.6.0-dev" } }
        // A local hive can also contain stale higher-version packages, so use the project-scoped
        // version when the persisted channel matches this channel. Projects created before that
        // version was persisted continue to use the channel's discovered pin.
        var configPath = ConfigurationHelper.FindNearestConfigFilePath(workingDirectory);
        if (configPath is null ||
            !ConfigurationHelper.TryLoadSettingsFile(configPath, out var configuration) ||
            !string.Equals(configuration["channel"], Name, StringComparisons.ChannelName))
        {
            return PinnedVersion;
        }
 
        var configuredVersion = configuration["sdk:version"];
        if (string.IsNullOrWhiteSpace(configuredVersion))
        {
            configuredVersion = configuration["sdkVersion"];
        }
 
        return string.IsNullOrWhiteSpace(configuredVersion) ? PinnedVersion : configuredVersion;
    }
 
    private IEnumerable<NuGetPackage> GetIntegrationPackagesFromLocalPackageSource(string source, DirectoryInfo packageSource, string? packageVersion, CancellationToken cancellationToken)
    {
        // Mirror NuGetPackageCache.GetIntegrationPackagesAsync: a user who flipped
        // ShowDeprecatedPackages to see deprecated packages on stable/staging/daily
        // must also see them on local-hive / PR-hive listings. Previously the deprecation
        // check was hardcoded into IsIntegrationPackageId and silently dropped them here.
        var showDeprecatedPackages = _features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false);
 
        var packageMetadata = EnumerateLocalPackageFiles(packageSource, cancellationToken)
            .Select(file => GetPackageFileMetadata(file.FullName))
            .OfType<PackageFileMetadata>()
            .Where(metadata => PackageIdFilters.IsIntegrationPackageId(metadata.PackageId))
            .Where(metadata => showDeprecatedPackages || !DeprecatedPackages.IsDeprecated(metadata.PackageId))
            .Where(IsAllowedByQuality);
 
        if (packageVersion is not null)
        {
            packageMetadata = packageMetadata
                .Where(metadata => string.Equals(metadata.Version.ToString(), packageVersion, StringComparison.OrdinalIgnoreCase));
        }
 
        return packageMetadata
            .GroupBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId)
            .Select(group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First())
            .OrderBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId)
            .Select(metadata => new NuGetPackage { Id = metadata.PackageId, Version = packageVersion ?? metadata.Version.ToString(), Source = source })
            .ToArray();
 
        bool IsAllowedByQuality(PackageFileMetadata metadata) => new { metadata.Version, Quality } switch
        {
            { Quality: PackageChannelQuality.Both } => true,
            { Quality: PackageChannelQuality.Stable, Version: { IsPrerelease: false } } => true,
            { Quality: PackageChannelQuality.Prerelease, Version: { IsPrerelease: true } } => true,
            _ => false
        };
    }
 
    /// <summary>
    /// Returns the set of integration package IDs in this channel that are marked polyglot-compatible,
    /// i.e. carry the <c>polyglot</c> NuGet tag. That tag is added by default to Aspire.Hosting
    /// integrations that run the export analyzer; authors opt out with
    /// <c>&lt;IsAspirePolyglotCompatible&gt;false&lt;/IsAspirePolyglotCompatible&gt;</c>.
    /// </summary>
    /// <remarks>
    /// Used by <c>aspire add</c> to hide integrations that have no ATS export coverage from non-C#
    /// AppHosts. The tag is read differently per channel kind because <c>dotnet package search --format json</c>
    /// does not return tags and <c>tags:</c> query scoping is not honored by local folder feeds:
    /// <list type="bullet">
    /// <item>Local package source / hive: the <c>&lt;tags&gt;</c> element is read from the nuspec inside each <c>.nupkg</c>.</item>
    /// <item>Remote feeds: a secondary <c>tags:polyglot</c> search is issued.</item>
    /// </list>
    /// </remarks>
    public async Task<IReadOnlySet<string>> GetPolyglotCompatiblePackageIdsAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        if (GetLocalAspirePackageSource() is { } localPackageSource)
        {
            return GetPolyglotCompatiblePackageIdsFromLocalPackageSource(
                localPackageSource.PackageSource,
                GetLocalPackageVersion(workingDirectory),
                cancellationToken);
        }
 
        using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null;
 
        var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
 
        if (Quality is PackageChannelQuality.Stable or PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetPackagesAsync(workingDirectory, PolyglotTagSearchTerm, filter: null, prerelease: false, tempNuGetConfig?.ConfigFile, useCache: true, cancellationToken));
        }
 
        if (Quality is PackageChannelQuality.Prerelease or PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetPackagesAsync(workingDirectory, PolyglotTagSearchTerm, filter: null, prerelease: true, tempNuGetConfig?.ConfigFile, useCache: true, cancellationToken));
        }
 
        var results = await Task.WhenAll(tasks);
 
        return results
            .SelectMany(r => r)
            .Select(p => p.Id)
            .ToHashSet(StringComparers.NuGetPackageId);
    }
 
    private IReadOnlySet<string> GetPolyglotCompatiblePackageIdsFromLocalPackageSource(
        DirectoryInfo packageSource,
        string? packageVersion,
        CancellationToken cancellationToken)
    {
        var ids = new HashSet<string>(StringComparers.NuGetPackageId);
 
        foreach (var file in EnumerateLocalPackageFiles(packageSource, cancellationToken))
        {
            if (GetPackageFileMetadata(file.FullName) is not { } metadata ||
                !PackageIdFilters.IsIntegrationPackageId(metadata.PackageId) ||
                packageVersion is not null &&
                !string.Equals(metadata.Version.ToString(), packageVersion, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }
 
            if (PackageHasPolyglotTag(file.FullName, logger))
            {
                ids.Add(metadata.PackageId);
            }
        }
 
        return ids;
    }
 
    private static IEnumerable<FileInfo> EnumerateLocalPackageFiles(DirectoryInfo packageSource, CancellationToken cancellationToken)
    {
        foreach (var file in packageSource.EnumerateFiles("*.nupkg", SearchOption.AllDirectories))
        {
            cancellationToken.ThrowIfCancellationRequested();
            yield return file;
        }
    }
 
    private static bool PackageHasPolyglotTag(string packageFile, ILogger logger)
    {
        try
        {
            using var archive = ZipFile.OpenRead(packageFile);
            var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase));
            if (nuspecEntry is null)
            {
                return false;
            }
 
            using var stream = nuspecEntry.Open();
            var document = XDocument.Load(stream);
            var tags = document
                .Descendants()
                .FirstOrDefault(element => element.Name.LocalName == "tags")?.Value;
 
            return HasPolyglotTag(tags);
        }
        catch (IOException ex)
        {
            logger.LogDebug(ex, "Failed to read package file '{PackageFile}' while resolving polyglot tag.", packageFile);
            return false;
        }
        catch (InvalidDataException ex)
        {
            logger.LogDebug(ex, "Package file '{PackageFile}' contains invalid data.", packageFile);
            return false;
        }
        catch (System.Xml.XmlException ex)
        {
            logger.LogDebug(ex, "Failed to parse nuspec in package file '{PackageFile}'.", packageFile);
            return false;
        }
    }
 
    private static bool HasPolyglotTag(string? tags)
    {
        // nuspec <tags> are a single space-delimited string, e.g.
        //   <tags>aspire integration hosting redis cache caching polyglot</tags>
        // Match whole tokens so a substring like "polyglotted" wouldn't be treated as the marker.
        return tags is not null && tags
            .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Any(tag => string.Equals(tag, PolyglotTag, StringComparison.OrdinalIgnoreCase));
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        if (PinnedVersion is not null)
        {
            return [new NuGetPackage { Id = packageId, Version = PinnedVersion, Source = SourceDetails }];
        }
 
        var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
 
        using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null;
 
        if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetPackagesAsync(
                workingDirectory: workingDirectory,
                packageId: packageId,
                filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId),
                prerelease: false,
                nugetConfigFile: tempNuGetConfig?.ConfigFile,
                useCache: true, // Enable caching for package channel resolution
                cancellationToken: cancellationToken));
        }
 
        if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetPackagesAsync(
                workingDirectory: workingDirectory,
                packageId: packageId,
                filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId),
                prerelease: true,
                nugetConfigFile: tempNuGetConfig?.ConfigFile,
                useCache: true, // Enable caching for package channel resolution
                cancellationToken: cancellationToken));
        }
 
        var packageResults = await Task.WhenAll(tasks);
 
        var packages = packageResults
            .SelectMany(p => p)
            .DistinctBy(p => $"{p.Id}-{p.Version}");
 
        // In the event that we have no stable packages we fallback to
        // returning prerelease packages. Example a package that is currently
        // in preview (Aspire.Hosting.Docker circa 9.4).
        if (Quality is PackageChannelQuality.Stable && !packages.Any())
        {
            packages = await nuGetPackageCache.GetPackagesAsync(
                workingDirectory: workingDirectory,
                packageId: packageId,
                filter: id => id.Equals(packageId, StringComparisons.NuGetPackageId),
                prerelease: true,
                nugetConfigFile: tempNuGetConfig?.ConfigFile,
                useCache: true, // Enable caching for package channel resolution
                cancellationToken: cancellationToken);
 
            return packages;
        }
 
        // When doing a `dotnet package search` the results may include stable packages even when searching for
        // prerelease packages. This filters out this noise.
        var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch
        {
            { Quality: PackageChannelQuality.Both } => true,
            { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
            { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
            _ => false
        });
 
        return filteredPackages;
    }
 
    public async Task<NuGetPackage?> GetLatestGuestAppHostSdkPackageAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        // Guest AppHost sdk.version resolves to the base Aspire.Hosting package because
        // the managed server restores that package to evaluate and generate the AppHost.
        var packages = await GetPackagesAsync(GuestAppHostSdkPackageId, workingDirectory, cancellationToken);
 
        NuGetPackage? latestPackage = null;
        SemVersion? latestVersion = null;
        foreach (var package in packages)
        {
            if (!SemVersion.TryParse(package.Version, SemVersionStyles.Strict, out var version))
            {
                continue;
            }
 
            if (latestVersion is null || SemVersion.PrecedenceComparer.Compare(version, latestVersion) > 0)
            {
                latestPackage = package;
                latestVersion = version;
            }
        }
 
        return latestPackage;
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken)
    {
        var tasks = new List<Task<IEnumerable<NuGetPackage>>>();
 
        using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null;
 
        if (Quality is PackageChannelQuality.Stable || Quality is PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetPackageVersionsAsync(
                workingDirectory: workingDirectory,
                exactPackageId: packageId,
                prerelease: false,
                nugetConfigFile: tempNuGetConfig?.ConfigFile,
                useCache: true, // Enable caching for package channel resolution
                cancellationToken: cancellationToken));
        }
 
        if (Quality is PackageChannelQuality.Prerelease || Quality is PackageChannelQuality.Both)
        {
            tasks.Add(nuGetPackageCache.GetPackageVersionsAsync(
                workingDirectory: workingDirectory,
                exactPackageId: packageId,
                prerelease: true,
                nugetConfigFile: tempNuGetConfig?.ConfigFile,
                useCache: true, // Enable caching for package channel resolution
                cancellationToken: cancellationToken));
        }
 
        var packageResults = await Task.WhenAll(tasks);
 
        var packages = packageResults
            .SelectMany(p => p)
            .DistinctBy(p => $"{p.Id}-{p.Version}");
 
        // In the event that we have no stable packages we fallback to
        // returning prerelease packages. Example a package that is currently
        // in preview (Aspire.Hosting.Docker circa 9.4).
        if (Quality is PackageChannelQuality.Stable && !packages.Any())
        {
            packages = await nuGetPackageCache.GetPackageVersionsAsync(
                workingDirectory: workingDirectory,
                exactPackageId: packageId,
                prerelease: true,
                nugetConfigFile: tempNuGetConfig?.ConfigFile,
                useCache: true, // Enable caching for package channel resolution
                cancellationToken: cancellationToken);
 
            return packages;
        }
 
        // When doing a `dotnet package search` the results may include stable packages even when searching for
        // prerelease packages. This filters out this noise.
        var filteredPackages = packages.Where(p => new { SemVer = SemVersion.Parse(p.Version), Quality = Quality } switch
        {
            { Quality: PackageChannelQuality.Both } => true,
            { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true,
            { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true,
            _ => false
        });
 
        return filteredPackages;
    }
 
    public PackageChannel CreateScopedChannelForPackage(string packageId)
    {
        return CreateScopedChannelForPackages([packageId]);
    }
 
    public PackageChannel CreateScopedChannelForPackages(IEnumerable<string> packageIds)
    {
        ArgumentNullException.ThrowIfNull(packageIds);
 
        var requestedPackageIds = packageIds
            .Where(packageId => !string.IsNullOrWhiteSpace(packageId))
            .Distinct(StringComparers.NuGetPackageId)
            .ToArray();
 
        if (requestedPackageIds.Length == 0)
        {
            throw new ArgumentException("At least one package ID must be provided.", nameof(packageIds));
        }
 
        var mappings = Mappings;
        if (!VersionHelper.IsLocalBuildChannel(Name) || Type is not PackageChannelType.Explicit || mappings is not { Length: > 0 })
        {
            return this;
        }
 
        var scopedMappings = mappings
            .SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds, logger))
            .ToArray();
 
        return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, _features, logger, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion, _currentCliVersion, validateTemplatePackageMetadataPrefetching);
    }
 
    private static IEnumerable<PackageMapping> CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection<string> packageIds, ILogger logger)
    {
        if (!IsScopedAspireMapping(mapping))
        {
            yield return mapping;
            yield break;
        }
 
        var scopedPackageIds = GetScopedPackageIds(mapping.Source, packageIds, logger);
 
        foreach (var scopedPackageId in scopedPackageIds)
        {
            yield return new PackageMapping(scopedPackageId, mapping.Source);
        }
    }
 
    private static HashSet<string> GetScopedPackageIds(string source, IEnumerable<string> packageIds, ILogger logger)
    {
        var resolvedPackageIds = new HashSet<string>(packageIds, StringComparers.NuGetPackageId);
 
        var normalizedSource = PackageSourceOverrideMappings.GetNormalizedLocalDirectory(source);
        if (normalizedSource is null || !Directory.Exists(normalizedSource))
        {
            return resolvedPackageIds;
        }
 
        var packageFiles = Directory.EnumerateFiles(normalizedSource, "*.nupkg", SearchOption.AllDirectories)
            .Select(GetPackageFileMetadata)
            .OfType<PackageFileMetadata>()
            .GroupBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId)
            .ToDictionary(
                group => group.Key,
                group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First(),
                StringComparers.NuGetPackageId);
 
        var packagesToProcess = new Queue<string>(resolvedPackageIds);
 
        while (packagesToProcess.Count > 0)
        {
            var currentPackageId = packagesToProcess.Dequeue();
            if (!packageFiles.TryGetValue(currentPackageId, out var metadata))
            {
                continue;
            }
 
            foreach (var dependencyPackageId in GetDependencyPackageIds(metadata.PackageFilePath, logger))
            {
                if (packageFiles.ContainsKey(dependencyPackageId) && resolvedPackageIds.Add(dependencyPackageId))
                {
                    packagesToProcess.Enqueue(dependencyPackageId);
                }
            }
        }
 
        return resolvedPackageIds;
    }
 
    private static PackageFileMetadata? GetPackageFileMetadata(string packageFile)
    {
        var packageIdentity = TryGetPackageIdentityFromPackageFileName(packageFile);
        if (packageIdentity is null)
        {
            return null;
        }
 
        return new PackageFileMetadata(packageIdentity.Value.PackageId, packageIdentity.Value.Version, packageFile);
    }
 
    private static IEnumerable<string> GetDependencyPackageIds(string packageFile, ILogger logger)
    {
        try
        {
            using var archive = ZipFile.OpenRead(packageFile);
            var nuspecEntry = archive.Entries.FirstOrDefault(entry => entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase));
            if (nuspecEntry is null)
            {
                return [];
            }
 
            using var stream = nuspecEntry.Open();
            var document = XDocument.Load(stream);
            return document
                .Descendants()
                .Where(element => element.Name.LocalName == "dependency")
                .Select(element => element.Attribute("id")?.Value)
                .Where(id => !string.IsNullOrWhiteSpace(id))
                .Distinct(StringComparers.NuGetPackageId)
                .Cast<string>()
                .ToArray();
        }
        catch (IOException ex)
        {
            logger.LogDebug(ex, "Failed to read package file '{PackageFile}' while resolving dependencies.", packageFile);
            return [];
        }
        catch (InvalidDataException ex)
        {
            logger.LogDebug(ex, "Package file '{PackageFile}' contains invalid data.", packageFile);
            return [];
        }
        catch (System.Xml.XmlException ex)
        {
            logger.LogDebug(ex, "Failed to parse nuspec in package file '{PackageFile}'.", packageFile);
            return [];
        }
    }
 
    private static (string PackageId, SemVersion Version)? TryGetPackageIdentityFromPackageFileName(string packageFile)
    {
        var packageFileName = Path.GetFileNameWithoutExtension(packageFile);
        if (string.IsNullOrWhiteSpace(packageFileName))
        {
            return null;
        }
 
        var separatorIndex = packageFileName.IndexOf('.');
        while (separatorIndex >= 0 && separatorIndex < packageFileName.Length - 1)
        {
            var versionCandidate = packageFileName[(separatorIndex + 1)..];
            if (SemVersion.TryParse(versionCandidate, SemVersionStyles.Strict, out var version))
            {
                return (packageFileName[..separatorIndex], version);
            }
 
            separatorIndex = packageFileName.IndexOf('.', separatorIndex + 1);
        }
 
        return null;
    }
 
    private readonly record struct PackageFileMetadata(string PackageId, SemVersion Version, string PackageFilePath);
 
    private static bool IsScopedAspireMapping(PackageMapping mapping)
    {
        return mapping.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase) &&
            !string.Equals(mapping.PackageFilter, PackageMapping.AllPackages, StringComparison.Ordinal);
    }
 
    public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, IFeatures features, ILogger logger, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, string? currentCliVersion = null, Action? validateTemplatePackageMetadataPrefetching = null)
    {
        return new PackageChannel(name, quality, mappings, nuGetPackageCache, features, logger, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion, currentCliVersion, validateTemplatePackageMetadataPrefetching);
    }
 
    public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache, IFeatures features, ILogger logger, string? currentCliVersion = null, Action? validateTemplatePackageMetadataPrefetching = null)
    {
        // The reason that PackageChannelQuality.Both is because there are situations like
        // in community toolkit where there is a newer beta version available for a package
        // in the case of implicit feeds we want to be able to show that, along side the stable
        // version. Not really an issue for template selection though (unless we start allowing)
        // for broader templating options.
        return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache, features, logger, currentCliVersion: currentCliVersion, validateTemplatePackageMetadataPrefetching: validateTemplatePackageMetadataPrefetching);
    }
}