diff --git a/src/Shared/JExtensions.cs b/src/Shared/JExtensions.cs index 131d3f11560..819812544f7 100644 --- a/src/Shared/JExtensions.cs +++ b/src/Shared/JExtensions.cs @@ -17,7 +17,6 @@ namespace Microsoft.TemplateEngine internal static class JExtensions { private static readonly JsonDocumentOptions DocOptions = new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }; - private static readonly JsonNodeOptions NodeOptions = new() { PropertyNameCaseInsensitive = true }; private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true, @@ -387,7 +386,7 @@ internal static JsonObject ReadJObjectFromIFile(this IFile file) using Stream s = file.OpenRead(); using TextReader tr = new StreamReader(s, System.Text.Encoding.UTF8, true); string json = tr.ReadToEnd(); - return (JsonObject?)JsonNode.Parse(json, NodeOptions, DocOptions) + return (JsonObject?)JsonNode.Parse(json, null, DocOptions) ?? throw new InvalidOperationException("Failed to parse JSON from file."); } @@ -396,7 +395,7 @@ internal static JsonObject ReadObject(this IPhysicalFileSystem fileSystem, strin using Stream fileStream = fileSystem.OpenRead(path); using var textReader = new StreamReader(fileStream, System.Text.Encoding.UTF8, true); string json = textReader.ReadToEnd(); - return (JsonObject?)JsonNode.Parse(json, NodeOptions, DocOptions) + return (JsonObject?)JsonNode.Parse(json, null, DocOptions) ?? throw new InvalidOperationException($"Failed to parse JSON from '{path}'."); } @@ -480,7 +479,7 @@ internal static bool TryGetValueCaseInsensitive(this JsonObject obj, string key, /// internal static JsonObject ParseJsonObject(string json) { - return (JsonObject?)JsonNode.Parse(json, NodeOptions, DocOptions) + return (JsonObject?)JsonNode.Parse(json, null, DocOptions) ?? throw new InvalidOperationException("Failed to parse JSON string as JsonObject."); } @@ -489,7 +488,7 @@ internal static JsonObject ParseJsonObject(string json) /// internal static JsonNode? ParseJsonNode(string json) { - return JsonNode.Parse(json, NodeOptions, DocOptions); + return JsonNode.Parse(json, null, DocOptions); } /// @@ -503,7 +502,7 @@ internal static JsonObject ParseJsonObject(string json) internal static JsonObject FromObject(object obj) { string json = JsonSerializer.Serialize(obj, SerializerOptions); - return (JsonObject?)JsonNode.Parse(json, NodeOptions, DocOptions) + return (JsonObject?)JsonNode.Parse(json, null, DocOptions) ?? throw new InvalidOperationException("Failed to round-trip object to JsonObject."); } @@ -512,7 +511,7 @@ internal static JsonObject FromObject(object obj) /// internal static JsonObject DeepCloneObject(this JsonObject source) { - return (JsonObject?)JsonNode.Parse(source.ToJsonString(), NodeOptions, DocOptions) + return (JsonObject?)JsonNode.Parse(source.ToJsonString(), null, DocOptions) ?? throw new InvalidOperationException("Failed to deep clone JsonObject."); } diff --git a/test/Microsoft.TemplateEngine.Orchestrator.RunnableProjects.UnitTests/TemplateConfigTests/GenericTests.cs b/test/Microsoft.TemplateEngine.Orchestrator.RunnableProjects.UnitTests/TemplateConfigTests/GenericTests.cs index 0b0383ca8fe..7e59609cbf3 100644 --- a/test/Microsoft.TemplateEngine.Orchestrator.RunnableProjects.UnitTests/TemplateConfigTests/GenericTests.cs +++ b/test/Microsoft.TemplateEngine.Orchestrator.RunnableProjects.UnitTests/TemplateConfigTests/GenericTests.cs @@ -54,5 +54,44 @@ public void CanReadTemplateFromStream() Assert.Equal(2, templateConfigModel.PrimaryOutputs.Count); Assert.Equal(new[] { "bar.cs", "bar/bar.cs" }, templateConfigModel.PrimaryOutputs.Select(po => po.Path).OrderBy(po => po)); } + + [Fact] + public void CanReadTemplateWithDuplicateCaseInsensitiveSymbolKeys() + { + // Regression test: template.json with symbols that differ only by case + // (e.g. "Empty" and "empty") should load without throwing. + // See https://github.com/dotnet/templating/issues/10047 + string templateWithDuplicateKeys = /*lang=json*/ """ + { + "author": "Test Asset", + "classifications": [ "Test Asset" ], + "name": "TemplateWithDuplicateKeys", + "identity": "TestAssets.TemplateWithDuplicateKeys", + "shortName": "dupkeys", + "symbols": { + "Empty": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "PascalCase variant" + }, + "empty": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "lowercase variant" + } + } + } + """; + + var exception = Record.Exception(() => TemplateConfigModel.FromString(templateWithDuplicateKeys)); + Assert.Null(exception); + + TemplateConfigModel configModel = TemplateConfigModel.FromString(templateWithDuplicateKeys); + Assert.Equal("TemplateWithDuplicateKeys", configModel.Name); + // Both symbols should be accessible (last-in-wins for case-sensitive dict, both kept) + Assert.NotEmpty(configModel.Symbols); + } } }