File: Utils\ConfigurationHelper.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.Configuration;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Cli.Utils;
 
internal static class ConfigurationHelper
{
    /// <summary>
    /// Standard options for parsing JSON that may contain non-spec features like comments and trailing commas.
    /// </summary>
    public static readonly JsonDocumentOptions ParseOptions = new()
    {
        CommentHandling = JsonCommentHandling.Skip,
        AllowTrailingCommas = true
    };
 
    internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, DirectoryInfo workingDirectory, FileInfo globalSettingsFile)
    {
        var currentDirectory = workingDirectory;
 
        // Find the nearest local settings file (prefer aspire.config.json, fall back to .aspire/settings.json)
        FileInfo? localSettingsFile = null;
 
        while (currentDirectory is not null)
        {
            // Check for aspire.config.json first (new format)
            var newSettingsPath = Path.Combine(currentDirectory.FullName, AspireConfigFile.FileName);
            if (File.Exists(newSettingsPath))
            {
                localSettingsFile = new FileInfo(newSettingsPath);
                break;
            }
 
            // TODO: Remove legacy .aspire/settings.json fallback once confident most users have migrated.
            // Tracked by https://github.com/microsoft/aspire/issues/15239
            // Fall back to .aspire/settings.json (legacy format)
            var legacySettingsPath = BuildPathToSettingsJsonFile(currentDirectory.FullName);
            if (File.Exists(legacySettingsPath))
            {
                localSettingsFile = new FileInfo(legacySettingsPath);
                break;
            }
 
            currentDirectory = currentDirectory.Parent;
        }
 
        // Add global settings first (if it exists) - lower precedence
        if (File.Exists(globalSettingsFile.FullName))
        {
            AddSettingsFile(configuration, globalSettingsFile.FullName);
        }
 
        // Then add local settings (if found) - this will override global settings
        if (localSettingsFile is not null)
        {
            AddSettingsFile(configuration, localSettingsFile.FullName);
        }
    }
 
    internal static string BuildPathToSettingsJsonFile(string workingDirectory)
    {
        return Path.Combine(workingDirectory, ".aspire", "settings.json");
    }
 
    /// <summary>
    /// Serializes a JsonObject and writes it to a settings file, creating the directory if needed.
    /// </summary>
    internal static async Task WriteSettingsFileAsync(string filePath, JsonObject settings, CancellationToken cancellationToken = default)
    {
        var jsonContent = JsonSerializer.Serialize(settings, JsonSourceGenerationContext.Default.JsonObject);
 
        EnsureDirectoryExists(filePath);
        await File.WriteAllTextAsync(filePath, jsonContent, cancellationToken);
    }
 
    /// <summary>
    /// Serializes a JsonObject and writes it to a settings file, creating the directory if needed.
    /// </summary>
    internal static void WriteSettingsFile(string filePath, JsonObject settings)
    {
        var jsonContent = JsonSerializer.Serialize(settings, JsonSourceGenerationContext.Default.JsonObject);
 
        EnsureDirectoryExists(filePath);
        File.WriteAllText(filePath, jsonContent);
    }
 
    private static void EnsureDirectoryExists(string filePath)
    {
        var directory = Path.GetDirectoryName(filePath);
        if (directory is not null && !Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }
    }
 
    private static void AddSettingsFile(IConfigurationBuilder configuration, string filePath)
    {
        // Proactively normalize the settings file to prevent duplicate key errors.
        // This handles files corrupted by mixing colon and dot notation
        // (e.g., both "features:key" flat entry and "features" nested object).
        TryNormalizeSettingsFile(filePath);
 
        // Pre-process the file to handle comments and trailing commas.
        // Microsoft.Extensions.Configuration.Json doesn't support JSON comments,
        // so we parse with comment support and load the clean JSON via stream.
        try
        {
            var content = File.ReadAllText(filePath);
            var node = JsonNode.Parse(content, documentOptions: ParseOptions);
            if (node is not null)
            {
                var cleanJson = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
                var bytes = System.Text.Encoding.UTF8.GetBytes(cleanJson);
                configuration.AddJsonStream(new MemoryStream(bytes));
                return;
            }
        }
        catch (JsonException ex)
        {
            throw new InvalidOperationException(
                string.Format(CultureInfo.CurrentCulture, ErrorStrings.InvalidJsonInConfigFile, filePath, ex.Message),
                ex);
        }
 
        configuration.AddJsonFile(filePath, optional: true);
    }
 
    /// <summary>
    /// Normalizes a settings file by converting flat colon-separated keys to nested JSON objects.
    /// </summary>
    internal static bool TryNormalizeSettingsFile(string filePath)
    {
        try
        {
            var content = File.ReadAllText(filePath);
 
            if (string.IsNullOrWhiteSpace(content))
            {
                return false;
            }
 
            var settings = JsonNode.Parse(content, documentOptions: ParseOptions)?.AsObject();
 
            if (settings is null)
            {
                return false;
            }
 
            // Find all colon-separated keys at root level
            var colonKeys = new List<(string key, string? value)>();
 
            foreach (var kvp in settings)
            {
                if (kvp.Key.Contains(':'))
                {
                    colonKeys.Add((kvp.Key, kvp.Value?.ToString()));
                }
            }
 
            if (colonKeys.Count == 0)
            {
                return false;
            }
 
            // Remove colon keys and re-add them as nested structure
            foreach (var (key, value) in colonKeys)
            {
                settings.Remove(key);
 
                // Convert "a:b:c" to nested {"a": {"b": {"c": value}}}
                var parts = key.Split(':');
                var currentObject = settings;
                var pathConflict = false;
 
                // Walk all but the last segment, creating objects as needed.
                for (int i = 0; i < parts.Length - 1; i++)
                {
                    var part = parts[i];
 
                    if (!currentObject.ContainsKey(part) || currentObject[part] is null)
                    {
                        currentObject[part] = new JsonObject();
                    }
                    else if (currentObject[part] is JsonObject)
                    {
                        currentObject = currentObject[part]!.AsObject();
                        continue;
                    }
                    else
                    {
                        // Existing non-object value conflicts with the desired nested structure.
                        // Prefer the existing nested value and drop the flat key.
                        pathConflict = true;
                        break;
                    }
 
                    currentObject = currentObject[part]!.AsObject();
                }
 
                if (pathConflict)
                {
                    continue;
                }
 
                var finalKey = parts[parts.Length - 1];
 
                // If the final key already exists, keep its value and drop the flat key.
                if (currentObject.ContainsKey(finalKey) && currentObject[finalKey] is not null)
                {
                    continue;
                }
 
                currentObject[finalKey] = value;
            }
 
            WriteSettingsFile(filePath, settings);
 
            return true;
        }
        catch
        {
            return false;
        }
    }
}