File: TemplateSearch\CliTemplateSearchCoordinator.cs
Web Access
Project: ..\..\..\src\Cli\Microsoft.TemplateEngine.Cli\Microsoft.TemplateEngine.Cli.csproj (Microsoft.TemplateEngine.Cli)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
using Microsoft.TemplateEngine.Abstractions;
using Microsoft.TemplateEngine.Abstractions.TemplatePackage;
using Microsoft.TemplateEngine.Cli.Commands;
using Microsoft.TemplateEngine.Cli.NuGet;
using Microsoft.TemplateEngine.Cli.TabularOutput;
using Microsoft.TemplateEngine.Edge.Settings;
using Microsoft.TemplateSearch.Common;
using Microsoft.TemplateSearch.Common.Abstractions;
using static Microsoft.TemplateEngine.Cli.NuGet.NugetApiManager;
 
namespace Microsoft.TemplateEngine.Cli.TemplateSearch
{
    internal static class CliTemplateSearchCoordinator
    {
        /// <summary>
        /// Executes searching for the templates in configured remote sources.
        /// Performs validation for the commands, search for the templates in configured remote source, displays the results in table format.
        /// </summary>
        /// <param name="environmentSettings">environment settings.</param>
        /// <param name="templatePackageManager"></param>
        /// <param name="commandArgs">new command data.</param>
        /// <param name="defaultLanguage">default language for the host.</param>
        /// <param name="cancellationToken"></param>
        /// <returns><see cref="NewCommandStatus.Success"/> when the templates were found and displayed;
        /// <see cref="NewCommandStatus.MissingRequiredOption"/> when the command validation fails;
        /// <see cref="NewCommandStatus.NotFound"/> when no templates found based on the filter criteria.
        /// </returns>
        internal static async Task<NewCommandStatus> SearchForTemplateMatchesAsync(
            IEngineEnvironmentSettings environmentSettings,
            TemplatePackageManager templatePackageManager,
            SearchCommandArgs commandArgs,
            string? defaultLanguage,
            CancellationToken cancellationToken)
        {
            if (!ValidateCommandInput(commandArgs))
            {
                return NewCommandStatus.MissingRequiredOption;
            }
 
            Reporter.Output.WriteLine(LocalizableStrings.CliTemplateSearchCoordinator_Info_SearchInProgress);
            IReadOnlyList<IManagedTemplatePackage> templatePackages =
                await templatePackageManager.GetManagedTemplatePackagesAsync(force: false, cancellationToken: cancellationToken).ConfigureAwait(false);
 
            TemplateSearchCoordinator searchCoordinator = CliTemplateSearchCoordinatorFactory.CreateCliTemplateSearchCoordinator(environmentSettings);
            CliSearchFiltersFactory searchFiltersFactory = new(templatePackages);
 
            IReadOnlyList<SearchResult>? searchResults = await searchCoordinator.SearchAsync(
                searchFiltersFactory.GetPackFilter(commandArgs),
                CliSearchFiltersFactory.GetMatchingTemplatesFilter(commandArgs),
                cancellationToken).ConfigureAwait(false);
 
            if (!searchResults.Any())
            {
                Reporter.Error.WriteLine(LocalizableStrings.CliTemplateSearchCoordinator_Error_NoSources.Bold().Red());
                return NewCommandStatus.NotFound;
            }
 
            foreach (SearchResult result in searchResults)
            {
                if (!result.Success)
                {
                    Reporter.Error.WriteLine(LocalizableStrings.CliTemplateSearchCoordinator_Info_MatchesFromSource, result.Provider.Factory.DisplayName);
                    Reporter.Error.WriteLine(string.Format(LocalizableStrings.CliTemplateSearchCoordinator_Error_SearchFailure, result.ErrorMessage).Red().Bold());
                    continue;
                }
 
                Reporter.Output.WriteLine(LocalizableStrings.CliTemplateSearchCoordinator_Info_MatchesFromSource, result.Provider.Factory.DisplayName);
                if (result.SearchHits.Any())
                {
                    DisplayResultsForPack(result.SearchHits, environmentSettings, commandArgs, defaultLanguage);
                }
                else
                {
                    //TODO: implement it for template options matching
                    //IReadOnlyDictionary<string, string?>? appliedParameterMatches = TemplateCommandInput.GetTemplateParametersFromCommand(commandArgs);
                    // No templates found matching the following input parameter(s): {0}.
                    Reporter.Error.WriteLine(
                        string.Format(
                            LocalizableStrings.Generic_Info_NoMatchingTemplates,
                            GetInputParametersString(commandArgs))
                        .Bold().Red());
                }
            }
            Reporter.Output.WriteLine();
            if (searchResults.Where(r => r.Success).SelectMany(r => r.SearchHits).Any())
            {
                string packageIdToShow = EvaluatePackageToShow(searchResults);
                Reporter.Output.WriteLine(LocalizableStrings.CliTemplateSearchCoordinator_Info_InstallHelp);
                Reporter.Output.WriteCommand(
                 Example
                     .For<NewCommand>(commandArgs.ParseResult)
                     .WithSubcommand<InstallCommand>()
                     .WithArgument(BaseInstallCommand.NameArgument));
                Reporter.Output.WriteLine(LocalizableStrings.Generic_ExampleHeader);
                Reporter.Output.WriteCommand(
                   Example
                       .For<NewCommand>(commandArgs.ParseResult)
                       .WithSubcommand<InstallCommand>()
                       .WithArgument(BaseInstallCommand.NameArgument, packageIdToShow));
                return NewCommandStatus.Success;
            }
            return NewCommandStatus.NotFound;
        }
 
