using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.AirGap.Importer.Versioning; namespace StellaOps.Cli.Services; internal sealed class FileBundleVersionStore : IBundleVersionStore { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private readonly string _stateDirectory; private readonly ILogger _logger; public FileBundleVersionStore(string stateDirectory, ILogger logger) { ArgumentException.ThrowIfNullOrWhiteSpace(stateDirectory); _stateDirectory = stateDirectory; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetCurrentAsync( string tenantId, string bundleType, CancellationToken ct = default) { var history = await GetHistoryInternalAsync(tenantId, bundleType, ct).ConfigureAwait(false); return history .OrderByDescending(record => record.ActivatedAt) .ThenByDescending(record => record.VersionString, StringComparer.Ordinal) .FirstOrDefault(); } public async Task UpsertAsync(BundleVersionRecord record, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(record); Directory.CreateDirectory(_stateDirectory); var path = GetStatePath(record.TenantId, record.BundleType); var history = await GetHistoryInternalAsync(record.TenantId, record.BundleType, ct).ConfigureAwait(false); history.Add(record); var ordered = history .OrderBy(r => r.ActivatedAt) .ThenBy(r => r.VersionString, StringComparer.Ordinal) .ToList(); var tempPath = path + ".tmp"; await using (var stream = File.Create(tempPath)) { await JsonSerializer.SerializeAsync(stream, ordered, JsonOptions, ct).ConfigureAwait(false); } File.Copy(tempPath, path, overwrite: true); File.Delete(tempPath); } public async Task> GetHistoryAsync( string tenantId, string bundleType, int limit = 10, CancellationToken ct = default) { var history = await GetHistoryInternalAsync(tenantId, bundleType, ct).ConfigureAwait(false); return history .OrderByDescending(r => r.ActivatedAt) .ThenByDescending(r => r.VersionString, StringComparer.Ordinal) .Take(Math.Max(0, limit)) .ToArray(); } private async Task> GetHistoryInternalAsync( string tenantId, string bundleType, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(bundleType); var path = GetStatePath(tenantId, bundleType); if (!File.Exists(path)) { return new List(); } try { await using var stream = File.OpenRead(path); var records = await JsonSerializer.DeserializeAsync>(stream, JsonOptions, ct).ConfigureAwait(false); return records ?? new List(); } catch (Exception ex) when (ex is IOException or JsonException) { _logger.LogWarning(ex, "Failed to read bundle version history from {Path}", path); return new List(); } } private string GetStatePath(string tenantId, string bundleType) { var safeTenant = SanitizePathSegment(tenantId); var safeBundleType = SanitizePathSegment(bundleType); return Path.Combine(_stateDirectory, $"bundle-versions__{safeTenant}__{safeBundleType}.json"); } private static string SanitizePathSegment(string value) { var trimmed = value.Trim().ToLowerInvariant(); var invalid = Path.GetInvalidFileNameChars(); var chars = trimmed .Select(c => invalid.Contains(c) || c == '/' || c == '\\' || char.IsWhiteSpace(c) ? '_' : c) .ToArray(); return new string(chars); } }