// 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.Utils;
using NuGet.Packaging;
using NuGet.Versioning;
namespace Aspire.Cli.Templating;
internal sealed class TemplateHostConfiguration
{
private readonly Dictionary<string, SymbolInfo> _symbols = new(StringComparer.Ordinal);
private readonly Dictionary<string, NuGetPackageVersionValueSource> _valueSources = new(StringComparer.Ordinal);
public IReadOnlyDictionary<string, NuGetPackageVersionValueSource> ValueSources => _valueSources;
/// <summary>
/// Gets whether this template opts into creating a missing output-root NuGet configuration
/// when the CLI identity's package sources require one. Defaults to false.
/// </summary>
/// <remarks>
/// This is an Aspire host setting, not a template parameter or a language inference.
/// Version substitution alone does not request NuGet policy: polyglot AppHosts can use
/// the CLI SDK version while their integration restore manages package sources separately.
/// An opt-in never permits overwriting a template-owned configuration.
/// </remarks>
public bool CreateNuGetConfig { get; private set; }
public bool IsHidden(string name) => _symbols.TryGetValue(name, out var symbol) && symbol.IsHidden;
public string? GetDisplayName(string name) => _symbols.TryGetValue(name, out var symbol) ? symbol.DisplayName : null;
public static async Task<TemplateHostConfiguration> LoadAsync(
IEngineEnvironmentSettings settings, ITemplateInfo template, CancellationToken cancellationToken)
{
var configuration = new TemplateHostConfiguration();
if (string.IsNullOrEmpty(template.HostConfigPlace))
{
return configuration;
}
// The engine selects one host file: aspirecli.host.json, falling back to
// dotnetcli.host.json. Read that file from the template's mount, never merge them.
if (!settings.TryGetMountPoint(template.MountPointUri, out var mount) || mount is null)
{
throw new FileNotFoundException(null, template.MountPointUri);
}
using (mount)
{
var file = mount.FileInfo(template.HostConfigPlace)
?? throw new FileNotFoundException(null, template.HostConfigPlace);
try
{
using var stream = file.OpenRead();
using var document = await JsonDocument.ParseAsync(stream, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
}, cancellationToken);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
throw new JsonException(TemplatingStrings.InvalidTemplateHostConfigurationFormat);
}
// Template-level settings share the selected host file with prompt metadata:
// {"createNuGetConfig":true,"symbolInfo":{"AspireVersion":{"isHidden":true}}}
// Read the opt-in even when symbolInfo is absent. Only JSON Booleans are valid;
// an omitted setting remains false, including in an authoritative empty host file.
if (root.TryGetProperty("createNuGetConfig", out var createNuGetConfig))
{
configuration.CreateNuGetConfig = createNuGetConfig.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => throw new JsonException(TemplatingStrings.InvalidTemplateHostConfigurationFormat)
};
}
if (!root.TryGetProperty("symbolInfo", out var symbols))
{
return configuration;
}
if (symbols.ValueKind != JsonValueKind.Object)
{
throw new JsonException(TemplatingStrings.InvalidTemplateHostConfigurationFormat);
}
// CLI host files use {"symbolInfo":{"Port":{"isHidden":true}}}.
// The .NET CLI schema also permits the strings "true" and "false".
foreach (var symbol in symbols.EnumerateObject())
{
if (symbol.Value.ValueKind != JsonValueKind.Object)
{
throw new JsonException(TemplatingStrings.InvalidTemplateHostConfigurationFormat);
}
var hidden = false;
if (symbol.Value.TryGetProperty("isHidden", out var isHidden))
{
hidden = isHidden.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when isHidden.GetString() == "true" => true,
JsonValueKind.String when isHidden.GetString() == "false" => false,
_ => throw new JsonException(TemplatingStrings.InvalidTemplateHostConfigurationFormat)
};
}
string? displayName = null;
if (symbol.Value.TryGetProperty("displayName", out var label))
{
if (label.ValueKind != JsonValueKind.String)
{
throw new JsonException(TemplatingStrings.InvalidTemplateHostConfigurationFormat);
}
displayName = label.GetString();
}
configuration._symbols[symbol.Name] = new SymbolInfo(hidden, displayName);
if (symbol.Value.TryGetProperty("valueSource", out var valueSource))
{
configuration._valueSources.Add(symbol.Name, ReadValueSource(valueSource, template, symbol.Name));
}
}
}
catch (JsonException ex)
{
throw new TemplateDiscoveryException(CliExitCodes.InvalidCommand,
string.Format(CultureInfo.CurrentCulture, TemplatingStrings.InvalidTemplateHostConfiguration,
file.GetDisplayPath(), ex.Message));
}
}
return configuration;
}
private static NuGetPackageVersionValueSource ReadValueSource(JsonElement source, ITemplateInfo template, string name)
{
// Literal host metadata, not an engine expression:
// {"kind":"nugetPackageVersion","packageId":"Example","versionRange":"[1.0,2.0)"}
// Only ordinary string parameters can opt in; SDK identity and source projection
// keep their separate contracts rather than becoming package-version lookups.
var parameter = template.ParameterDefinitions.FirstOrDefault(parameter => parameter.Name == name);
if (parameter is null || parameter.IsName || parameter.Type != "parameter" ||
!parameter.DataType.Equals("string", StringComparison.OrdinalIgnoreCase) ||
name.Equals("AspireVersion", StringComparison.OrdinalIgnoreCase) ||
name.Equals("AspireNuGetSource", StringComparison.OrdinalIgnoreCase) ||
source.ValueKind != JsonValueKind.Object ||
!source.TryGetProperty("kind", out var kind) || kind.ValueKind != JsonValueKind.String ||
kind.GetString() != "nugetPackageVersion" ||
!source.TryGetProperty("packageId", out var id) || id.ValueKind != JsonValueKind.String ||
!PackageIdValidator.IsValidPackageId(id.GetString()!) ||
!source.TryGetProperty("versionRange", out var versionRange) || versionRange.ValueKind != JsonValueKind.String ||
!VersionRange.TryParse(versionRange.GetString()!, out var range) ||
(source.TryGetProperty("allowPrerelease", out var prerelease) &&
prerelease.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) ||
(source.TryGetProperty("workingDirectory", out var directory) &&
(directory.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(directory.GetString()))))
{
throw new JsonException(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.InvalidTemplateValueSource, name));
}
return new NuGetPackageVersionValueSource(
id.GetString()!, range,
source.TryGetProperty("allowPrerelease", out prerelease) && prerelease.ValueKind == JsonValueKind.True,
source.TryGetProperty("workingDirectory", out directory) ? directory.GetString()! : ".");
}
private sealed record SymbolInfo(bool IsHidden, string? DisplayName);
}
internal sealed record NuGetPackageVersionValueSource(
string PackageId, VersionRange VersionRange, bool AllowPrerelease, string WorkingDirectory);