Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions src/Aspire.Cli/Configuration/ConfigurationService.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<bool> DeleteConfigurationAsync(string key, bool isGlobal = false, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -76,9 +66,7 @@ public async Task<bool> 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;
Expand Down Expand Up @@ -180,6 +168,11 @@ private static async Task LoadConfigurationFromFileAsync(string filePath, Dictio
/// </summary>
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")
Expand Down Expand Up @@ -238,7 +231,15 @@ private static void RemoveConflictingFlattenedKeys(JsonObject settings, string[]
/// </summary>
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)>();

Expand All @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -294,7 +295,9 @@ private static void FlattenJsonObject(JsonObject obj, Dictionary<string, string>
{
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)
{
Expand Down
117 changes: 115 additions & 2 deletions src/Aspire.Cli/Utils/ConfigurationHelper.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -30,18 +32,129 @@ 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);
}
}

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

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)?.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;

for (int i = 0; i < parts.Length - 1; i++)
{
if (!currentObject.ContainsKey(parts[i]) || currentObject[parts[i]] is not JsonObject)
{
currentObject[parts[i]] = new JsonObject();
}

currentObject = currentObject[parts[i]]!.AsObject();
}

var finalKey = parts[parts.Length - 1];
currentObject[finalKey] = value;
Comment thread
JamesNK marked this conversation as resolved.
}

WriteSettingsFile(filePath, settings);

return true;
}
catch
{
return false;
}
}
}
146 changes: 146 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,152 @@ public void ShowDeprecatedPackages_DefaultsToFalse()
var featureFlags = provider.GetRequiredService<IFeatures>();
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<Aspire.Cli.Commands.RootCommand>();
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<Aspire.Cli.Commands.RootCommand>();

// 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<Aspire.Cli.Commands.RootCommand>();

// 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<Aspire.Cli.Commands.RootCommand>();

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

public class TestConfigurationService : IConfigurationService
Expand Down
Loading