File: Commands\IntegrationPackageSearchService.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.Text.Json;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Semver;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.Commands;
 
internal sealed class IntegrationPackageSearchService(
    IPackagingService packagingService,
    IProjectLocator projectLocator,
    IInteractionService interactionService,
    CliExecutionContext executionContext,
    IAppHostProjectFactory projectFactory)
{
    private const double FuzzyMatchThreshold = 0.3;
 
    public async Task<IEnumerable<(NuGetPackage Package, PackageChannel Channel)>> GetIntegrationPackagesWithChannelsAsync(DirectoryInfo workingDirectory, string? configuredChannel, CancellationToken cancellationToken)
    {
        var channels = await GetSearchChannelsAsync(configuredChannel, cancellationToken);
 
        var packages = new List<(NuGetPackage Package, PackageChannel Channel)>();
        var packagesLock = new object();
 
        await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
        {
            var integrationPackages = await channel.GetIntegrationPackagesAsync(
                workingDirectory: workingDirectory,
                cancellationToken: ct);
            lock (packagesLock)
            {
                packages.AddRange(integrationPackages.Select(p => (p, channel)));
            }
        });
 
        return packages;
    }
 
    /// <summary>
    /// Searches the same channels as <see cref="GetIntegrationPackagesWithChannelsAsync"/> and, in the same
    /// pass, resolves the union of integration package IDs that are marked polyglot-compatible (carry the
    /// <c>polyglot</c> NuGet tag). Used by <c>aspire add</c> and integration discovery to hide integrations a
    /// non-C# AppHost cannot consume unless <c>--all</c> is passed.
    /// </summary>
    /// <remarks>
    /// Resolving both lists together avoids re-resolving the channel set and lets each channel's integration
    /// search and its <c>tags:polyglot</c> lookup run concurrently, rather than as two serial discovery passes.
    /// </remarks>
    public async Task<(IReadOnlyList<(NuGetPackage Package, PackageChannel Channel)> Packages, IReadOnlySet<string> PolyglotCompatibleIds)> GetIntegrationPackagesWithPolyglotCompatibilityAsync(DirectoryInfo workingDirectory, string? configuredChannel, CancellationToken cancellationToken)
    {
        var channels = await GetSearchChannelsAsync(configuredChannel, cancellationToken);
 
        var packages = new List<(NuGetPackage Package, PackageChannel Channel)>();
        var polyglotIds = new HashSet<string>(StringComparers.NuGetPackageId);
        var gate = new object();
 
        await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) =>
        {
            // Resolve the integration list and the polyglot allow-list for this channel concurrently so the
            // compatibility lookup runs alongside the integration search instead of as a second serial pass.
            var integrationPackagesTask = channel.GetIntegrationPackagesAsync(workingDirectory: workingDirectory, cancellationToken: ct);
            var polyglotIdsTask = channel.GetPolyglotCompatiblePackageIdsAsync(workingDirectory: workingDirectory, cancellationToken: ct);
            await Task.WhenAll(integrationPackagesTask, polyglotIdsTask);
 
            lock (gate)
            {
                packages.AddRange(integrationPackagesTask.Result.Select(p => (p, channel)));
                polyglotIds.UnionWith(polyglotIdsTask.Result);
            }
        });
 
        return (packages, polyglotIds);
    }
 
    private async Task<IEnumerable<PackageChannel>> GetSearchChannelsAsync(string? configuredChannel, CancellationToken cancellationToken)
    {
        // `configuredChannel` (from a polyglot apphost's aspire.config.json) is forwarded
        // as `requestedChannelName` so PackagingService can synthesize the staging channel
        // for out-of-tree apphosts whose directory wasn't picked up by
        // ConfigurationHelper.RegisterSettingsFiles.
        var allChannels = await packagingService.GetChannelsAsync(cancellationToken, configuredChannel);
 
        // Channels included in the search:
        //   * Implicit channel: always.
        //   * Explicit channels (stable, daily, staging, custom): when PR hives exist OR the
        //     apphost has pinned an explicit channel via aspire.config.json.
        //
        // What this method MUST NOT do is narrow the explicit channel set to just the pinned
        // channel. That was the root cause of https://github.com/microsoft/aspire/issues/17724
        // and https://github.com/microsoft/aspire/issues/17725: a TS apphost pinned to a
        // Quality.Stable channel ended up with prerelease=false queries everywhere and
        // prerelease-only packages (e.g. Aspire.Hosting.Foundry) became invisible. The implicit
        // channel (Quality.Both) must always participate so prerelease packages are reachable
        // even when the explicit pin is Stable-quality.
        // An ASPIRE_CLI_PACKAGES / sidecar `packages` override deliberately points Aspire.*
        // resolution at a local directory (used to emulate a released/staging build from locally
        // built packages). Treat it like a hive so the synthesized local channel — named after the
        // emulated identity (stable/daily/staging), not a local-build name — participates in the
        // search instead of being filtered out, which would silently fall back to nuget.org.
        var hasHives = executionContext.GetHiveCount() > 0 || executionContext.IdentityPackagesDirectory is not null;
        return hasHives || !string.IsNullOrEmpty(configuredChannel)
            ? allChannels
            : allChannels.Where(c => c.Type is PackageChannelType.Implicit);
    }
 
    public async Task<(DirectoryInfo WorkingDirectory, string? ConfiguredChannel, string? LanguageId, int? ExitCode)> GetPackageSearchContextAsync(FileInfo? passedAppHostProjectFile, CancellationToken cancellationToken)
    {
        FileInfo? appHostProjectFile;
        if (passedAppHostProjectFile is not null)
        {
            var searchResult = await projectLocator.UseOrFindAppHostProjectFileAsync(
                passedAppHostProjectFile,
                MultipleAppHostProjectsFoundBehavior.Throw,
                createSettingsFile: false,
                cancellationToken);
 
            appHostProjectFile = searchResult.SelectedProjectFile;
        }
        else
        {
            appHostProjectFile = await projectLocator.GetAppHostFromSettingsAsync(cancellationToken);
        }
 
        if (appHostProjectFile is null)
        {
            return (executionContext.WorkingDirectory, ConfiguredChannel: null, LanguageId: null, ExitCode: null);
        }
 
        var project = projectFactory.GetProject(appHostProjectFile);
        var (configuredChannel, exitCode) = GetConfiguredChannel(appHostProjectFile, project);
        return (appHostProjectFile.Directory!, configuredChannel, project.LanguageId, exitCode);
    }
 
    public (string? ConfiguredChannel, int? ExitCode) GetConfiguredChannel(FileInfo appHostProjectFile, IAppHostProject project)
    {
        // For non-.NET projects, read the channel from the local Aspire configuration if available.
        // Unlike .NET projects which have a nuget.config, polyglot apphosts persist the channel
        // in aspire.config.json (or the legacy settings.json during migration).
        if (project.LanguageId == KnownLanguageId.CSharp)
        {
            return (ConfiguredChannel: null, ExitCode: null);
        }
 
        var appHostDirectory = appHostProjectFile.Directory!.FullName;
        var isProjectReferenceMode = project.IsUsingProjectReferences(appHostProjectFile);
        if (isProjectReferenceMode)
        {
            return (ConfiguredChannel: null, ExitCode: null);
        }
 
        // TODO: Remove legacy AspireJsonConfiguration fallback once confident most users
        // have migrated. Tracked by https://github.com/microsoft/aspire/issues/15239
        try
        {
            return (AspireConfigFile.Load(appHostDirectory)?.Channel
                ?? AspireJsonConfiguration.Load(appHostDirectory)?.Channel, ExitCode: null);
        }
        catch (JsonException ex)
        {
            interactionService.DisplayError(ex.Message);
            return (ConfiguredChannel: null, ExitCode: CliExitCodes.FailedToLoadConfiguration);
        }
    }
 
    public static (string FriendlyName, NuGetPackage Package, PackageChannel Channel) GenerateFriendlyName((NuGetPackage Package, PackageChannel Channel) packageWithChannel)
    {
        var packageId = packageWithChannel.Package.Id.Replace("Aspire.Hosting.", "", StringComparison.OrdinalIgnoreCase);
        var friendlyName = packageId.Replace('.', '-').ToLowerInvariant();
 
        return (friendlyName, packageWithChannel.Package, packageWithChannel.Channel);
    }
 
    public static IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel, double SearchScore)> GetIntegrationSearchMatches(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, string searchTerm)
    {
        return packages
            .Select(p => (p.FriendlyName, p.Package, p.Channel, SearchScore: GetIntegrationSearchScore(searchTerm, p)))
            .Where(p => p.SearchScore > FuzzyMatchThreshold)
            .OrderByDescending(p => p.SearchScore)
            .ThenByDescending(p => p.FriendlyName, new CommunityToolkitFirstComparer());
    }
 
    public static (string FriendlyName, NuGetPackage Package, PackageChannel Channel, double SearchScore) SelectPreferredIntegrationPackage(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel, double SearchScore)> packages)
    {
        return packages
            .OrderByDescending(p => p.Channel.Type is PackageChannelType.Implicit)
            .ThenByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer)
            .First();
    }
 
    private static double GetIntegrationSearchScore(string searchTerm, (string FriendlyName, NuGetPackage Package, PackageChannel Channel) package)
    {
        return Math.Max(
            StringUtils.CalculateFuzzyScore(searchTerm, package.FriendlyName),
            StringUtils.CalculateFuzzyScore(searchTerm, package.Package.Id));
    }
}