        internal static async Task<(NugetPackageMetadata?, IReadOnlyList<ITemplateInfo>)> SearchForPackageDetailsAsync(
            IEngineEnvironmentSettings environmentSettings,
            NugetApiManager nugetApiManager,
            string packageIdentifier,
            string? version,
            CancellationToken cancellationToken)
        {
            var nugetPackage = await nugetApiManager.GetPackageMetadataAsync(packageIdentifier, version, cancellationToken: cancellationToken).ConfigureAwait(false);
            if (nugetPackage != null)
            {
                var packages = await SearchForPackageTemplatesAsync(
                    environmentSettings,
                    packageIdentifier,
                    version,
                    cancellationToken).ConfigureAwait(false);
                return (nugetPackage, packages);
            }
 
            return (null, new List<ITemplateInfo>());
        }
 
        internal static async Task<IReadOnlyList<ITemplateInfo>> SearchForPackageTemplatesAsync(
            IEngineEnvironmentSettings environmentSettings,
            string packageIdentifier,
            string? version,
            CancellationToken cancellationToken)
        {
            var searchResults = await CliTemplateSearchCoordinatorFactory
                    .CreateCliTemplateSearchCoordinator(environmentSettings)
                    .SearchAsync(
                        f => f.Name == packageIdentifier && (string.IsNullOrEmpty(version) || f.Version == version),
                        t => t.Templates,
                        cancellationToken).ConfigureAwait(false);
 
            if (searchResults.Any() && searchResults[0].SearchHits.Any())
            {
                return searchResults[0].SearchHits[0].MatchedTemplates;
            }
 
            return new List<ITemplateInfo>();
        }
 
        private static string EvaluatePackageToShow(IReadOnlyList<SearchResult> searchResults)
        {
            var microsoftAuthoredPackages = searchResults
                .SelectMany(r => r.SearchHits)
                .Where(hit => hit.PackageInfo.Name.StartsWith("Microsoft", StringComparison.OrdinalIgnoreCase)
                                && hit.MatchedTemplates.Any(t => t.Author == "Microsoft"))
                .OrderByDescending(hit => hit.PackageInfo.TotalDownloads);
 
            if (microsoftAuthoredPackages.Any())
            {
                return microsoftAuthoredPackages.First().PackageInfo.Name;
            }
            else
            {
                return searchResults
                        .SelectMany(r => r.SearchHits)
                        .OrderByDescending(hit => hit.PackageInfo.TotalDownloads)
                        .First().PackageInfo.Name;
            }
        }
 
