diff --git a/src/Aspire.Cli/Configuration/FlexibleBooleanConverter.cs b/src/Aspire.Cli/Configuration/FlexibleBooleanConverter.cs new file mode 100644 index 00000000000..5e75f0d04d3 --- /dev/null +++ b/src/Aspire.Cli/Configuration/FlexibleBooleanConverter.cs @@ -0,0 +1,42 @@ +// 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.Serialization; + +namespace Aspire.Cli.Configuration; + +/// +/// A JSON converter for that accepts both actual boolean values +/// (true/false) and string representations ("true"/"false"). +/// This provides backward compatibility for settings files that may have string values +/// written by older CLI versions. +/// +internal sealed class FlexibleBooleanConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.String => ParseString(reader.GetString()), + _ => throw new JsonException($"Unexpected token parsing boolean. Token: {reader.TokenType}") + }; + } + + private static bool ParseString(string? value) + { + if (bool.TryParse(value, out var result)) + { + return result; + } + + throw new JsonException($"Invalid boolean value: '{value}'. Expected 'true' or 'false'."); + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } +} diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs index ad23050e888..b0fadbcf717 100644 --- a/src/Aspire.Cli/JsonSourceGenerationContext.cs +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -18,7 +18,8 @@ namespace Aspire.Cli; WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip)] + ReadCommentHandling = JsonCommentHandling.Skip, + Converters = [typeof(FlexibleBooleanConverter)])] [JsonSerializable(typeof(CliSettings))] [JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(ListIntegrationsResponse))] diff --git a/src/Aspire.Cli/Utils/ConfigurationHelper.cs b/src/Aspire.Cli/Utils/ConfigurationHelper.cs index 8d4e4b2235f..354a0bb0696 100644 --- a/src/Aspire.Cli/Utils/ConfigurationHelper.cs +++ b/src/Aspire.Cli/Utils/ConfigurationHelper.cs @@ -154,13 +154,15 @@ internal static bool TryNormalizeSettingsFile(string filePath) } // Find all colon-separated keys at root level - var colonKeys = new List<(string key, string? value)>(); + var colonKeys = new List<(string key, JsonNode? value)>(); foreach (var kvp in settings) { if (kvp.Key.Contains(':')) { - colonKeys.Add((kvp.Key, kvp.Value?.ToString())); + // DeepClone preserves the original JSON type (boolean, number, etc.) + // instead of converting to string via ToString(). + colonKeys.Add((kvp.Key, kvp.Value?.DeepClone())); } } diff --git a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs index ec3d9c4d6f6..f7556f11b89 100644 --- a/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/AspireConfigFileTests.cs @@ -306,6 +306,69 @@ public void GetIntegrationReferences_IncludesPackagesAndBasePackage() Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis"); } + [Fact] + public void Load_ReturnsConfig_WhenFeaturesAreBooleans() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + File.WriteAllText(configPath, """ + { + "features": { "polyglotSupportEnabled": true, "showAllTemplates": false } + } + """); + + var result = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + + Assert.NotNull(result); + Assert.NotNull(result.Features); + Assert.True(result.Features["polyglotSupportEnabled"]); + Assert.False(result.Features["showAllTemplates"]); + } + + [Fact] + public void Load_ReturnsConfig_WhenFeaturesAreStringBooleans() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Simulates what happens when ConfigurationService.SetNestedValue wrote "true"/"false" as strings + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + File.WriteAllText(configPath, """ + { + "features": { "polyglotSupportEnabled": "true", "showAllTemplates": "false" } + } + """); + + var result = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + + Assert.NotNull(result); + Assert.NotNull(result.Features); + Assert.True(result.Features["polyglotSupportEnabled"]); + Assert.False(result.Features["showAllTemplates"]); + } + + [Fact] + public void Save_Load_RoundTrips_WithFeatures() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var config = new AspireConfigFile + { + Features = new Dictionary + { + ["polyglotSupportEnabled"] = true, + ["showAllTemplates"] = false + } + }; + + config.Save(workspace.WorkspaceRoot.FullName); + var loaded = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + + Assert.NotNull(loaded?.Features); + Assert.True(loaded.Features["polyglotSupportEnabled"]); + Assert.False(loaded.Features["showAllTemplates"]); + } + [Fact] public void Load_RoundTrips_WithProfiles() { diff --git a/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs b/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs index cdea30079d2..f65125ca022 100644 --- a/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/ConfigurationHelperTests.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 Aspire.Cli.Configuration; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; @@ -108,4 +110,35 @@ public void RegisterSettingsFiles_HandlesCommentsAndTrailingCommas() Assert.Equal("MyApp.csproj", config["appHost:path"]); Assert.Equal("daily", config["channel"]); } + + [Fact] + public void TryNormalizeSettingsFile_PreservesBooleanTypes() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var settingsPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + // File has a colon-separated key with a boolean value + File.WriteAllText(settingsPath, """ + { + "features:polyglotSupportEnabled": true, + "features:showAllTemplates": false + } + """); + + var normalized = ConfigurationHelper.TryNormalizeSettingsFile(settingsPath); + + Assert.True(normalized); + + var json = JsonNode.Parse(File.ReadAllText(settingsPath)); + var polyglotNode = json!["features"]!["polyglotSupportEnabled"]; + var templatesNode = json!["features"]!["showAllTemplates"]; + Assert.Equal(JsonValueKind.True, polyglotNode!.GetValueKind()); + Assert.Equal(JsonValueKind.False, templatesNode!.GetValueKind()); + + // Verify the file can be loaded by AspireConfigFile without error + var config = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + Assert.NotNull(config?.Features); + Assert.True(config.Features["polyglotSupportEnabled"]); + Assert.False(config.Features["showAllTemplates"]); + } } diff --git a/tests/Aspire.Cli.Tests/Configuration/ConfigurationServiceTests.cs b/tests/Aspire.Cli.Tests/Configuration/ConfigurationServiceTests.cs index 8b10267db2b..f0ff9161d10 100644 --- a/tests/Aspire.Cli.Tests/Configuration/ConfigurationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/ConfigurationServiceTests.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 Aspire.Cli.Configuration; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Configuration; @@ -225,4 +227,64 @@ public async Task SetConfigurationAsync_SetsNestedValues() Assert.Contains("appHost", result); Assert.Contains("MyApp/MyApp.csproj", result); } + + [Fact] + public async Task SetConfigurationAsync_WritesBooleanStringAsJsonString() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var (service, settingsFilePath) = CreateService(workspace, "{}"); + + await service.SetConfigurationAsync("features.polyglotSupportEnabled", "true", isGlobal: false); + + // Value is written as a JSON string "true", not a JSON boolean true. + // The FlexibleBooleanConverter handles parsing "true" -> bool on read. + var json = JsonNode.Parse(File.ReadAllText(settingsFilePath)); + var node = json!["features"]!["polyglotSupportEnabled"]; + Assert.Equal(JsonValueKind.String, node!.GetValueKind()); + Assert.Equal("true", node.GetValue()); + + // Verify round-trip through AspireConfigFile.Load still works + var config = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + Assert.NotNull(config?.Features); + Assert.True(config.Features["polyglotSupportEnabled"]); + } + + [Fact] + public async Task SetConfigurationAsync_ChannelWithBooleanLikeValue_StaysAsString() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var (service, settingsFilePath) = CreateService(workspace, "{}"); + + // "true" is a valid channel value and must remain a string in JSON + // to avoid corrupting the string-typed Channel property. + await service.SetConfigurationAsync("channel", "true", isGlobal: false); + + // Must be a JSON string "true", not a JSON boolean true + var json = JsonNode.Parse(File.ReadAllText(settingsFilePath)); + var node = json!["channel"]; + Assert.Equal(JsonValueKind.String, node!.GetValueKind()); + Assert.Equal("true", node.GetValue()); + + // Verify it round-trips correctly through AspireConfigFile.Load + var config = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName); + Assert.NotNull(config); + Assert.Equal("true", config.Channel); + } + + [Fact] + public async Task SetConfigurationAsync_WritesStringValueAsString() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var (service, settingsFilePath) = CreateService(workspace, "{}"); + + await service.SetConfigurationAsync("channel", "daily", isGlobal: false); + + var json = JsonNode.Parse(File.ReadAllText(settingsFilePath)); + var node = json!["channel"]; + Assert.Equal(JsonValueKind.String, node!.GetValueKind()); + Assert.Equal("daily", node.GetValue()); + } }