File: Configuration\ConfigurationService.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Cli.Configuration;
 
internal sealed class ConfigurationService(IConfiguration configuration, DirectoryInfo currentDirectory, FileInfo globalSettingsFile) : IConfigurationService
{
    public async Task SetConfigurationAsync(string key, string value, bool isGlobal = false, CancellationToken cancellationToken = default)
    {
        var settingsFilePath = GetSettingsFilePath(isGlobal);
 
        JsonObject settings;
 
        // Read existing settings or create new
        if (File.Exists(settingsFilePath))
        {
            var existingContent = await File.ReadAllTextAsync(settingsFilePath, cancellationToken);
            settings = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject();
        }
        else
        {
            settings = new JsonObject();
        }
 
        // 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);
    }
 
    public async Task<bool> DeleteConfigurationAsync(string key, bool isGlobal = false, CancellationToken cancellationToken = default)
    {
        var settingsFilePath = GetSettingsFilePath(isGlobal);
 
        if (!File.Exists(settingsFilePath))
        {
            return false;
        }
 
        try
        {
            var existingContent = await File.ReadAllTextAsync(settingsFilePath, cancellationToken);
            var settings = JsonNode.Parse(existingContent)?.AsObject();
 
            if (settings is null)
            {
                return false;
            }
 
            // Delete using dot notation support and return whether deletion occurred
            var deleted = DeleteNestedValue(settings, key);
            
            if (deleted)
            {
                // Write the updated settings
                var jsonContent = JsonSerializer.Serialize(settings, JsonSourceGenerationContext.Default.JsonObject);
                await File.WriteAllTextAsync(settingsFilePath, jsonContent, cancellationToken);
            }
 
            return deleted;
        }
        catch
        {
            return false;
        }
    }
 
    private string GetSettingsFilePath(bool isGlobal)
    {
        if (isGlobal)
        {
            return globalSettingsFile.FullName;
        }
        else
        {
            return FindNearestSettingsFile();
        }
    }
 
    private string FindNearestSettingsFile()
    {
        var searchDirectory = currentDirectory;
 
        // Walk up the directory tree to find existing settings file
        while (searchDirectory is not null)
        {
            var settingsFilePath = ConfigurationHelper.BuildPathToSettingsJsonFile(searchDirectory.FullName);
 
            if (File.Exists(settingsFilePath))
            {
                return settingsFilePath;
            }
 
            searchDirectory = searchDirectory.Parent;
        }
 
        // If no existing settings file found, create one in current directory
        return ConfigurationHelper.BuildPathToSettingsJsonFile(currentDirectory.FullName);
    }
 
    public async Task<Dictionary<string, string>> GetAllConfigurationAsync(CancellationToken cancellationToken = default)
    {
        var allConfig = new Dictionary<string, string>();
 
        var nearestSettingFilePath = FindNearestSettingsFile();
        await LoadConfigurationFromFileAsync(nearestSettingFilePath, allConfig, cancellationToken);
        await LoadConfigurationFromFileAsync(globalSettingsFile.FullName, allConfig, cancellationToken);
 
        return allConfig;
    }
 
    private static async Task LoadConfigurationFromFileAsync(string filePath, Dictionary<string, string> config, CancellationToken cancellationToken)
    {
        try
        {
            var content = await File.ReadAllTextAsync(filePath, cancellationToken);
            var settings = JsonNode.Parse(content)?.AsObject();
            
            if (settings is not null)
            {
                FlattenJsonObject(settings, config, string.Empty);
            }
        }
        catch
        {
            // Ignore errors reading configuration files
        }
    }
 
    /// <summary>
    /// Sets a nested value in a JsonObject using dot notation.
    /// Creates intermediate objects as needed and replaces primitives with objects when necessary.
    /// </summary>
    private static void SetNestedValue(JsonObject settings, string key, string value)
    {
        var keyParts = key.Split('.');
        var currentObject = settings;
 
        // Navigate to the parent object, creating objects as needed
        for (int i = 0; i < keyParts.Length - 1; i++)
        {
            var part = keyParts[i];
            
            // If the property doesn't exist or isn't an object, replace it with a new object
            if (!currentObject.ContainsKey(part) || currentObject[part] is not JsonObject)
            {
                currentObject[part] = new JsonObject();
            }
            
            currentObject = currentObject[part]!.AsObject();
        }
 
        // Set the final value
        var finalKey = keyParts[keyParts.Length - 1];
        currentObject[finalKey] = value;
    }
 
    /// <summary>
    /// Deletes a nested value from a JsonObject using dot notation.
    /// Cleans up empty parent objects after deletion.
    /// </summary>
    private static bool DeleteNestedValue(JsonObject settings, string key)
    {
        var keyParts = key.Split('.');
        var currentObject = settings;
        var objectPath = new List<(JsonObject obj, string key)>();
 
        // Navigate to the target value, keeping track of the path
        for (int i = 0; i < keyParts.Length - 1; i++)
        {
            var part = keyParts[i];
            objectPath.Add((currentObject, part));
            
            if (!currentObject.ContainsKey(part) || currentObject[part] is not JsonObject)
            {
                return false; // Path doesn't exist
            }
            
            currentObject = currentObject[part]!.AsObject();
        }
 
        var finalKey = keyParts[keyParts.Length - 1];
        
        // Check if the final key exists
        if (!currentObject.ContainsKey(finalKey))
        {
            return false;
        }
 
        // Remove the final key
        currentObject.Remove(finalKey);
 
        // Clean up empty parent objects, working backwards
        for (int i = objectPath.Count - 1; i >= 0; i--)
        {
            var (parentObject, parentKey) = objectPath[i];
            
            // If the current object is empty, remove it from its parent
            if (currentObject.Count == 0)
            {
                parentObject.Remove(parentKey);
                currentObject = parentObject;
            }
            else
            {
                break; // Stop cleanup if we encounter a non-empty object
            }
        }
 
        return true;
    }
 
    /// <summary>
    /// Recursively flattens a JsonObject into a dictionary with dot notation keys.
    /// </summary>
    private static void FlattenJsonObject(JsonObject obj, Dictionary<string, string> result, string prefix)
    {
        foreach (var kvp in obj)
        {
            var key = string.IsNullOrEmpty(prefix) ? kvp.Key : $"{prefix}.{kvp.Key}";
            
            if (kvp.Value is JsonObject nestedObj)
            {
                FlattenJsonObject(nestedObj, result, key);
            }
            else if (kvp.Value is not null)
            {
                result[key] = kvp.Value.ToString();
            }
        }
    }
 
    public Task<string?> GetConfigurationAsync(string key, CancellationToken cancellationToken = default)
    {
        // Convert dot notation to colon notation for IConfiguration access
        var configKey = key.Replace('.', ':');
        return Task.FromResult(configuration[configKey]);
    }
}