Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
116 changes: 116 additions & 0 deletions Essentials.Tests/PersistenceNamingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
try
{
NativeFileSystemProvider fs = new();
JsonSerializationProvider serializer = new();
await AssertKeysRoundTripAcrossCultures(new FileSystemPersistenceProvider<double>(fs, serializer, dir), CultureSensitiveDoubleKeys).ConfigureAwait(false);
await AssertKeysRoundTripAcrossCultures(new FileSystemPersistenceProvider<DateTime>(fs, serializer, Path.Combine(dir, "dates")), CultureSensitiveDateKeys).ConfigureAwait(false);
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
}
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<double> doubles = new(fs, serializer, "NamingTests");
await AssertKeysRoundTripAcrossCultures(doubles, CultureSensitiveDoubleKeys).ConfigureAwait(false);

using TempPersistenceProvider<DateTime> dates = new(fs, serializer, "NamingTests");
await AssertKeysRoundTripAcrossCultures(dates, CultureSensitiveDateKeys).ConfigureAwait(false);
}

/// <summary>
/// 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.
/// </summary>
private async Task AssertKeysRoundTripAcrossCultures<TKey>(IPersistenceProvider<TKey> 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<string>(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;
}
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>A culture formatted like en-US: month-first dates and a twelve-hour clock.</summary>
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()
{
Expand Down
46 changes: 46 additions & 0 deletions Essentials/PersistenceProviderUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,30 @@ public static string GetSafeFileName(string input)
return builder.ToString();
}

/// <summary>
/// Formats a key as the text its filename is built from, independently of the current culture.
/// </summary>
/// <remarks>
/// <see cref="TryConvertToKey{TKey}(string, out TKey)"/> parses with the invariant culture, so a key
/// formatted with the current culture would come back as a different key: <c>1.5</c> is written as
/// <c>1,5</c> under de-DE and read back as <c>15</c>. 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 <see cref="DateTimeKind"/> is lost.
/// </remarks>
/// <typeparam name="TKey">The key type.</typeparam>
/// <param name="key">The key to format.</param>
/// <returns>The key's culture-independent text.</returns>
public static string FormatKey<TKey>(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()!,
};

/// <summary>
/// Attempts to convert a string value to the specified key type.
/// </summary>
Expand Down Expand Up @@ -177,6 +201,28 @@ public static bool TryConvertToKey<TKey>(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;
}
Expand Down
Loading