< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Localization.KestrunLocalizationStore
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Localization/KestrunLocalizationStore.cs
Tag: Kestrun/Kestrun@ca54e35c77799b76774b3805b6f075cdbc0c5fbe
Line coverage
85%
Covered lines: 142
Uncovered lines: 25
Coverable lines: 167
Total lines: 426
Line coverage: 85%
Branch coverage
79%
Covered branches: 86
Total branches: 108
Branch coverage: 79.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 01/24/2026 - 19:35:59 Line coverage: 85% (142/167) Branch coverage: 79.6% (86/108) Total lines: 426 Tag: Kestrun/Kestrun@f59dcba478ea75f69584d696e5f1fb1cfa40aa51 01/24/2026 - 19:35:59 Line coverage: 85% (142/167) Branch coverage: 79.6% (86/108) Total lines: 426 Tag: Kestrun/Kestrun@f59dcba478ea75f69584d696e5f1fb1cfa40aa51

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
get_Logger()100%11100%
.ctor(...)50%44100%
get_AvailableCultures()100%11100%
ResolveCulture(...)50%22100%
GetStringsForCulture(...)100%11100%
GetStringsForResolvedCulture(...)50%66100%
.ctor(...)93.75%1616100%
get_Item(...)50%22100%
get_Keys()100%11100%
get_Values()0%620%
get_Count()100%11100%
ContainsKey(...)100%210%
GetEnumerator()50%2266.66%
System.Collections.IEnumerable.GetEnumerator()100%210%
TryGetValue(...)100%66100%
IsUsableCandidate(...)100%22100%
BuildKeySet()100%66100%
get_Item(...)100%210%
ResolveCulture(...)85.71%141492.3%
BuildCultureCandidates(...)100%44100%
GetSpecificCultureFallback(...)80%131068.75%
DiscoverAvailableCultures()78.57%161476.92%
IsCultureAvailable(...)100%11100%
HasJsonFallback(...)50%2280%
LoadStringsForCulture(...)78.57%1414100%
LoadPsStringTable(...)100%1140%
LoadJsonStrings(...)100%1140%
NormalizeCulture(...)100%2266.66%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Localization/KestrunLocalizationStore.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Globalization;
 3
 4namespace Kestrun.Localization;
 5
 6/// <summary>
 7/// Provides cached access to localized string tables with culture fallback support.
 8/// </summary>
 9public sealed class KestrunLocalizationStore
 10{
 111    private static readonly IReadOnlyDictionary<string, string> EmptyStrings =
 112        new Dictionary<string, string>(StringComparer.Ordinal);
 13
 14    private readonly KestrunLocalizationOptions options;
 15    /// <summary>
 16    /// Gets the logger instance.
 17    /// </summary>
 3618    public Serilog.ILogger Logger { get; }
 19    private readonly string resourcesRoot;
 20    private readonly ConcurrentDictionary<string, IReadOnlyDictionary<string, string>> cache;
 21    private readonly HashSet<string> availableCultures;
 22    private readonly string defaultCulture;
 23
 24    /// <summary>
 25    /// Initializes a new instance of the <see cref="KestrunLocalizationStore"/> class.
 26    /// </summary>
 27    /// <param name="options">The localization options.</param>
 28    /// <param name="contentRootPath">The content root path used to resolve resources.</param>
 29    /// <param name="logger">The logger instance.</param>
 1630    public KestrunLocalizationStore(
 1631        KestrunLocalizationOptions options,
 1632        string contentRootPath,
 1633        Serilog.ILogger logger)
 34    {
 1635        ArgumentNullException.ThrowIfNull(options);
 1636        ArgumentNullException.ThrowIfNull(contentRootPath);
 1637        ArgumentNullException.ThrowIfNull(logger);
 38
 1639        this.options = options;
 1640        Logger = logger;
 1641        cache = new ConcurrentDictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
 1642        availableCultures = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 1643        defaultCulture = NormalizeCulture(options.DefaultCulture) ?? options.DefaultCulture;
 44
 1645        resourcesRoot = Path.IsPathRooted(this.options.ResourcesBasePath)
 1646            ? this.options.ResourcesBasePath
 1647            : Path.Combine(contentRootPath, this.options.ResourcesBasePath);
 48
 1649        DiscoverAvailableCultures();
 1650    }
 51
 52    /// <summary>
 53    /// Gets the set of available cultures discovered at startup (read-only).
 54    /// </summary>
 155    public IReadOnlyCollection<string> AvailableCultures => [.. availableCultures];
 56
 57    /// <summary>
 58    /// Resolves the requested culture to the closest available culture or the default culture.
 59    /// </summary>
 60    /// <param name="requestedCulture">The requested culture name.</param>
 61    /// <returns>The resolved culture name.</returns>
 62    public string ResolveCulture(string? requestedCulture) =>
 1163        ResolveCulture(requestedCulture, allowDefaultFallback: true) ?? defaultCulture;
 64
 65    /// <summary>
 66    /// Gets the localized strings for the requested culture with fallback resolution.
 67    /// </summary>
 68    /// <param name="requestedCulture">The requested culture name.</param>
 69    /// <returns>A dictionary of localized strings.</returns>
 1370    public IReadOnlyDictionary<string, string> GetStringsForCulture(string? requestedCulture) => new CompositeStringTabl
 71
 72    /// <summary>
 73    /// Gets the localized strings for the already-resolved culture.
 74    /// </summary>
 75    /// <param name="resolvedCulture">The resolved culture name.</param>
 76    /// <returns>A dictionary of localized strings.</returns>
 77    public IReadOnlyDictionary<string, string> GetStringsForResolvedCulture(string resolvedCulture)
 78    {
 179        return string.IsNullOrWhiteSpace(resolvedCulture)
 180            ? EmptyStrings
 181            : (IsCultureAvailable(resolvedCulture) ||
 182               string.Equals(resolvedCulture, defaultCulture, StringComparison.OrdinalIgnoreCase)
 183                ? cache.GetOrAdd(resolvedCulture, LoadStringsForCulture)
 184                : EmptyStrings);
 85    }
 86
 87    private sealed class CompositeStringTable : IReadOnlyDictionary<string, string>
 88    {
 89        private readonly KestrunLocalizationStore _store;
 90        private readonly List<string> _candidates;
 91        private readonly Lazy<HashSet<string>> _keyCache;
 92
 1393        public CompositeStringTable(KestrunLocalizationStore store, string? requestedCulture)
 94        {
 1395            _store = store ?? throw new ArgumentNullException(nameof(store));
 1396            _candidates = [];
 97
 98            // Build candidate list from requested culture up the parent chain
 1399            var baseCandidates = _store.BuildCultureCandidates(requestedCulture);
 13100            if (baseCandidates.Count > 0)
 101            {
 11102                _candidates.Add(baseCandidates[0]);
 103            }
 104
 105            // Add the specific-culture sibling fallback before walking the parent chain
 13106            var specific = GetSpecificCultureFallback(requestedCulture);
 13107            if (!string.IsNullOrWhiteSpace(specific) && !_candidates.Contains(specific, StringComparer.OrdinalIgnoreCase
 108            {
 1109                _candidates.Add(specific);
 110            }
 111
 48112            for (var i = 1; i < baseCandidates.Count; i++)
 113            {
 11114                var candidate = baseCandidates[i];
 11115                if (!_candidates.Contains(candidate, StringComparer.OrdinalIgnoreCase))
 116                {
 11117                    _candidates.Add(candidate);
 118                }
 119            }
 120
 121            // Finally add the default culture as last resort (if not already present)
 13122            if (!string.IsNullOrWhiteSpace(_store.defaultCulture) && !_candidates.Contains(_store.defaultCulture, String
 123            {
 10124                _candidates.Add(_store.defaultCulture);
 125            }
 126
 13127            _keyCache = new Lazy<HashSet<string>>(BuildKeySet, LazyThreadSafetyMode.ExecutionAndPublication);
 13128        }
 129
 5130        public string this[string key] => TryGetValue(key, out var value) ? value : throw new KeyNotFoundException(key);
 131
 2132        public IEnumerable<string> Keys => _keyCache.Value;
 133
 134        public IEnumerable<string> Values
 135        {
 136            get
 137            {
 0138                foreach (var key in Keys)
 139                {
 0140                    yield return this[key];
 141                }
 0142            }
 143        }
 144
 1145        public int Count => _keyCache.Value.Count;
 146
 0147        public bool ContainsKey(string key) => TryGetValue(key, out _);
 148
 149        public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
 150        {
 2151            foreach (var key in Keys)
 152            {
 0153                yield return new KeyValuePair<string, string>(key, this[key]);
 154            }
 1155        }
 156
 0157        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
 158
 159        /// <summary>
 160        /// Tries to get the localized value for the specified key using culture fallback.
 161        /// </summary>
 162        /// <param name="key">The key to look up.</param>
 163        /// <param name="value">The localized value if found; otherwise, an empty string.</param>
 164        /// <returns>True if the key was found; otherwise, false.</returns>
 165        public bool TryGetValue(string key, out string value)
 166        {
 36167            foreach (var candidate in _candidates)
 168            {
 13169                if (!IsUsableCandidate(candidate))
 170                {
 171                    continue;
 172                }
 173
 12174                var dict = _store.cache.GetOrAdd(candidate, _store.LoadStringsForCulture);
 175
 12176                if (dict.TryGetValue(key, out var v))
 177                {
 8178                    value = v;
 8179                    return true;
 180                }
 181            }
 182
 1183            value = string.Empty; // or throw / fallback marker
 1184            return false;
 8185        }
 186
 187        private bool IsUsableCandidate(string candidate)
 188        {
 189            // Prevent unbounded cache growth from arbitrary culture tags.
 190            // Only cache/load cultures that were discovered as available, plus the configured default.
 19191            return _store.IsCultureAvailable(candidate) ||
 19192                   string.Equals(candidate, _store.defaultCulture, StringComparison.OrdinalIgnoreCase);
 193        }
 194
 195        private HashSet<string> BuildKeySet()
 196        {
 2197            var set = new HashSet<string>(StringComparer.Ordinal);
 16198            foreach (var candidate in _candidates)
 199            {
 6200                if (!IsUsableCandidate(candidate))
 201                {
 202                    continue;
 203                }
 204
 3205                var dict = _store.cache.GetOrAdd(candidate, _store.LoadStringsForCulture);
 14206                foreach (var k in dict.Keys)
 207                {
 4208                    _ = set.Add(k);
 209                }
 210            }
 211
 2212            return set;
 213        }
 214    }
 215
 216    /// <summary>
 217    /// Gets the localized strings for the specified culture using fallback resolution.
 218    /// </summary>
 219    /// <param name="culture">The culture name to resolve.</param>
 0220    public IReadOnlyDictionary<string, string> this[string culture] => GetStringsForCulture(culture);
 221
 222    internal string? ResolveCulture(string? requestedCulture, bool allowDefaultFallback)
 223    {
 11224        var candidates = BuildCultureCandidates(requestedCulture);
 225
 226        // Prefer an exact match for the requested culture first (typically the first candidate).
 11227        if (candidates.Count > 0 && IsCultureAvailable(candidates[0]))
 228        {
 6229            return candidates[0];
 230        }
 231
 232        // Then attempt a sibling-specific fallback (e.g., fr-FR when resolving fr-CA) before walking parents.
 5233        var specificFallback = GetSpecificCultureFallback(requestedCulture);
 5234        if (specificFallback is not null && IsCultureAvailable(specificFallback))
 235        {
 0236            return specificFallback;
 237        }
 238
 239        // Finally, walk the remaining candidates (typically the parent chain).
 12240        for (var i = 1; i < candidates.Count; i++)
 241        {
 2242            var candidate = candidates[i];
 2243            if (IsCultureAvailable(candidate))
 244            {
 1245                return candidate;
 246            }
 247        }
 248
 4249        return allowDefaultFallback
 4250            ? defaultCulture
 4251            : null;
 252    }
 253
 254    private IReadOnlyList<string> BuildCultureCandidates(string? requestedCulture)
 255    {
 24256        var list = new List<string>();
 24257        var normalized = NormalizeCulture(requestedCulture);
 24258        if (!string.IsNullOrWhiteSpace(normalized))
 259        {
 20260            var culture = CultureInfo.GetCultureInfo(normalized);
 59261            while (!string.IsNullOrWhiteSpace(culture.Name))
 262            {
 39263                list.Add(culture.Name);
 39264                culture = culture.Parent;
 265            }
 266        }
 267
 24268        return list;
 269    }
 270
 271    private static string? GetSpecificCultureFallback(string? requestedCulture)
 272    {
 18273        if (string.IsNullOrWhiteSpace(requestedCulture))
 274        {
 4275            return null;
 276        }
 277
 278        CultureInfo culture;
 279        try
 280        {
 14281            culture = CultureInfo.GetCultureInfo(requestedCulture);
 14282        }
 0283        catch (CultureNotFoundException)
 284        {
 0285            return null;
 286        }
 287
 14288        var neutral = culture.IsNeutralCulture ? culture : culture.Parent;
 14289        if (neutral is null || string.IsNullOrWhiteSpace(neutral.Name))
 290        {
 0291            return null;
 292        }
 293
 294        try
 295        {
 14296            var specific = CultureInfo.CreateSpecificCulture(neutral.Name).Name;
 14297            return string.Equals(specific, culture.Name, StringComparison.OrdinalIgnoreCase)
 14298                ? null
 14299                : specific;
 300        }
 0301        catch (CultureNotFoundException)
 302        {
 0303            return null;
 304        }
 14305    }
 306
 307    private void DiscoverAvailableCultures()
 308    {
 16309        if (!Directory.Exists(resourcesRoot))
 310        {
 0311            if (Logger.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 312            {
 0313                Logger.Debug("Localization resources root '{BasePath}' does not exist; no cultures discovered.", resourc
 314            }
 0315            return;
 316        }
 317
 92318        foreach (var dir in Directory.GetDirectories(resourcesRoot))
 319        {
 30320            var name = Path.GetFileName(dir);
 30321            if (string.IsNullOrWhiteSpace(name))
 322            {
 323                continue;
 324            }
 325
 30326            var filePath = Path.Combine(dir, options.FileName);
 30327            if (File.Exists(filePath) || HasJsonFallback(filePath))
 328            {
 29329                _ = availableCultures.Add(name);
 330            }
 331        }
 332
 16333        if (Logger.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 334        {
 9335            Logger.Debug("Discovered {Count} localization cultures in {BasePath}.", availableCultures.Count, resourcesRo
 336        }
 16337    }
 338
 33339    private bool IsCultureAvailable(string culture) => availableCultures.Contains(culture);
 340
 341    private static bool HasJsonFallback(string filePath)
 342    {
 3343        var extension = Path.GetExtension(filePath);
 3344        if (!string.Equals(extension, ".psd1", StringComparison.OrdinalIgnoreCase))
 345        {
 0346            return false;
 347        }
 348
 3349        var jsonPath = Path.ChangeExtension(filePath, ".json");
 3350        return File.Exists(jsonPath);
 351    }
 352
 353    private IReadOnlyDictionary<string, string> LoadStringsForCulture(string culture)
 354    {
 11355        var filePath = Path.Combine(resourcesRoot, culture, options.FileName);
 11356        var extension = Path.GetExtension(filePath);
 11357        var primaryPath = filePath;
 11358        var jsonFallbackPath = string.Equals(extension, ".psd1", StringComparison.OrdinalIgnoreCase)
 11359            ? Path.ChangeExtension(filePath, ".json")
 11360            : null;
 361
 11362        if (!File.Exists(primaryPath))
 363        {
 3364            if (!string.IsNullOrWhiteSpace(jsonFallbackPath) && File.Exists(jsonFallbackPath))
 365            {
 1366                return LoadJsonStrings(jsonFallbackPath);
 367            }
 2368            if (Logger.IsEnabled(Serilog.Events.LogEventLevel.Debug))
 369            {
 1370                Logger.Debug(
 1371                    "Localization file missing for culture '{Culture}'. Tried '{PrimaryPath}'{JsonPathMessage}.",
 1372                    culture,
 1373                    primaryPath,
 1374                    jsonFallbackPath is null ? string.Empty : $" and '{jsonFallbackPath}'");
 375            }
 2376            return EmptyStrings;
 377        }
 378
 8379        return string.Equals(extension, ".json", StringComparison.OrdinalIgnoreCase)
 8380            ? LoadJsonStrings(primaryPath)
 8381            : LoadPsStringTable(primaryPath);
 382    }
 383
 384    private IReadOnlyDictionary<string, string> LoadPsStringTable(string path)
 385    {
 386        try
 387        {
 8388            return StringTableParser.ParseFile(path);
 389        }
 0390        catch (Exception ex)
 391        {
 0392            Logger.Warning(ex, "Failed to parse localization file '{Path}'.", path);
 0393            return EmptyStrings;
 394        }
 8395    }
 396
 397    private IReadOnlyDictionary<string, string> LoadJsonStrings(string path)
 398    {
 399        try
 400        {
 1401            return StringTableParser.ParseJsonFile(path);
 402        }
 0403        catch (Exception ex)
 404        {
 0405            Logger.Warning(ex, "Failed to parse localization JSON file '{Path}'.", path);
 0406            return EmptyStrings;
 407        }
 1408    }
 409
 410    private static string? NormalizeCulture(string? culture)
 411    {
 40412        if (string.IsNullOrWhiteSpace(culture))
 413        {
 4414            return null;
 415        }
 416
 417        try
 418        {
 36419            return CultureInfo.GetCultureInfo(culture).Name;
 420        }
 0421        catch (CultureNotFoundException)
 422        {
 0423            return null;
 424        }
 36425    }
 426}