diff --git a/Frontmatter.Test/PropertyMergerTests.cs b/Frontmatter.Test/PropertyMergerTests.cs index 4314c03..200b1a5 100644 --- a/Frontmatter.Test/PropertyMergerTests.cs +++ b/Frontmatter.Test/PropertyMergerTests.cs @@ -769,6 +769,6 @@ public void CombineFrontmatter_SingleBlockWithRepeatedListItems_RoundTripsTheLis Assert.IsNotNull(frontmatter); CollectionAssert.AreEqual(new object[] { "mix", "wait", "mix" }, (System.Collections.ICollection)frontmatter["steps"]); - CollectionAssert.AreEqual(new object[] { "1", "1", "2" }, (System.Collections.ICollection)frontmatter["scores"]); + CollectionAssert.AreEqual(new long[] { 1, 1, 2 }, ((System.Collections.IEnumerable)frontmatter["scores"]).Cast().Select(score => Convert.ToInt64(score, System.Globalization.CultureInfo.InvariantCulture)).ToArray()); } } diff --git a/Frontmatter.Test/ScalarTypeRoundTripTests.cs b/Frontmatter.Test/ScalarTypeRoundTripTests.cs new file mode 100644 index 0000000..eff7760 --- /dev/null +++ b/Frontmatter.Test/ScalarTypeRoundTripTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter.Test; + +using System.Collections.Generic; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using YamlDotNet.Serialization; + +/// +/// Regression tests for #137: a round trip must keep what each scalar means. Quoted strings that look +/// like other types stay strings, plain scalars keep their types, and nulls stay null. +/// +[TestClass] +public class ScalarTypeRoundTripTests +{ + private static readonly string Nl = Environment.NewLine; + + private static readonly IDeserializer TypedReader = new DeserializerBuilder() + .WithAttemptingUnquotedStringTypeDeserialization() + .Build(); + + private static Dictionary ReadHeader(string document) + { + string[] parts = document.Split(["---"], 3, StringSplitOptions.None); + return TypedReader.Deserialize>(parts[1]); + } + + [TestMethod] + [DataRow(FrontmatterMergeStrategy.None)] + [DataRow(FrontmatterMergeStrategy.Conservative)] + public void CombineFrontmatter_QuotedLookalikesNullsAndBooleans_KeepTheirTypes(FrontmatterMergeStrategy strategy) + { + string input = $"---{Nl}title: \"true\"{Nl}version: \"1.0\"{Nl}zip: \"01234\"{Nl}null_str: \"null\"{Nl}empty:{Nl}draft: false{Nl}count: 3{Nl}---{Nl}body{Nl}"; + + string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, strategy); + Dictionary header = ReadHeader(result); + + Assert.AreEqual("true", header["title"], result); + Assert.AreEqual("1.0", header["version"], result); + Assert.AreEqual("01234", header["zip"], result); + Assert.AreEqual("null", header["null_str"], result); + Assert.IsTrue(header.TryGetValue("empty", out object? emptyValue), result); + Assert.IsNull(emptyValue, result); + Assert.IsInstanceOfType(header["draft"], result); + Assert.IsFalse((bool)header["draft"]!, result); + Assert.AreEqual(3L, Convert.ToInt64(header["count"], System.Globalization.CultureInfo.InvariantCulture), result); + Assert.Contains($"draft: false{Nl}", result); + } + + [TestMethod] + public void AddFrontmatter_StringsThatLookLikeOtherTypes_AreWrittenQuoted() + { + string result = Frontmatter.AddFrontmatter("body", new Dictionary { ["title"] = "true", ["id"] = "007" }); + Dictionary header = ReadHeader(result); + + Assert.AreEqual("true", header["title"], result); + Assert.AreEqual("007", header["id"], result); + } + + [TestMethod] + public void ExtractFrontmatter_PlainAndQuotedScalars_KeepDistinctTypes() + { + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter($"---{Nl}draft: false{Nl}label: \"false\"{Nl}---{Nl}body{Nl}"); + + Assert.IsNotNull(frontmatter); + Assert.IsInstanceOfType(frontmatter["draft"]); + Assert.IsFalse((bool)frontmatter["draft"]); + Assert.AreEqual("false", frontmatter["label"]); + } +} diff --git a/Frontmatter/PropertyMerger.cs b/Frontmatter/PropertyMerger.cs index baa50d2..121fb4c 100644 --- a/Frontmatter/PropertyMerger.cs +++ b/Frontmatter/PropertyMerger.cs @@ -125,6 +125,12 @@ private static void MergePropertyGroup( object firstValue = source[firstKey]; if (firstValue == null) { + // A null has no type to merge on, so every key keeps its own value rather than being dropped. + foreach (string key in originalKeys) + { + target[key] = source[key]; + } + return; } @@ -138,7 +144,7 @@ private static void MergePropertyGroup( // If types are different, keep all properties separate foreach (string key in originalKeys) { - target[key] = source[key] ?? throw new InvalidOperationException($"Value for key {key} is null"); + target[key] = source[key]; } return; diff --git a/Frontmatter/YamlSerializer.cs b/Frontmatter/YamlSerializer.cs index b82ab6e..5552aff 100644 --- a/Frontmatter/YamlSerializer.cs +++ b/Frontmatter/YamlSerializer.cs @@ -27,6 +27,7 @@ public static class YamlSerializer private static readonly IDeserializer Deserializer = new DeserializerBuilder() .WithNamingConvention(NullNamingConvention.Instance) .IgnoreUnmatchedProperties() + .WithAttemptingUnquotedStringTypeDeserialization() .Build(); /// @@ -35,6 +36,7 @@ public static class YamlSerializer private static readonly ISerializer Serializer = new SerializerBuilder() .WithNamingConvention(NullNamingConvention.Instance) .ConfigureDefaultValuesHandling(DefaultValuesHandling.Preserve) + .WithQuotingNecessaryStrings() .Build(); /// @@ -159,7 +161,9 @@ private static object ConvertValue(object? value) { return value switch { - null => string.Empty, + // A null stays null so it is written back as a null rather than as an empty string. The + // dictionaries this feeds are typed as non-nullable for compatibility, so the null is forgiven. + null => null!, Dictionary dict => dict.ToDictionary( kvp => kvp.Key?.ToString() ?? string.Empty, kvp => ConvertValue(kvp.Value)),