diff --git a/src/TeamsISO.Engine/Persistence/ConfigStore.cs b/src/TeamsISO.Engine/Persistence/ConfigStore.cs new file mode 100644 index 0000000..13fef05 --- /dev/null +++ b/src/TeamsISO.Engine/Persistence/ConfigStore.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using TeamsISO.Engine.Domain; + +namespace TeamsISO.Engine.Persistence; + +/// +/// Loads and saves as JSON. +/// Writes are atomic: serialize to a temp file in the same directory, then File.Move with overwrite. +/// +public sealed class ConfigStore +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly string _path; + private readonly ILogger _logger; + + public ConfigStore(string path, ILogger logger) + { + _path = path; + _logger = logger; + } + + public EngineConfig Load() + { + if (!File.Exists(_path)) + { + _logger.LogInformation("Config file not found at {Path}; using defaults.", _path); + return EngineConfig.Default; + } + + try + { + var json = File.ReadAllText(_path); + var loaded = JsonSerializer.Deserialize(json, Options); + return loaded ?? EngineConfig.Default; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Config file at {Path} is not valid JSON; using defaults.", _path); + return EngineConfig.Default; + } + } + + public void Save(EngineConfig config) + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + var temp = _path + ".tmp"; + var json = JsonSerializer.Serialize(config, Options); + File.WriteAllText(temp, json); + File.Move(temp, _path, overwrite: true); + } +} diff --git a/src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs b/src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs new file mode 100644 index 0000000..fa156e2 --- /dev/null +++ b/src/tests/TeamsISO.Engine.Tests/Persistence/ConfigStoreTests.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Logging.Abstractions; +using TeamsISO.Engine.Domain; +using TeamsISO.Engine.Persistence; + +namespace TeamsISO.Engine.Tests.Persistence; + +public class ConfigStoreTests : IDisposable +{ + private readonly string _dir; + + public ConfigStoreTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"teamsiso-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dir); + } + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + private ConfigStore NewStore() => + new(Path.Combine(_dir, "config.json"), NullLogger.Instance); + + [Fact] + public void Load_WhenFileMissing_ReturnsDefault() + { + var store = NewStore(); + var config = store.Load(); + config.Should().Be(EngineConfig.Default); + } + + [Fact] + public void SaveThenLoad_RoundTripsExactly() + { + var store = NewStore(); + var input = new EngineConfig( + new FrameProcessingSettings(TargetFramerate.Fps59_94, TargetResolution.R720p, + AspectMode.Letterbox, AudioMode.Isolated), + new[] + { + new IsoAssignment(Guid.NewGuid(), IsEnabled: true, CustomOutputName: "TEAMSISO_A"), + new IsoAssignment(Guid.NewGuid(), IsEnabled: false, CustomOutputName: null), + }); + + store.Save(input); + var loaded = store.Load(); + + loaded.Should().BeEquivalentTo(input); + } + + [Fact] + public void Load_WhenFileCorrupt_ReturnsDefault() + { + var path = Path.Combine(_dir, "config.json"); + File.WriteAllText(path, "not valid json {{{"); + + var store = NewStore(); + var config = store.Load(); + config.Should().Be(EngineConfig.Default); + } + + [Fact] + public void Save_WritesAtomically_NoPartialFileVisible() + { + var store = NewStore(); + var path = Path.Combine(_dir, "config.json"); + + store.Save(EngineConfig.Default); + var firstWrite = File.ReadAllText(path); + + store.Save(new EngineConfig( + FrameProcessingSettings.Default with { Framerate = TargetFramerate.Fps60 }, + Array.Empty())); + + File.Exists(path).Should().BeTrue(); + File.ReadAllText(path).Should().NotBe(firstWrite); + Directory.GetFiles(_dir).Should().HaveCount(1); + } +}