From 8f02d2f93369c7ce52a81f2b0e805e69ff8389d2 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Sun, 27 Sep 2026 17:26:07 +0000 Subject: [PATCH 1/2] [patch] Keep both values when two keys standardize to the same name NameStandardizer wrote each renamed property with a plain indexer, so when two keys resolved to one standard name (author + creator, Title + title) the later silently overwrote the earlier. With the default options a document carrying both author and creator lost its author. A key is now renamed only when its standard name is free: not already written and not the exact name of a key still to come. Otherwise it keeps its original name. The lower-casing branch goes through the same guard. Fixes ktsu-dev/Frontmatter#136 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KrGyxYxnoFgAkP1ENResCJ --- .../StandardNamingCollisionTests.cs | 80 +++++++++++++++++++ Frontmatter/NameStandardizer.cs | 77 ++++++++---------- 2 files changed, 113 insertions(+), 44 deletions(-) create mode 100644 Frontmatter.Test/StandardNamingCollisionTests.cs diff --git a/Frontmatter.Test/StandardNamingCollisionTests.cs b/Frontmatter.Test/StandardNamingCollisionTests.cs new file mode 100644 index 0000000..872a72f --- /dev/null +++ b/Frontmatter.Test/StandardNamingCollisionTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter.Test; + +using System.Collections.Generic; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Regression tests for #136: when two keys standardize to the same name, standard naming must keep +/// both values instead of letting one overwrite the other. +/// +[TestClass] +public class StandardNamingCollisionTests +{ + [TestMethod] + public void DefaultOptions_AuthorAndCreatorListOfDifferentType_KeepsBothValues() + { + const string input = "---\nauthor: Alice\ncreator:\n - Bob\n---\nbody\n"; + + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(Frontmatter.CombineFrontmatter(input)); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual("Alice", frontmatter["author"]); + Assert.IsTrue(frontmatter.ContainsKey("creator"), "creator's list must survive alongside author"); + CollectionAssert.AreEqual(new object[] { "Bob" }, (System.Collections.ICollection)frontmatter["creator"]); + } + + [TestMethod] + public void NoMerge_AuthorAndCreator_KeepsBothValues() + { + const string input = "---\nauthor: Alice\ncreator: Bob\n---\nbody\n"; + + string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.Standard, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None); + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(result); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual("Alice", frontmatter["author"]); + Assert.AreEqual("Bob", frontmatter["creator"]); + } + + [TestMethod] + public void NoMerge_TitleCasedAndLowerCased_KeepsBothValues() + { + const string input = "---\nTitle: A\ntitle: B\n---\nbody\n"; + + string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.Standard, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None); + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(result); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual("A", frontmatter["Title"]); + Assert.AreEqual("B", frontmatter["title"]); + } + + [TestMethod] + public void StandardizePropertyNames_AliasBeforeStandardName_KeepsAliasUnchanged() + { + Dictionary result = NameStandardizer.StandardizePropertyNames(new Dictionary + { + ["creator"] = "Bob", + ["author"] = "Alice", + }); + + Assert.HasCount(2, result); + Assert.AreEqual("Bob", result["creator"]); + Assert.AreEqual("Alice", result["author"]); + } + + [TestMethod] + public void StandardizePropertyNames_LoneAlias_IsStillRenamed() + { + Dictionary result = NameStandardizer.StandardizePropertyNames(new Dictionary + { + ["creator"] = "Bob", + }); + + Assert.HasCount(1, result); + Assert.AreEqual("Bob", result["author"]); + } +} diff --git a/Frontmatter/NameStandardizer.cs b/Frontmatter/NameStandardizer.cs index b9c69bc..903933c 100644 --- a/Frontmatter/NameStandardizer.cs +++ b/Frontmatter/NameStandardizer.cs @@ -27,59 +27,48 @@ internal static Dictionary StandardizePropertyNames(Dictionary standardizedFrontmatter = []; + KeyValuePair[] properties = [.. frontmatter]; - foreach (KeyValuePair property in frontmatter) + for (int i = 0; i < properties.Length; i++) { - // Skip if it's already a standard property name - if (Array.Exists(standardProperties, p => string.Equals(p, property.Key, StringComparison.OrdinalIgnoreCase))) - { - standardizedFrontmatter[property.Key.ToLowerInvariant()] = property.Value; - continue; - } + KeyValuePair property = properties[i]; + string standardName = GetStandardName(property.Key, standardProperties); - // Check if we've already processed this property name before - if (PropertyNameCache.TryGetValue(property.Key, out string? mappedName)) - { - // If the mapped name is the same as the original, it means we previously determined - // there's no good match, so keep the original - if (mappedName == property.Key) - { - standardizedFrontmatter[property.Key] = property.Value; - } - else - { - // Use the previously matched property name - standardizedFrontmatter[mappedName] = property.Value; - } + // Two keys can resolve to the same standard name (author + creator, Title + title). Only + // rename a key when its standard name is free: not already written, and not the exact name + // of a key still to come. Otherwise keep the original key, so neither value is lost. + bool isFree = standardName == property.Key || + (!standardizedFrontmatter.ContainsKey(standardName) && + !properties.Skip(i + 1).Any(later => later.Key == standardName)); - continue; - } + standardizedFrontmatter[isFree ? standardName : property.Key] = property.Value; + } - // Try to find a match in known property mappings - string? knownMapping = FindKnownPropertyMapping(property.Key); - if (knownMapping != null) - { - PropertyNameCache.TryAdd(property.Key, knownMapping); - standardizedFrontmatter[knownMapping] = property.Value; - continue; - } + return standardizedFrontmatter; + } - // Try to find a match by removing common prefixes and suffixes - string normalizedKey = NormalizePropertyName(property.Key); - string? standardMatch = FindStandardPropertyMatch(normalizedKey, standardProperties); - if (standardMatch != null) - { - PropertyNameCache.TryAdd(property.Key, standardMatch); - standardizedFrontmatter[standardMatch] = property.Value; - continue; - } + private static string GetStandardName(string key, string[] standardProperties) + { + // Already a standard property name, possibly in a different case + if (Array.Exists(standardProperties, p => string.Equals(p, key, StringComparison.OrdinalIgnoreCase))) + { + return key.ToLowerInvariant(); + } - // If no match found, preserve the original property name - PropertyNameCache.TryAdd(property.Key, property.Key); - standardizedFrontmatter[property.Key] = property.Value; + // Check if we've already processed this property name before + if (PropertyNameCache.TryGetValue(key, out string? mappedName)) + { + return mappedName; } - return standardizedFrontmatter; + // Try to find a match in known property mappings, then by removing common prefixes and + // suffixes. If no match is found, preserve the original property name. + string standardName = FindKnownPropertyMapping(key) + ?? FindStandardPropertyMatch(NormalizePropertyName(key), standardProperties) + ?? key; + + PropertyNameCache.TryAdd(key, standardName); + return standardName; } private static string? FindKnownPropertyMapping(string key) From 00e3ae1f171ee91db8b95d662a6a54af15d700ee Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Sun, 27 Sep 2026 17:27:34 +0000 Subject: [PATCH 2/2] Use TryGetValue instead of ContainsKey plus indexer in collision test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KrGyxYxnoFgAkP1ENResCJ --- Frontmatter.Test/StandardNamingCollisionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Frontmatter.Test/StandardNamingCollisionTests.cs b/Frontmatter.Test/StandardNamingCollisionTests.cs index 872a72f..fddf5a7 100644 --- a/Frontmatter.Test/StandardNamingCollisionTests.cs +++ b/Frontmatter.Test/StandardNamingCollisionTests.cs @@ -22,8 +22,8 @@ public void DefaultOptions_AuthorAndCreatorListOfDifferentType_KeepsBothValues() Assert.IsNotNull(frontmatter); Assert.AreEqual("Alice", frontmatter["author"]); - Assert.IsTrue(frontmatter.ContainsKey("creator"), "creator's list must survive alongside author"); - CollectionAssert.AreEqual(new object[] { "Bob" }, (System.Collections.ICollection)frontmatter["creator"]); + Assert.IsTrue(frontmatter.TryGetValue("creator", out object? creator), "creator's list must survive alongside author"); + CollectionAssert.AreEqual(new object[] { "Bob" }, (System.Collections.ICollection)creator); } [TestMethod]