| File: Templating\ConfiguredTemplateSourceService.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.Globalization; using System.Text.Json; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; namespace Aspire.Cli.Templating; internal sealed class ConfiguredTemplateSourceService( IConfigurationService configurationService, ITemplateProvider templateProvider, IInteractionService interactionService) { internal async Task<IReadOnlyList<KeyValuePair<string, ConfiguredTemplateSource>>> GetSourcesAsync(CancellationToken cancellationToken) { var settingsPath = configurationService.GetSettingsFilePath(isGlobal: true); AspireConfigFile? settings; try { settings = JsonSerializer.Deserialize( await File.ReadAllTextAsync(settingsPath, cancellationToken), JsonSourceGenerationContext.Default.AspireConfigFile); } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { return []; } List<KeyValuePair<string, ConfiguredTemplateSource>> sources = []; foreach (var (name, definition) in settings?.TemplateSources ?? []) { cancellationToken.ThrowIfCancellationRequested(); TemplateSourceConfiguration.ValidateName(name); if (definition is null) { throw new TemplateSourceConfigurationException($"templateSources.{name}", NewCommandStrings.InvalidConfiguredTemplateSource); } var source = TemplateSourceConfiguration.Parse( definition.Path, definition.Repository, definition.Ref, $"templateSources.{name}", requireComplete: true); sources.Add(new(name, source)); } return sources; } public async Task<IReadOnlyList<ConfiguredTemplate>> SearchAsync(string query, CancellationToken cancellationToken) { var sources = await GetSourcesAsync(cancellationToken); if (sources.Count == 0) { interactionService.DisplayMessage(KnownEmojis.Information, NewCommandStrings.NoConfiguredTemplateSources); return []; } List<ConfiguredTemplate> results = []; // The template engine owns mutable mount/resource state. Discover sequentially rather // than sharing a factory across concurrent source installations. foreach (var (name, configuredSource) in sources) { cancellationToken.ThrowIfCancellationRequested(); try { var status = string.Format(CultureInfo.CurrentCulture, NewCommandStrings.SearchingTemplateSource, name); var source = configuredSource.Template ?? throw new InvalidOperationException("A complete template source must have a location."); IEnumerable<ITemplate> templates; if (source.Repository is null) { templates = await interactionService.ShowStatusAsync(status, () => templateProvider.GetTemplatesAsync(source, cancellationToken)); } else { // Git owns its resolve/clone spinners; an enclosing status hides them. interactionService.DisplaySubtleMessage(status); templates = await templateProvider.GetTemplatesAsync(source, cancellationToken); } results.AddRange(templates .Where(template => template.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || template.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || template.SearchDescription?.Contains(query, StringComparison.OrdinalIgnoreCase) is true) .Select(template => new ConfiguredTemplate(name, template))); } catch (Exception ex) when (ex is TemplateDiscoveryException or IOException or UnauthorizedAccessException or ArgumentException) { Warn(name, ex.Message); } } return results.OrderBy(result => result.Template.Description, StringComparer.OrdinalIgnoreCase) .ThenBy(result => result.SourceName, StringComparers.TemplateSourceName) .ThenBy(result => result.Template.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); } private void Warn(string source, string message) => interactionService.DisplayMessage(KnownEmojis.Warning, string.Format( CultureInfo.CurrentCulture, NewCommandStrings.TemplateSourceSearchFailed, source, message)); } internal sealed record ConfiguredTemplate(string SourceName, ITemplate Template);