File: Commands\IntegrationSearchCommand.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.Collections.Immutable;
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Spectre.Console;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.Commands;
 
internal abstract class IntegrationDiscoveryCommand : BaseCommand
{
    private readonly IntegrationPackageSearchService _integrationPackageSearchService;
    private readonly IFeatures _features;
    private readonly OptionWithLegacy<FileInfo?> _appHostOption = new("--apphost", "--project", AddCommandStrings.IntegrationSearchAppHostOptionDescription);
    private readonly Option<OutputFormat> _formatOption = new("--format")
    {
        Description = AddCommandStrings.FormatOptionDescription
    };
    private readonly Option<bool> _allOption = new("--all")
    {
        Description = AddCommandStrings.AllArgumentDescription
    };
 
    protected IntegrationDiscoveryCommand(
        string name,
        string description,
        IntegrationPackageSearchService integrationPackageSearchService,
        CommonCommandServices services)
        : base(name, description, services)
    {
        _integrationPackageSearchService = integrationPackageSearchService;
        _features = services.Features;
 
        Options.Add(_appHostOption);
        Options.Add(_formatOption);
        Options.Add(_allOption);
    }
 
    protected abstract string? GetSearchTerm(ParseResult parseResult);
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(Name);
 
        try
        {
            var searchTerm = GetSearchTerm(parseResult);
            var passedAppHostProjectFile = parseResult.GetValue(_appHostOption);
            var format = parseResult.GetValue(_formatOption);
            var includeAllIntegrations = parseResult.GetValue(_allOption);
 
            var (workingDirectory, configuredChannel, languageId, contextExitCode) = await _integrationPackageSearchService.GetPackageSearchContextAsync(passedAppHostProjectFile, cancellationToken);
            if (contextExitCode is { } exitCode)
            {
                return CommandResult.FromExitCode(exitCode);
            }
 
            // Match `aspire add`: a non-C# (polyglot) AppHost can only consume integrations with ATS
            // export coverage (the `polyglot` NuGet tag), so list/search hide the rest unless --all is
            // passed. The language is only known when an AppHost was resolved; otherwise show everything.
            //
            // The filter is opt-in and off by default for the same reason as `aspire add`: no remote feed
            // answers a `tags:polyglot` search usefully (see PackageChannel.PolyglotTagSearchTerm), so the
            // allow-list comes back empty and the filter fails closed, hiding every integration.
            // See https://github.com/microsoft/aspire/issues/19161.
            var applyPolyglotFilter = _features.IsFeatureEnabled(KnownFeatures.PolyglotIntegrationFilterEnabled, false)
                && languageId is not null
                && languageId != KnownLanguageId.CSharp
                && !includeAllIntegrations;
 
            (NuGetPackage Package, PackageChannel Channel)[] packagesWithChannels;
            IReadOnlySet<string> polyglotCompatibleIds = ImmutableHashSet<string>.Empty;
            if (applyPolyglotFilter)
            {
                // Resolve the integration list and the polyglot allow-list in a single discovery pass.
                var (discoveredPackages, discoveredPolyglotIds) = await InteractionService.ShowStatusAsync(
                    AddCommandStrings.SearchingForAspirePackages,
                    async () => await _integrationPackageSearchService.GetIntegrationPackagesWithPolyglotCompatibilityAsync(workingDirectory, configuredChannel, cancellationToken));
                packagesWithChannels = discoveredPackages.ToArray();
                polyglotCompatibleIds = discoveredPolyglotIds;
            }
            else
            {
                packagesWithChannels = (await InteractionService.ShowStatusAsync(
                    AddCommandStrings.SearchingForAspirePackages,
                    async () => await _integrationPackageSearchService.GetIntegrationPackagesWithChannelsAsync(workingDirectory, configuredChannel, cancellationToken)))
                    .ToArray();
            }
 
            var packagesWithShortName = packagesWithChannels
                .Select(IntegrationPackageSearchService.GenerateFriendlyName)
                .OrderBy(p => p.FriendlyName, new CommunityToolkitFirstComparer())
                .ToArray();
 
            var polyglotFilterRemovedAllIntegrations = false;
            if (applyPolyglotFilter)
            {
                var compatiblePackagesWithShortName = packagesWithShortName
                    .Where(p => polyglotCompatibleIds.Contains(p.Package.Id))
                    .ToArray();
                var hiddenIntegrationCount = packagesWithShortName.Length - compatiblePackagesWithShortName.Length;
                packagesWithShortName = compatiblePackagesWithShortName;
 
                // Distinguish "the polyglot filter removed every integration" from "a search term matched
                // nothing". Only the former should report NoPolyglotCompatibleIntegrationsFound; when
                // compatible integrations exist but none match the query, DisplayIntegrationResults must
                // fall through to NoIntegrationPackagesMatchedSearchTerm instead of falsely claiming the
                // AppHost language has no compatible integrations.
                polyglotFilterRemovedAllIntegrations = hiddenIntegrationCount > 0 && packagesWithShortName.Length == 0;
 
                // Mirror `aspire add`: tell the user when the polyglot filter removed integrations and how to
                // reveal them. Only show this when results remain; when the filter removes every integration,
                // DisplayIntegrationResults reports NoPolyglotCompatibleIntegrationsFound (which also points at
                // --all), so suppressing the count here avoids a redundant pair of --all hints. Skip for JSON
                // so the machine-readable payload on stdout stays clean.
                if (hiddenIntegrationCount > 0 && packagesWithShortName.Length > 0 && format is not OutputFormat.Json)
                {
                    InteractionService.DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.PolyglotIntegrationsHidden, hiddenIntegrationCount));
                }
            }
 
            return CommandResult.FromExitCode(DisplayIntegrationResults(packagesWithShortName, searchTerm, format, polyglotFilterRemovedAllIntegrations));
        }
        catch (ProjectLocatorException ex)
        {
            return HandleProjectLocatorException(ex, InteractionService, Telemetry);
        }
        catch (OperationCanceledException)
        {
            return CommandResult.Cancelled();
        }
        catch (Exception ex)
        {
            var errorMessage = string.Format(CultureInfo.CurrentCulture, AddCommandStrings.ErrorOccurredWhileSearchingIntegrations, ex.Message);
            Telemetry.RecordError(errorMessage, ex);
            return CommandResult.Failure(CliExitCodes.FailedToSearchIntegrations, errorMessage);
        }
    }
 
    private int DisplayIntegrationResults(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, string? searchTerm, OutputFormat format, bool polyglotFilterRemovedAllIntegrations)
    {
        var matches = (searchTerm is null
            ? packages.Select(p => (p.FriendlyName, p.Package, p.Channel, SearchScore: 0.0))
            : IntegrationPackageSearchService.GetIntegrationSearchMatches(packages, searchTerm))
            .GroupBy(p => p.Package.Id)
            .Select(IntegrationPackageSearchService.SelectPreferredIntegrationPackage);
 
        var orderedMatches = searchTerm is null
            ? matches.OrderBy(p => p.FriendlyName, new CommunityToolkitFirstComparer()).ThenBy(p => p.Package.Id, StringComparer.OrdinalIgnoreCase)
            : matches;
 
        var results = orderedMatches
            .Select(p => new IntegrationSearchResult
            {
                Name = p.FriendlyName,
                Package = p.Package.Id,
                Version = p.Package.Version
            })
            .ToArray();
 
        if (format is OutputFormat.Json)
        {
            var json = JsonSerializer.Serialize(results, JsonSourceGenerationContext.RelaxedEscaping.IntegrationSearchResultArray);
            InteractionService.DisplayRawText(json, ConsoleOutput.Standard);
            return CliExitCodes.Success;
        }
 
        if (results.Length == 0)
        {
            if (polyglotFilterRemovedAllIntegrations)
            {
                // The polyglot filter removed every result, so point the user at --all rather than
                // implying no integrations exist at all. A search-term mismatch (compatible integrations
                // exist but none match the query) does not reach this branch; it falls through below.
                InteractionService.DisplayError(AddCommandStrings.NoPolyglotCompatibleIntegrationsFound);
            }
            else if (searchTerm is not null)
            {
                InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.NoIntegrationPackagesMatchedSearchTerm, searchTerm));
            }
            else
            {
                InteractionService.DisplayError(AddCommandStrings.NoPackagesFound);
            }
 
            return CliExitCodes.Success;
        }
 
        if (searchTerm is not null)
        {
            InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.FoundIntegrationPackagesMatchingSearchTerm, results.Length, searchTerm));
        }
        else
        {
            InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.FoundIntegrationPackages, results.Length));
        }
 
        var table = new Table();
        table.AddBoldColumn(AddCommandStrings.HeaderName);
        table.AddBoldColumn(AddCommandStrings.HeaderPackage);
        table.AddBoldColumn(AddCommandStrings.HeaderVersion);
 
        foreach (var result in results)
        {
            table.AddRow(
                Markup.Escape(result.Name),
                Markup.Escape(result.Package),
                Markup.Escape(result.Version));
        }
 
        InteractionService.DisplayRenderable(table);
        return CliExitCodes.Success;
    }
}
 
