File: Templating\TemplatePromptDefaults.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.Resources;
using Microsoft.TemplateEngine.Abstractions;
using Microsoft.TemplateEngine.Core;
using Microsoft.TemplateEngine.Orchestrator.RunnableProjects.Abstractions;
using Microsoft.TemplateEngine.Orchestrator.RunnableProjects.ConfigModel;
using Microsoft.TemplateEngine.Utils;
 
namespace Aspire.Cli.Templating;
 
internal sealed class TemplatePromptDefaults
{
    private readonly Dictionary<string, List<(IGeneratedSymbolMacro Macro, IMacroConfig Config)>> _projections = new(StringComparer.Ordinal);
 
    public static async Task<TemplatePromptDefaults> LoadAsync(
        IEngineEnvironmentSettings settings, ITemplateInfo template, TemplateHostConfiguration hostConfiguration,
        CancellationToken cancellationToken)
    {
        var defaults = new TemplatePromptDefaults();
        if (hostConfiguration.DefaultValueSources.Count == 0)
        {
            return defaults;
        }
 
        if (!settings.Components.TryGetComponent(template.GeneratorId, out IGenerator? generator) || generator is null)
        {
            throw new InvalidOperationException($"Template generator '{template.GeneratorId}' is not registered.");
        }
 
        using var loadedTemplate = await generator.LoadTemplateAsync(settings, template, cancellationToken: cancellationToken)
            ?? throw new FileNotFoundException(null, template.ConfigPlace);
 
        if (!settings.TryGetMountPoint(template.MountPointUri, out var mount) || mount is null)
        {
            throw new FileNotFoundException(null, template.MountPointUri);
        }
 
        TemplateConfigModel model;
        using (mount)
        {
            var file = mount.FileInfo(template.ConfigPlace)
                ?? throw new FileNotFoundException(null, template.ConfigPlace);
            using var stream = file.OpenRead();
            model = TemplateConfigModel.FromStream(stream);
        }
 
        var symbols = model.Symbols.ToDictionary(symbol => symbol.Name, StringComparer.Ordinal);
        var macros = settings.Components.OfType<IGeneratedSymbolMacro>().ToDictionary(macro => macro.Type, StringComparer.Ordinal);
        foreach (var (parameterName, sourceName) in hostConfiguration.DefaultValueSources)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var parameter = template.ParameterDefinitions.FirstOrDefault(parameter => parameter.Name == parameterName);
            if (parameter is null || parameter.IsName || parameter.Type != "parameter" ||
                parameter.Precedence.CanBeRequired || !string.IsNullOrEmpty(parameter.DefaultValue) ||
                !parameter.DataType.Equals("string", StringComparison.OrdinalIgnoreCase) ||
                parameterName.Equals("AspireVersion", StringComparison.OrdinalIgnoreCase) ||
                parameterName.Equals("AspireNuGetSource", StringComparison.OrdinalIgnoreCase) ||
                !symbols.TryGetValue(sourceName, out var source) || source is not GeneratedSymbol { Generator: "coalesce" } coalesce)
            {
                throw InvalidDefault(parameterName);
            }
 
            if (ReadReference(coalesce, "sourceVariableName") != parameterName)
            {
                throw InvalidDefault(parameterName);
            }
 
            var visiting = new HashSet<string>(StringComparer.Ordinal);
            var validated = new HashSet<string>(StringComparer.Ordinal);
            var projection = new List<(IGeneratedSymbolMacro, IMacroConfig)>();
            Validate(sourceName);
            defaults._projections.Add(parameterName, projection);
 
            void Validate(string symbolName)
            {
                cancellationToken.ThrowIfCancellationRequested();
                if (symbolName == "name")
                {
                    // Cached discovery metadata does not preserve IsName; use runtime definitions.
                    if (!loadedTemplate.ParameterDefinitions.Any(parameter => parameter.Name == symbolName && parameter.IsName))
                    {
                        throw InvalidDefault(parameterName);
                    }
 
                    return;
                }
 
                if (validated.Contains(symbolName))
                {
                    return;
                }
 
                if (!visiting.Add(symbolName) ||
                    !symbols.TryGetValue(symbolName, out var symbol) ||
                    symbol is not GeneratedSymbol { Generator: "regex" or "coalesce" } generated ||
                    !macros.TryGetValue(generated.Generator, out var macro))
                {
                    throw InvalidDefault(parameterName);
                }
 
                // Only project-name-derived values are previewed. Evaluating random/bound
                // symbols twice can change the fallback; other parameters may contain secrets.
                // Validate raw references, including unused coalesce branches: the engine's
                // dependency discovery filters out unknown symbol names.
                var config = macro.CreateConfig(settings, generated);
                if (generated.Generator == "regex")
                {
                    var regexSource = ReadReference(generated, "source");
                    if (regexSource == parameterName)
                    {
                        // Regex can observe an omitted parameter's empty default, whereas
                        // coalesce also observes suppliedness. A plain collection cannot
                        // represent both; do not preview graphs requiring parameter state.
                        throw InvalidDefault(parameterName);
                    }
                    Validate(regexSource);
                }
                else
                {
                    var coalesceSource = ReadReference(generated, "sourceVariableName");
                    if (coalesceSource == "name")
                    {
                        // The engine marks name as NameParameter, not User input, so it
                        // falls back here even when name is nonempty. Its parameter-aware
                        // collection is internal; reject this shape rather than mispreview it.
                        // https://github.com/dotnet/templating/wiki/Available-Symbols-Generators#coalesce
                        throw InvalidDefault(parameterName);
                    }
                    if (coalesceSource != parameterName)
                    {
                        Validate(coalesceSource);
                    }
                    Validate(ReadReference(generated, "fallbackVariableName"));
                }
 
                visiting.Remove(symbolName);
                validated.Add(symbolName);
                projection.Add((macro, config));
            }
 
            string ReadReference(GeneratedSymbol symbol, string key)
            {
                if (!symbol.Parameters.TryGetValue(key, out var reference))
                {
                    throw InvalidDefault(parameterName);
                }
 
                // GeneratedSymbol.Parameters contains JSON-serialized values, e.g.
                // sourceVariableName -> "\"displayName\"", not an expression to interpolate.
                using var document = JsonDocument.Parse(reference);
                if (document.RootElement.ValueKind != JsonValueKind.String ||
                    string.IsNullOrWhiteSpace(document.RootElement.GetString()))
                {
                    throw InvalidDefault(parameterName);
                }
 
                return document.RootElement.GetString()!;
            }
        }
 
        return defaults;
    }
 
    public IReadOnlyDictionary<string, string> Evaluate(IEngineEnvironmentSettings settings, string name, CancellationToken cancellationToken)
    {
        var defaults = new Dictionary<string, string>(StringComparer.Ordinal);
        foreach (var (parameterName, projection) in _projections)
        {
            // Omit the override so native coalesce selects its fallback. Validation excludes
            // graphs where a plain collection differs from the engine's parameter-aware one.
            var variables = new VariableCollection { ["name"] = name };
            foreach (var (macro, config) in projection)
            {
                cancellationToken.ThrowIfCancellationRequested();
                macro.EvaluateConfig(settings, variables, config);
            }
            if (!variables.TryGetValue(projection[^1].Config.VariableName, out var value) || value is not string defaultValue)
            {
                throw InvalidDefault(parameterName);
            }
            defaults.Add(parameterName, defaultValue);
        }
 
        return defaults;
    }
 
    private static TemplateAuthoringException InvalidDefault(string parameterName) =>
        new(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.InvalidTemplatePromptDefault, parameterName), parameterName);
}