diff --git a/src/ClaudeMailbox/Config/FileConfig.cs b/src/ClaudeMailbox/Config/FileConfig.cs new file mode 100644 index 0000000..2a2535f --- /dev/null +++ b/src/ClaudeMailbox/Config/FileConfig.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ClaudeMailbox.Config; + +public sealed class FileConfig +{ + [JsonPropertyName("port")] + public int? Port { get; set; } + + [JsonPropertyName("bind")] + public string? Bind { get; set; } + + [JsonPropertyName("dbPath")] + public string? DbPath { get; set; } + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + }; + + public static FileConfig Load(string? explicitPath, string? defaultPath) + { + if (!string.IsNullOrEmpty(explicitPath)) + { + if (!File.Exists(explicitPath)) + throw new FileNotFoundException($"Config file not found: {explicitPath}", explicitPath); + return Parse(File.ReadAllText(explicitPath)); + } + + if (!string.IsNullOrEmpty(defaultPath) && File.Exists(defaultPath)) + return Parse(File.ReadAllText(defaultPath)); + + return new FileConfig(); + } + + private static FileConfig Parse(string json) + { + return JsonSerializer.Deserialize(json, Options) ?? new FileConfig(); + } +} diff --git a/tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs b/tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs new file mode 100644 index 0000000..56ef39b --- /dev/null +++ b/tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs @@ -0,0 +1,99 @@ +using ClaudeMailbox.Config; + +namespace ClaudeMailbox.Tests.Config; + +public sealed class FileConfigTests +{ + [Fact] + public void Load_ReturnsEmpty_WhenPathIsNullAndDefaultMissing() + { + var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json"); + var cfg = FileConfig.Load(explicitPath: null, defaultPath: missing); + + Assert.Null(cfg.Port); + Assert.Null(cfg.Bind); + Assert.Null(cfg.DbPath); + } + + [Fact] + public void Load_ReadsDefaultPath_WhenExplicitPathNull() + { + var path = WriteTemp("""{"port":9000,"bind":"0.0.0.0","dbPath":"C:\\tmp\\a.db"}"""); + try + { + var cfg = FileConfig.Load(explicitPath: null, defaultPath: path); + Assert.Equal(9000, cfg.Port); + Assert.Equal("0.0.0.0", cfg.Bind); + Assert.Equal(@"C:\tmp\a.db", cfg.DbPath); + } + finally { File.Delete(path); } + } + + [Fact] + public void Load_ExplicitPath_WinsOverDefault() + { + var defaultPath = WriteTemp("""{"port":1111}"""); + var explicitPath = WriteTemp("""{"port":2222}"""); + try + { + var cfg = FileConfig.Load(explicitPath: explicitPath, defaultPath: defaultPath); + Assert.Equal(2222, cfg.Port); + } + finally { File.Delete(defaultPath); File.Delete(explicitPath); } + } + + [Fact] + public void Load_ExplicitPathMissing_Throws() + { + var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json"); + var ex = Assert.Throws(() => + FileConfig.Load(explicitPath: missing, defaultPath: null)); + Assert.Contains(missing, ex.Message); + } + + [Fact] + public void Load_MissingFields_AreNull() + { + var path = WriteTemp("""{"port":1234}"""); + try + { + var cfg = FileConfig.Load(explicitPath: path, defaultPath: null); + Assert.Equal(1234, cfg.Port); + Assert.Null(cfg.Bind); + Assert.Null(cfg.DbPath); + } + finally { File.Delete(path); } + } + + [Fact] + public void Load_CaseInsensitive_PropertyNames() + { + var path = WriteTemp("""{"Port":1,"BIND":"x","DBPATH":"y"}"""); + try + { + var cfg = FileConfig.Load(explicitPath: path, defaultPath: null); + Assert.Equal(1, cfg.Port); + Assert.Equal("x", cfg.Bind); + Assert.Equal("y", cfg.DbPath); + } + finally { File.Delete(path); } + } + + [Fact] + public void Load_MalformedJson_Throws() + { + var path = WriteTemp("not json"); + try + { + Assert.ThrowsAny(() => FileConfig.Load(explicitPath: path, defaultPath: null)); + } + finally { File.Delete(path); } + } + + private static string WriteTemp(string content) + { + var p = Path.Combine(Path.GetTempPath(), $"mailbox-{Guid.NewGuid():N}.json"); + File.WriteAllText(p, content); + return p; + } +}