        private static void DisplayResultsForPack(
            IReadOnlyList<(ITemplatePackageInfo PackageInfo, IReadOnlyList<ITemplateInfo> MatchedTemplates)> results,
            IEngineEnvironmentSettings environmentSettings,
            SearchCommandArgs commandArgs,
            string? defaultLanguage)
        {
            //TODO: implement it for template options matching
            //IReadOnlyDictionary<string, string?>? appliedParameterMatches = TemplateCommandInput.GetTemplateParametersFromCommand(commandArgs);
            Reporter.Output.WriteLine(LocalizableStrings.TemplatesFoundMatchingInputParameters, GetInputParametersString(commandArgs));
            Reporter.Output.WriteLine();
            IReadOnlyCollection<SearchResultTableRow> data = GetSearchResultsForDisplay(results, commandArgs.Language, defaultLanguage, environmentSettings.Environment);
 
            TabularOutput<SearchResultTableRow> formatter =
               TabularOutput.TabularOutput
                    .For(
                        new TabularOutputSettings(environmentSettings.Environment, commandArgs),
                        data
                          .OrderByDescending(d => d.TotalDownloads, SearchResultTableRow.TotalDownloadsComparer)
                          .ThenBy(d => d.TemplateGroupInfo.Name, StringComparer.CurrentCultureIgnoreCase))
                    .DefineColumn(r => r.TemplateGroupInfo.Name, out object? nameColumn, LocalizableStrings.ColumnNameTemplateName, showAlways: true, shrinkIfNeeded: true, minWidth: 15)
                    .DefineColumn(r => r.TemplateGroupInfo.ShortNames, LocalizableStrings.ColumnNameShortName, showAlways: true)
                    .DefineColumn(r => r.TemplateGroupInfo.Author, LocalizableStrings.ColumnNameAuthor, TabularOutputSettings.ColumnNames.Author, defaultColumn: false, shrinkIfNeeded: true, minWidth: 12)
                    .DefineColumn(r => r.TemplateGroupInfo.Languages, LocalizableStrings.ColumnNameLanguage, TabularOutputSettings.ColumnNames.Language, defaultColumn: true)
                    .DefineColumn(r => r.TemplateGroupInfo.Type, LocalizableStrings.ColumnNameType, TabularOutputSettings.ColumnNames.Type, defaultColumn: false)
                    .DefineColumn(r => r.TemplateGroupInfo.Classifications, LocalizableStrings.ColumnNameTags, TabularOutputSettings.ColumnNames.Tags, defaultColumn: false, shrinkIfNeeded: true, minWidth: 10)
                    .DefineColumn(r => GetPackageInfo(r.PackageName, r.PackageOwners), out object? packageColumn, LocalizableStrings.ColumnNamePackageNameAndOwners, showAlways: true)
                    .DefineColumn(r => GetReservedMark(r.Reserved), LocalizableStrings.ColumnNameTrusted, showAlways: true, textAlign: TextAlign.Center)
                    .DefineColumn(r => r.PrintableTotalDownloads, out object? downloadsColumn, LocalizableStrings.ColumnNameTotalDownloads, showAlways: true, textAlign: TextAlign.Center);
 
            Reporter.Output.WriteLine(formatter.Layout());
        }
 
        private static string GetReservedMark(bool reserved) => reserved ? "✓" : string.Empty;
 
        private static string GetPackageInfo(string packageName, string packageOwners)
        {
            // take max number of package id symbols https://learn.microsoft.com/en-us/nuget/reference/nuspec because this value has to be fully displayed
            var maxColumnWidth = 128;
            var packageIdLength = packageName.Length;
 
            var formattedOutput = packageName;
            var maxPackageOwnerLength = maxColumnWidth - packageIdLength;
 
            if (maxPackageOwnerLength > 0)
            {
                formattedOutput = packageOwners.Length > maxPackageOwnerLength
                    ? $"{packageName} / {packageOwners.Substring(0, maxPackageOwnerLength)}"
                    : $"{packageName} / {packageOwners}";
            }
 
            return formattedOutput;
        }
 
        private static IReadOnlyCollection<SearchResultTableRow> GetSearchResultsForDisplay(
            IReadOnlyList<(ITemplatePackageInfo PackageInfo, IReadOnlyList<ITemplateInfo> MatchedTemplates)> results,
            string? language,
            string? defaultLanguage,
            IEnvironment environment)
        {
            List<SearchResultTableRow> templateGroupsForDisplay = new();
 
            foreach (var packSearchResult in results)
            {
                var templateGroupsForPack = TemplateGroupDisplay.GetTemplateGroupsForListDisplay(packSearchResult.MatchedTemplates, language, defaultLanguage, environment);
                templateGroupsForDisplay.AddRange(templateGroupsForPack.Select(t =>
                new SearchResultTableRow(
                    t,
                    packSearchResult.PackageInfo.Name,
                    string.Join(", ", packSearchResult.PackageInfo.Owners),
                    packSearchResult.PackageInfo.Reserved,
                    packSearchResult.PackageInfo.TotalDownloads)));
            }
 
            return templateGroupsForDisplay;
        }
 
