File: Templating\TemplateSourceConfiguration.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 System.Text.Json.Nodes;
using Aspire.Cli.Git;
using Aspire.Cli.Resources;
 
namespace Aspire.Cli.Templating;
 
internal static class TemplateSourceConfiguration
{
    public static ConfiguredTemplateSource Parse(JsonElement value, string settingPath, bool requireComplete)
    {
        if (value.ValueKind is not JsonValueKind.Object)
        {
            throw new TemplateSourceConfigurationException(settingPath, NewCommandStrings.InvalidConfiguredTemplateSource);
        }
 
        string? path = null;
        string? repository = null;
        string? reference = null;
        foreach (var property in value.EnumerateObject())
        {
            var field = property.Name.ToLowerInvariant();
            if (field is not ("repository" or "ref" or "path"))
            {
                // Ignore fields from newer CLIs, matching typed configuration deserialization.
                continue;
            }
            var text = property.Value.ValueKind is JsonValueKind.Null
                ? null
                : ReadString(property.Value, $"{settingPath}.{property.Name}");
            switch (field)
            {
                case "path":
                    path = text;
                    break;
                case "repository":
                    repository = text;
                    break;
                case "ref":
                    reference = text;
                    break;
            }
        }
 
        return Parse(path, repository, reference, settingPath, requireComplete);
    }
 
    public static ConfiguredTemplateSource Parse(
        string? path, string? repository, string? reference, string settingPath, bool requireComplete)
    {
        foreach (var (field, value) in new[] { ("path", path), ("repository", repository), ("ref", reference) })
        {
            if (value is not null)
            {
                ValidateString(value, $"{settingPath}.{field}");
            }
        }
 
        if (repository is null)
        {
            if (path is not null)
            {
                if (reference is null && Path.IsPathFullyQualified(path))
                {
                    return new(new TemplateSource(path, null, null));
                }
                ValidateRepositoryPath(path, $"{settingPath}.path");
            }
            if (requireComplete)
            {
                throw new TemplateSourceConfigurationException(settingPath, NewCommandStrings.TemplateSourceMissingLocation);
            }
            return new(null);
        }
 
        ValidateRepository(repository, $"{settingPath}.repository");
        path ??= ".";
        ValidateRepositoryPath(path, $"{settingPath}.path");
        return new(new TemplateSource(path, repository, reference));
    }
 
    public static string ReadString(JsonElement value, string settingPath)
    {
        if (value.ValueKind is not JsonValueKind.String ||
            value.GetString() is not { } text)
        {
            throw new TemplateSourceConfigurationException(settingPath, NewCommandStrings.TemplateSourceStringRequired);
        }
        ValidateString(text, settingPath);
        return text;
    }
 
    private static void ValidateString(string value, string settingPath)
    {
        if (string.IsNullOrWhiteSpace(value) || value.Any(char.IsControl))
        {
            throw new TemplateSourceConfigurationException(settingPath, NewCommandStrings.TemplateSourceStringRequired);
        }
    }
 
    public static void ValidateRepository(string repository, string settingPath)
    {
        if (!GitHelpers.IsValidRepositoryUrl(repository))
        {
            throw new TemplateSourceConfigurationException(settingPath, NewCommandStrings.TemplateSourceInvalidRepository);
        }
    }
 
    public static void ValidateName(string name)
    {
        if (string.IsNullOrWhiteSpace(name) || name.Any(char.IsControl) || name.Contains('.') || name.Contains(':'))
        {
            throw new TemplateSourceConfigurationException("templateSources", NewCommandStrings.TemplateSourceNameRequired);
        }
    }
 
    public static void ValidateRepositoryPath(string path, string settingPath)
    {
        try
        {
            TemplateSource.GetRepositoryRelativePath(path, Path.GetFullPath("."));
        }
        catch (Exception ex) when (ex is TemplateDiscoveryException or ArgumentException or NotSupportedException)
        {
            throw new TemplateSourceConfigurationException(settingPath, NewCommandStrings.TemplateRepositoryPathInvalid);
        }
    }
 
    public static string GetPropertyName(JsonObject settings, string key, StringComparison comparison) =>
        settings.Select(property => property.Key).FirstOrDefault(name => name.Equals(key, comparison)) ?? key;
 
    public static string ResolveEditKey(JsonObject settings, string key)
    {
        var parts = key.Replace(':', '.').Split('.');
        if (!parts[0].Equals("templateSources", StringComparison.OrdinalIgnoreCase))
        {
            return key;
        }
        parts[0] = GetPropertyName(settings, "templateSources", StringComparison.OrdinalIgnoreCase);
        if (parts.Length > 1 && settings[parts[0]] is JsonObject sources)
        {
            parts[1] = GetPropertyName(sources, parts[1], StringComparisons.TemplateSourceName);
            if (parts.Length > 2 && sources[parts[1]] is JsonObject source)
            {
                parts[2] = GetPropertyName(source, parts[2], StringComparison.OrdinalIgnoreCase);
            }
        }
 
        return string.Join('.', parts);
    }
 
    public static string ValidateEditKey(JsonObject settings, string key)
    {
        key = ResolveEditKey(settings, key);
        var parts = key.Replace(':', '.').Split('.');
        if (!parts[0].Equals("templateSources", StringComparison.OrdinalIgnoreCase))
        {
            return key;
        }
        if (parts.Length < 2)
        {
            throw new TemplateSourceConfigurationException("templateSources", NewCommandStrings.TemplateSourcesMustBeObject);
        }
        ValidateName(parts[1]);
        if (parts.Length > 3 || (parts.Length == 3 && parts[2].ToLowerInvariant() is not ("repository" or "ref" or "path")))
        {
            throw new TemplateSourceConfigurationException(key.Replace(':', '.'), NewCommandStrings.TemplateSourceUnknownField);
        }
        if (settings[parts[0]] is { } templates && templates is not JsonObject)
        {
            throw new TemplateSourceConfigurationException("templateSources", NewCommandStrings.TemplateSourcesMustBeObject);
        }
        if (parts.Length == 3 && settings[parts[0]]?[parts[1]] is JsonValue)
        {
            // The general config setter replaces scalar parents with objects. Do not silently
            // discard an existing directory/repository string when adding source fields.
            throw new TemplateSourceConfigurationException($"templateSources.{parts[1]}",
                string.Format(CultureInfo.CurrentCulture, NewCommandStrings.TemplateSourceCannotExtendString, $"templateSources.{parts[1]}"));
        }
 
        return key;
    }
 
    public static void ValidateEditedSource(JsonObject settings, string key)
    {
        var parts = key.Replace(':', '.').Split('.');
        if (parts[0].Equals("templateSources", StringComparison.OrdinalIgnoreCase))
        {
            using var document = JsonDocument.Parse(settings[parts[0]]![parts[1]]!.ToJsonString());
            Parse(document.RootElement, $"templateSources.{parts[1]}", requireComplete: false);
        }
    }
}
 
internal sealed record ConfiguredTemplateSource(TemplateSource? Template);
 
internal sealed class TemplateSourceConfigurationException(string settingPath, string reason)
    : Exception(string.Format(CultureInfo.CurrentCulture, NewCommandStrings.TemplateSourceSettingInvalid, settingPath, reason));