diff --git a/src/Aspire.Cli/Configuration/ConfigurationService.cs b/src/Aspire.Cli/Configuration/ConfigurationService.cs index f079a403587..00aedf75edc 100644 --- a/src/Aspire.Cli/Configuration/ConfigurationService.cs +++ b/src/Aspire.Cli/Configuration/ConfigurationService.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json; using System.Text.Json.Nodes; using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; @@ -33,16 +32,7 @@ public async Task SetConfigurationAsync(string key, string value, bool isGlobal // Set the configuration value using dot notation support SetNestedValue(settings, key, value); - // Ensure directory exists - var directory = Path.GetDirectoryName(settingsFilePath); - if (directory is not null && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - // Write the updated settings - var jsonContent = JsonSerializer.Serialize(settings, JsonSourceGenerationContext.Default.JsonObject); - await File.WriteAllTextAsync(settingsFilePath, jsonContent, cancellationToken); + await ConfigurationHelper.WriteSettingsFileAsync(settingsFilePath, settings, cancellationToken); } public async Task DeleteConfigurationAsync(string key, bool isGlobal = false, CancellationToken cancellationToken = default) @@ -76,9 +66,7 @@ public async Task DeleteConfigurationAsync(string key, bool isGlobal = fal if (deleted) { - // Write the updated settings - var jsonContent = JsonSerializer.Serialize(settings, JsonSourceGenerationContext.Default.JsonObject); - await File.WriteAllTextAsync(settingsFilePath, jsonContent, cancellationToken); + await ConfigurationHelper.WriteSettingsFileAsync(settingsFilePath, settings, cancellationToken); } return deleted; @@ -180,6 +168,11 @@ private static async Task LoadConfigurationFromFileAsync(string filePath, Dictio /// private static void SetNestedValue(JsonObject settings, string key, string value) { + // Normalize colon-separated keys to dot notation since both represent + // the same configuration hierarchy (e.g., "features:polyglotSupportEnabled" + // is equivalent to "features.polyglotSupportEnabled") + key = key.Replace(':', '.'); + var keyParts = key.Split('.'); // Remove any conflicting flattened keys (e.g., "features:showAllTemplates" when setting "features.showAllTemplates") @@ -238,7 +231,15 @@ private static void RemoveConflictingFlattenedKeys(JsonObject settings, string[] /// private static bool DeleteNestedValue(JsonObject settings, string key) { + // Normalize colon-separated keys to dot notation + key = key.Replace(':', '.'); + var keyParts = key.Split('.'); + + // Remove any flat colon-separated key at root level (legacy format) + var flattenedKey = string.Join(":", keyParts); + var removedFlat = settings.Remove(flattenedKey); + var currentObject = settings; var objectPath = new List<(JsonObject obj, string key)>(); @@ -250,7 +251,7 @@ private static bool DeleteNestedValue(JsonObject settings, string key) if (!currentObject.ContainsKey(part) || currentObject[part] is not JsonObject) { - return false; // Path doesn't exist + return removedFlat; // Path doesn't exist, but may have removed flat key } currentObject = currentObject[part]!.AsObject(); @@ -261,7 +262,7 @@ private static bool DeleteNestedValue(JsonObject settings, string key) // Check if the final key exists if (!currentObject.ContainsKey(finalKey)) { - return false; + return removedFlat; } // Remove the final key @@ -294,7 +295,9 @@ private static void FlattenJsonObject(JsonObject obj, Dictionary { foreach (var kvp in obj) { - var key = string.IsNullOrEmpty(prefix) ? kvp.Key : $"{prefix}.{kvp.Key}"; + // Normalize colon-separated keys to dot notation for consistent display + var normalizedKey = kvp.Key.Replace(':', '.'); + var key = string.IsNullOrEmpty(prefix) ? normalizedKey : $"{prefix}.{normalizedKey}"; if (kvp.Value is JsonObject nestedObj) { diff --git a/src/Aspire.Cli/Utils/ConfigurationHelper.cs b/src/Aspire.Cli/Utils/ConfigurationHelper.cs index 743d77874c1..acfd5ee4235 100644 --- a/src/Aspire.Cli/Utils/ConfigurationHelper.cs +++ b/src/Aspire.Cli/Utils/ConfigurationHelper.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.Configuration; namespace Aspire.Cli.Utils; @@ -30,13 +32,13 @@ internal static void RegisterSettingsFiles(IConfigurationBuilder configuration, // Add global settings first (if it exists) - lower precedence if (File.Exists(globalSettingsFile.FullName)) { - configuration.AddJsonFile(globalSettingsFile.FullName, optional: true); + AddSettingsFile(configuration, globalSettingsFile.FullName); } // Then add local settings (if found) - this will override global settings if (localSettingsFile is not null) { - configuration.AddJsonFile(localSettingsFile.FullName, optional: true); + AddSettingsFile(configuration, localSettingsFile.FullName); } } @@ -44,4 +46,143 @@ internal static string BuildPathToSettingsJsonFile(string workingDirectory) { return Path.Combine(workingDirectory, ".aspire", "settings.json"); } + + /// + /// Serializes a JsonObject and writes it to a settings file, creating the directory if needed. + /// + 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); + } + + /// + /// Serializes a JsonObject and writes it to a settings file, creating the directory if needed. + /// + 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); + + configuration.AddJsonFile(filePath, optional: true); + } + + /// + /// Normalizes a settings file by converting flat colon-separated keys to nested JSON objects. + /// + internal static bool TryNormalizeSettingsFile(string filePath) + { + try + { + var content = File.ReadAllText(filePath); + + if (string.IsNullOrWhiteSpace(content)) + { + return false; + } + + var settings = JsonNode.Parse(content)?.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; + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs index 8bd2d2fab37..c18b2b42547 100644 --- a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs @@ -419,6 +419,186 @@ public void ShowDeprecatedPackages_DefaultsToFalse() var featureFlags = provider.GetRequiredService(); Assert.False(featureFlags.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false)); } + + [Fact] + public async Task ConfigSetCommand_WithColonNotation_CreatesNestedObject() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("config set features:polyglotSupportEnabled true"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + + // Colon notation should be normalized to nested JSON, not stored as a flat key + var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json"); + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonNode.Parse(json)?.AsObject(); + Assert.NotNull(settings); + + // Should be stored as nested object, not as flat "features:polyglotSupportEnabled" key + Assert.False(settings.ContainsKey("features:polyglotSupportEnabled")); + Assert.True(settings["features"] is JsonObject); + var featuresObject = settings["features"]!.AsObject(); + Assert.Equal("true", featuresObject["polyglotSupportEnabled"]?.ToString()); + } + + [Fact] + public async Task ConfigSetCommand_ColonThenDot_NoDuplicateKeys() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Set with colon notation first + var result1 = command.Parse("config set features:polyglotSupportEnabled true"); + var exitCode1 = await result1.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode1); + + // Then set with dot notation + var result2 = command.Parse("config set features.polyglotSupportEnabled false"); + var exitCode2 = await result2.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode2); + + // Should have a single nested entry, no flat colon key + var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json"); + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonNode.Parse(json)?.AsObject(); + Assert.NotNull(settings); + + Assert.False(settings.ContainsKey("features:polyglotSupportEnabled")); + Assert.True(settings["features"] is JsonObject); + var featuresObject = settings["features"]!.AsObject(); + Assert.Equal("false", featuresObject["polyglotSupportEnabled"]?.ToString()); + } + + [Fact] + public async Task ConfigSetCommand_DotThenColon_NoDuplicateKeys() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Set with dot notation first + var result1 = command.Parse("config set features.polyglotSupportEnabled true"); + var exitCode1 = await result1.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode1); + + // Then set with colon notation + var result2 = command.Parse("config set features:polyglotSupportEnabled false"); + var exitCode2 = await result2.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode2); + + // Should have a single nested entry, no flat colon key + var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json"); + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonNode.Parse(json)?.AsObject(); + Assert.NotNull(settings); + + Assert.False(settings.ContainsKey("features:polyglotSupportEnabled")); + Assert.True(settings["features"] is JsonObject); + var featuresObject = settings["features"]!.AsObject(); + Assert.Equal("false", featuresObject["polyglotSupportEnabled"]?.ToString()); + } + + [Fact] + public async Task ConfigDeleteCommand_WithColonNotation_DeletesNestedValue() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Set with dot notation + var setResult = command.Parse("config set features.polyglotSupportEnabled true"); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, setExitCode); + + // Delete with colon notation + var deleteResult = command.Parse("config delete features:polyglotSupportEnabled"); + var deleteExitCode = await deleteResult.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, deleteExitCode); + + // Verify the entire features structure is cleaned up + var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json"); + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonNode.Parse(json)?.AsObject(); + Assert.NotNull(settings); + Assert.False(settings.ContainsKey("features")); + } + + [Fact] + public async Task ConfigSetCommand_WithCorruptedFile_RecoversDuringLoad() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Manually create a corrupted settings file with duplicate keys + // (flat colon key + nested object for the same path) + var settingsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire"); + Directory.CreateDirectory(settingsDir); + var settingsPath = Path.Combine(settingsDir, "settings.json"); + await File.WriteAllTextAsync(settingsPath, """ + { + "features": { + "polyglotSupportEnabled": "false" + }, + "features:polyglotSupportEnabled": "true" + } + """); + + // Loading configuration should succeed after normalizing the corrupted file + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + // Verify the file was normalized - flat key should be gone, existing nested value preserved + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonNode.Parse(json)?.AsObject(); + Assert.NotNull(settings); + Assert.False(settings.ContainsKey("features:polyglotSupportEnabled")); + Assert.True(settings["features"] is JsonObject); + var featuresObject = settings["features"]!.AsObject(); + Assert.Equal("false", featuresObject["polyglotSupportEnabled"]?.ToString()); + } + + [Fact] + public async Task ConfigSetCommand_WithCorruptedFile_PreservesExistingNestedValueOverFlatKey() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Create a settings file where both a nested value and a flat colon key exist + // for the same path but with different values. + var settingsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire"); + Directory.CreateDirectory(settingsDir); + var settingsPath = Path.Combine(settingsDir, "settings.json"); + await File.WriteAllTextAsync(settingsPath, """ + { + "features": { + "polyglotSupportEnabled": "nested-value" + }, + "features:polyglotSupportEnabled": "flat-value" + } + """); + + // Loading configuration triggers normalization + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + // The existing nested value should be preserved; the flat key value should be dropped + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonNode.Parse(json)?.AsObject(); + Assert.NotNull(settings); + Assert.False(settings.ContainsKey("features:polyglotSupportEnabled")); + var featuresObject = settings["features"]!.AsObject(); + Assert.Equal("nested-value", featuresObject["polyglotSupportEnabled"]?.ToString()); + } } public class TestConfigurationService : IConfigurationService