From 6eb68a349db11ab4bff279c5680d073aad170f5f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:30:28 +0000 Subject: [PATCH 1/3] Format persistence keys with the invariant culture FileSystem and Temp built file names with key.ToString(), which uses the current culture, while GetAllKeysAsync parsed them back with the invariant culture. Under de-DE a double key 1.5 was stored as 1,5 and listed as 15, and data stored under one culture could not be found under another. Both providers now format keys through PersistenceProviderUtilities.FormatKey, which uses round-trip formats, and TryConvertToKey parses DateTime and DateTimeOffset keys with round-trip kind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CzC1o7LFYHYaoWmVy1NLcp --- CLAUDE.md | 2 +- .../FileSystemPersistenceProvider.cs | 2 +- .../TempPersistenceProvider.cs | 2 +- Essentials.Tests/PersistenceNamingTests.cs | 116 ++++++++++++++++++ Essentials/PersistenceProviderUtilities.cs | 46 +++++++ 5 files changed, 165 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 298d4b5..f15b838 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ This is a .NET library (`ktsu.Essentials`) providing high-performance interfaces - `Essentials/ICommandExecutor.cs` - Shell command execution interface; `Execute(command, environmentVariables, workingDirectory, cancellationToken)` is the synchronous primitive that every other synchronous member composes over - `Essentials/IFileSystemProvider.cs` - Filesystem abstraction extending Testably.Abstractions - `Essentials/ProviderHelpers.cs` - Internal utilities for async wrapping, stream bridging, UTF8 transforms -- `Essentials/PersistenceProviderUtilities.cs` - Shared utilities for persistence providers (safe filenames, key conversion) +- `Essentials/PersistenceProviderUtilities.cs` - Shared utilities for persistence providers (safe filenames, culture-independent key formatting via `FormatKey`, and key conversion back from a filename) - `Essentials/PersistenceProviderException.cs` - Custom exception for persistence operations ### Provider Implementations (in solution) diff --git a/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs b/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs index 62d9de2..c89b913 100644 --- a/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs +++ b/Essentials.PersistenceProviders.FileSystem/FileSystemPersistenceProvider.cs @@ -227,7 +227,7 @@ public Task ClearAsync(CancellationToken cancellationToken = default) private string GetFilePath(TKey key) { - string fileName = PersistenceProviderUtilities.GetSafeFileName(key.ToString()!) + _serializationProvider.FileExtension; + string fileName = PersistenceProviderUtilities.GetSafeFileName(PersistenceProviderUtilities.FormatKey(key)) + _serializationProvider.FileExtension; return _fileSystemProvider.Path.Combine(BaseDirectory, fileName); } } diff --git a/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs b/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs index d0ad67f..3649650 100644 --- a/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs +++ b/Essentials.PersistenceProviders.Temp/TempPersistenceProvider.cs @@ -251,7 +251,7 @@ public void CleanupDirectory() private string GetFilePath(TKey key) { - string fileName = PersistenceProviderUtilities.GetSafeFileName(key.ToString()!) + _serializationProvider.FileExtension; + string fileName = PersistenceProviderUtilities.GetSafeFileName(PersistenceProviderUtilities.FormatKey(key)) + _serializationProvider.FileExtension; return _fileSystemProvider.Path.Combine(_tempDirectory, fileName); } diff --git a/Essentials.Tests/PersistenceNamingTests.cs b/Essentials.Tests/PersistenceNamingTests.cs index 6dd81ff..aed71a5 100644 --- a/Essentials.Tests/PersistenceNamingTests.cs +++ b/Essentials.Tests/PersistenceNamingTests.cs @@ -4,11 +4,13 @@ namespace ktsu.Essentials.Tests; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using ktsu.Essentials; using ktsu.Essentials.FileSystemProviders.Native; using ktsu.Essentials.PersistenceProviders.FileSystem; +using ktsu.Essentials.PersistenceProviders.Temp; using ktsu.Essentials.SerializationProviders.Json; using ktsu.Essentials.SerializationProviders.Toml; using ktsu.Essentials.SerializationProviders.Yaml; @@ -163,6 +165,120 @@ public async Task Colliding_Keys_Do_Not_Overwrite_Each_Other() } } + private static readonly double[] CultureSensitiveDoubleKeys = [1.5, 0.1, -2.25e-7, 1234567.875]; + + private static readonly DateTime[] CultureSensitiveDateKeys = + [ + new(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc), + new DateTime(2026, 12, 31, 23, 59, 59, 123, DateTimeKind.Unspecified).AddTicks(4567), + ]; + + [TestMethod] + public void FormatKey_Ignores_The_Current_Culture() + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CommaDecimalDayFirstCulture(); + + Assert.AreEqual("1.5", PersistenceProviderUtilities.FormatKey(1.5)); + Assert.AreEqual("2026-03-04T05:06:07.0000000Z", PersistenceProviderUtilities.FormatKey(new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc))); + Assert.AreEqual("a,b", PersistenceProviderUtilities.FormatKey("a,b")); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [TestMethod] + public async Task FileSystem_Double_And_Date_Keys_Round_Trip_Across_Cultures() + { + string dir = Path.Combine(Path.GetTempPath(), "NamingTests_" + Guid.NewGuid().ToString("N")[..8]); + try + { + NativeFileSystemProvider fs = new(); + JsonSerializationProvider serializer = new(); + await AssertKeysRoundTripAcrossCultures(new FileSystemPersistenceProvider(fs, serializer, dir), CultureSensitiveDoubleKeys).ConfigureAwait(false); + await AssertKeysRoundTripAcrossCultures(new FileSystemPersistenceProvider(fs, serializer, Path.Combine(dir, "dates")), CultureSensitiveDateKeys).ConfigureAwait(false); + } + finally + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, true); + } + } + } + + [TestMethod] + public async Task Temp_Double_And_Date_Keys_Round_Trip_Across_Cultures() + { + NativeFileSystemProvider fs = new(); + JsonSerializationProvider serializer = new(); + + using TempPersistenceProvider doubles = new(fs, serializer, "NamingTests"); + await AssertKeysRoundTripAcrossCultures(doubles, CultureSensitiveDoubleKeys).ConfigureAwait(false); + + using TempPersistenceProvider dates = new(fs, serializer, "NamingTests"); + await AssertKeysRoundTripAcrossCultures(dates, CultureSensitiveDateKeys).ConfigureAwait(false); + } + + /// + /// Stores each key under a culture whose decimal separator and date order differ from the invariant + /// culture, then checks that enumeration lists exactly those keys and that each can still be + /// retrieved after the process switches to a culture that formats both differently again. + /// + private async Task AssertKeysRoundTripAcrossCultures(IPersistenceProvider persistence, TKey[] keys) where TKey : notnull + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CommaDecimalDayFirstCulture(); + for (int i = 0; i < keys.Length; i++) + { + await persistence.StoreAsync(keys[i], $"value {i}", TestContext.CancellationToken).ConfigureAwait(false); + } + + TKey[] listed = [.. await persistence.GetAllKeysAsync(TestContext.CancellationToken).ConfigureAwait(false)]; + CollectionAssert.AreEquivalent(keys, listed, "GetAllKeys should list the stored keys, not culture-mangled ones"); + + CultureInfo.CurrentCulture = MonthFirstTwelveHourCulture(); + for (int i = 0; i < keys.Length; i++) + { + string? value = await persistence.RetrieveAsync(keys[i], TestContext.CancellationToken).ConfigureAwait(false); + Assert.AreEqual($"value {i}", value, $"Key '{keys[i]}' stored under de-DE should be found under another culture"); + } + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + /// + /// A culture formatted like de-DE: a comma decimal separator and day-first dates. It is built from + /// the invariant culture so the tests do not depend on the machine's ICU data. + /// + private static CultureInfo CommaDecimalDayFirstCulture() + { + CultureInfo culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + culture.NumberFormat.NumberDecimalSeparator = ","; + culture.NumberFormat.NumberGroupSeparator = "."; + culture.DateTimeFormat.ShortDatePattern = "dd.MM.yyyy"; + culture.DateTimeFormat.LongTimePattern = "HH:mm:ss"; + return culture; + } + + /// A culture formatted like en-US: month-first dates and a twelve-hour clock. + private static CultureInfo MonthFirstTwelveHourCulture() + { + CultureInfo culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + culture.DateTimeFormat.ShortDatePattern = "M/d/yyyy"; + culture.DateTimeFormat.LongTimePattern = "h:mm:ss tt"; + return culture; + } + [TestMethod] public void Serializers_Report_Their_Own_Extension() { diff --git a/Essentials/PersistenceProviderUtilities.cs b/Essentials/PersistenceProviderUtilities.cs index bc558c4..4ce4e10 100644 --- a/Essentials/PersistenceProviderUtilities.cs +++ b/Essentials/PersistenceProviderUtilities.cs @@ -126,6 +126,30 @@ public static string GetSafeFileName(string input) return builder.ToString(); } + /// + /// Formats a key as the text its filename is built from, independently of the current culture. + /// + /// + /// parses with the invariant culture, so a key + /// formatted with the current culture would come back as a different key: 1.5 is written as + /// 1,5 under de-DE and read back as 15. Formatting invariantly also keeps a key's file + /// the same whichever culture the process runs under. Floating-point values use the round-trip + /// format and dates the ISO 8601 round-trip format, so no precision or is lost. + /// + /// The key type. + /// The key to format. + /// The key's culture-independent text. + public static string FormatKey(TKey key) where TKey : notnull => key switch + { + string text => text, + DateTime dateTime => dateTime.ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture), + double number => number.ToString("R", CultureInfo.InvariantCulture), + float number => number.ToString("R", CultureInfo.InvariantCulture), + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => key.ToString()!, + }; + /// /// Attempts to convert a string value to the specified key type. /// @@ -177,6 +201,28 @@ public static bool TryConvertToKey(string value, out TKey key) where TKey return true; } + if (typeof(TKey) == typeof(DateTime)) + { + if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime dateTime)) + { + return false; + } + + key = (TKey)(object)dateTime; + return true; + } + + if (typeof(TKey) == typeof(DateTimeOffset)) + { + if (!DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset dateTimeOffset)) + { + return false; + } + + key = (TKey)(object)dateTimeOffset; + return true; + } + key = (TKey)Convert.ChangeType(value, typeof(TKey), CultureInfo.InvariantCulture); return true; } From de49775fd01a1dfba25fa7837571e310dc21fcfc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:34:31 +0000 Subject: [PATCH 2/3] Build the culture round-trip test's directories with Path.Join Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CzC1o7LFYHYaoWmVy1NLcp --- Essentials.Tests/PersistenceNamingTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Essentials.Tests/PersistenceNamingTests.cs b/Essentials.Tests/PersistenceNamingTests.cs index aed71a5..72ca0c2 100644 --- a/Essentials.Tests/PersistenceNamingTests.cs +++ b/Essentials.Tests/PersistenceNamingTests.cs @@ -194,13 +194,13 @@ public void FormatKey_Ignores_The_Current_Culture() [TestMethod] public async Task FileSystem_Double_And_Date_Keys_Round_Trip_Across_Cultures() { - string dir = Path.Combine(Path.GetTempPath(), "NamingTests_" + Guid.NewGuid().ToString("N")[..8]); + string dir = Path.Join(Path.GetTempPath(), "NamingTests_" + Guid.NewGuid().ToString("N")[..8]); try { NativeFileSystemProvider fs = new(); JsonSerializationProvider serializer = new(); await AssertKeysRoundTripAcrossCultures(new FileSystemPersistenceProvider(fs, serializer, dir), CultureSensitiveDoubleKeys).ConfigureAwait(false); - await AssertKeysRoundTripAcrossCultures(new FileSystemPersistenceProvider(fs, serializer, Path.Combine(dir, "dates")), CultureSensitiveDateKeys).ConfigureAwait(false); + await AssertKeysRoundTripAcrossCultures(new FileSystemPersistenceProvider(fs, serializer, Path.Join(dir, "dates")), CultureSensitiveDateKeys).ConfigureAwait(false); } finally { From 59c4f8930c0379d1036ca8236f3933dc5cc3ed36 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:59:47 +0000 Subject: [PATCH 3/3] Cover every FormatKey arm and the date parse paths Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CzC1o7LFYHYaoWmVy1NLcp --- Essentials.Tests/PersistenceNamingTests.cs | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Essentials.Tests/PersistenceNamingTests.cs b/Essentials.Tests/PersistenceNamingTests.cs index 72ca0c2..26470f3 100644 --- a/Essentials.Tests/PersistenceNamingTests.cs +++ b/Essentials.Tests/PersistenceNamingTests.cs @@ -191,6 +191,46 @@ public void FormatKey_Ignores_The_Current_Culture() } } + [TestMethod] + public void FormatKey_Round_Trips_Every_Key_Kind_Through_TryConvertToKey() + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CommaDecimalDayFirstCulture(); + + AssertRoundTrips(0.1f, "0.1"); + AssertRoundTrips(12.5m, "12.5"); + AssertRoundTrips(-7L, "-7"); + AssertRoundTrips(new DateTimeOffset(2026, 3, 4, 5, 6, 7, TimeSpan.FromHours(10)), "2026-03-04T05:06:07.0000000+10:00"); + + DateTime utc = new(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc); + Assert.IsTrue(PersistenceProviderUtilities.TryConvertToKey(PersistenceProviderUtilities.FormatKey(utc), out DateTime parsedUtc)); + Assert.AreEqual(DateTimeKind.Utc, parsedUtc.Kind, "A UTC key should not come back as local time"); + } + finally + { + CultureInfo.CurrentCulture = original; + } + + Assert.AreEqual("plain", PersistenceProviderUtilities.FormatKey(new NonFormattableKey("plain")), "A key that is not IFormattable falls back to ToString"); + Assert.IsFalse(PersistenceProviderUtilities.TryConvertToKey("not-a-date", out DateTime _), "Unparseable input should fail, not yield DateTime.MinValue"); + Assert.IsFalse(PersistenceProviderUtilities.TryConvertToKey("not-a-date", out DateTimeOffset _), "Unparseable input should fail, not yield DateTimeOffset.MinValue"); + } + + private static void AssertRoundTrips(TKey key, string expectedText) where TKey : notnull + { + string text = PersistenceProviderUtilities.FormatKey(key); + Assert.AreEqual(expectedText, text); + Assert.IsTrue(PersistenceProviderUtilities.TryConvertToKey(text, out TKey parsed), $"'{text}' should parse back"); + Assert.AreEqual(key, parsed); + } + + private sealed record NonFormattableKey(string Name) + { + public override string ToString() => Name; + } + [TestMethod] public async Task FileSystem_Double_And_Date_Keys_Round_Trip_Across_Cultures() {