internal sealed class IntegrationListCommand : IntegrationDiscoveryCommand
{
    public IntegrationListCommand(
        IntegrationPackageSearchService integrationPackageSearchService,
        CommonCommandServices services)
        : base("list", AddCommandStrings.IntegrationListDescription, integrationPackageSearchService, services)
    {
    }
 
    protected override string? GetSearchTerm(ParseResult parseResult) => null;
}
 
internal sealed class IntegrationSearchCommand : IntegrationDiscoveryCommand
{
    private readonly Argument<string> _queryArgument = new("query")
    {
        Description = AddCommandStrings.IntegrationSearchQueryArgumentDescription,
        Arity = ArgumentArity.ExactlyOne
    };
 
    public IntegrationSearchCommand(
        IntegrationPackageSearchService integrationPackageSearchService,
        CommonCommandServices services)
        : base("search", AddCommandStrings.IntegrationSearchDescription, integrationPackageSearchService, services)
    {
        Arguments.Add(_queryArgument);
    }
 
    protected override string? GetSearchTerm(ParseResult parseResult) => parseResult.GetValue(_queryArgument);
}
 
// `aspire integration list --format json` and `aspire integration search --format json`
// use this shape; keep docs/specs/cli-output-formats.md in sync when changing it.
internal sealed class IntegrationSearchResult
{
    public required string Name { get; init; }
 
    public required string Package { get; init; }
 
    public required string Version { get; init; }
}