| 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; } } }