Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
145 changes: 143 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,157 @@ 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;
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;
}
}
}
Loading
Loading