        private static bool ValidateCommandInput(SearchCommandArgs commandArgs)
        {
            if (string.IsNullOrWhiteSpace(commandArgs.SearchNameCriteria) && !commandArgs.AppliedFilters.Any())
            //TODO: implement it for template options matching
            // && !commandInput.RemainingParameters.Any())
            {
                Reporter.Error.WriteLine(LocalizableStrings.CliTemplateSearchCoordinator_Error_NoTemplateName.Red().Bold());
                Reporter.Error.WriteLine(LocalizableStrings.CliTemplateSearchCoordinator_Info_SearchHelp, string.Join(", ", BaseSearchCommand.SupportedFilters.Select(f => $"'{f.OptionFactory().Name}'")));
                Reporter.Error.WriteLine(LocalizableStrings.Generic_ExamplesHeader);
                Reporter.Error.WriteCommand(
                    Example
                        .For<NewCommand>(commandArgs.ParseResult)
                        .WithSubcommand<SearchCommand>()
                        .WithArgument(BaseSearchCommand.NameArgument, "web"));
 
                Reporter.Error.WriteCommand(
                     Example
                        .For<NewCommand>(commandArgs.ParseResult)
                        .WithSubcommand<SearchCommand>()
                        .WithOption(SharedOptionsFactory.CreateAuthorOption(), "Microsoft"));
 
                Reporter.Error.WriteCommand(
                 Example
                    .For<NewCommand>(commandArgs.ParseResult)
                    .WithSubcommand<SearchCommand>()
                    .WithArgument(BaseSearchCommand.NameArgument, "web")
                    .WithOption(SharedOptionsFactory.CreateLanguageOption(), "C#"));
 
                return false;
            }
 
            if (!string.IsNullOrWhiteSpace(commandArgs.SearchNameCriteria) && commandArgs.SearchNameCriteria.Length < 2)
            {
                Reporter.Error.WriteLine(LocalizableStrings.CliTemplateSearchCoordinator_Error_TemplateNameIsTooShort.Bold().Red());
                return false;
            }
 
            return true;
        }
 
        private static string GetInputParametersString(SearchCommandArgs commandArgs/*, IReadOnlyDictionary<string, string?>? templateParameters = null*/)
        {
            string separator = ", ";
            IEnumerable<string> appliedFilters = commandArgs.AppliedFilters
                .Select(filter => $"{commandArgs.GetFilterToken(filter)}='{commandArgs.GetFilterValue(filter)}'");
 
            //TODO: implement it for template options matching
            //IEnumerable<string> appliedTemplateParameters = templateParameters?
            //       .Select(param => string.IsNullOrWhiteSpace(param.Value) ? param.Key : $"{param.Key}='{param.Value}'") ?? Array.Empty<string>();
 
            StringBuilder inputParameters = new();
            string? mainCriteria = commandArgs.SearchNameCriteria;
            if (!string.IsNullOrWhiteSpace(mainCriteria))
            {
                inputParameters.Append($"'{mainCriteria}'");
                if (appliedFilters.Any()/* || appliedTemplateParameters.Any()*/)
                {
                    inputParameters.Append(separator);
                }
            }
            if (appliedFilters/*.Concat(appliedTemplateParameters)*/.Any())
            {
                inputParameters.Append(string.Join(separator, appliedFilters/*.Concat(appliedTemplateParameters)*/));
            }
            return inputParameters.ToString();
        }
 
        /// <summary>
        /// Represents a table row for the template with the package information.
        /// </summary>
        internal class SearchResultTableRow
        {
            private const string MinimumDownloadCount = "<1k";
            private const char ThousandsChar = 'k';
 
            internal SearchResultTableRow(TemplateGroupTableRow templateGroupTableRow, string packageName, string packageOwners, bool reserved, long downloads = 0)
            {
                TemplateGroupInfo = templateGroupTableRow;
                PackageName = packageName;
                PackageOwners = packageOwners;
                TotalDownloads = downloads;
                Reserved = reserved;
            }
 
            internal static IComparer<long> TotalDownloadsComparer { get; } = new ThousandComparer();
 
            internal string PackageName { get; private set; }
 
            internal string PackageOwners { get; private set; }
 
            internal bool Reserved { get; private set; }
 
            internal string PrintableTotalDownloads
            {
                get
                {
                    if (TotalDownloads < 1)
                    {
                        return string.Empty;
                    }
                    else if (TotalDownloads < 1000)
                    {
                        return MinimumDownloadCount;
                    }
                    else
                    {
                        return $"{(TotalDownloads / 1000):N0}{ThousandsChar}";
                    }
                }
            }
 
            internal TemplateGroupTableRow TemplateGroupInfo { get; private set; }
 
            internal long TotalDownloads { get; private set; }
 
            private class ThousandComparer : IComparer<long>
            {
                public int Compare(long x, long y)
                {
                    if (x == y || x < 1 && y < 1)
                    {
                        return 0;
                    }
                    if (x < 1)
                    {
                        return -1;
                    }
                    if (y < 1)
                    {
                        return 1;
                    }
                    return (x / 1000).CompareTo(y / 1000);
                }
            }
        }
    }
}