From 921e6262084c30e21383172590812054ad0adc5d Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 15 Apr 2026 08:53:52 +0200 Subject: [PATCH] feat(installer): add InstallManifest + json-backed store --- .../Core/InstallManifest.cs | 48 ++++++++++++++ .../InstallManifestStoreTests.cs | 66 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/ClaudeDo.Installer/Core/InstallManifest.cs create mode 100644 tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs diff --git a/src/ClaudeDo.Installer/Core/InstallManifest.cs b/src/ClaudeDo.Installer/Core/InstallManifest.cs new file mode 100644 index 0000000..be2a774 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/InstallManifest.cs @@ -0,0 +1,48 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ClaudeDo.Installer.Core; + +public sealed record InstallManifest( + string Version, + string InstallDir, + string WorkerDir, + DateTimeOffset InstalledAt); + +public static class InstallManifestStore +{ + public const string FileName = "install.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static string ManifestPath(string installDir) => Path.Combine(installDir, FileName); + + public static InstallManifest? TryRead(string installDir) + { + var path = ManifestPath(installDir); + if (!File.Exists(path)) return null; + + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch + { + return null; + } + } + + public static void Write(string installDir, InstallManifest manifest) + { + Directory.CreateDirectory(installDir); + var json = JsonSerializer.Serialize(manifest, JsonOptions); + File.WriteAllText(ManifestPath(installDir), json); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs b/tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs new file mode 100644 index 0000000..553773b --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs @@ -0,0 +1,66 @@ +using System.IO; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Tests; + +public sealed class InstallManifestStoreTests : IDisposable +{ + private readonly string _tempDir; + + public InstallManifestStoreTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoInstallerTests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ } + } + + [Fact] + public void TryRead_ReturnsNull_WhenFileMissing() + { + var result = InstallManifestStore.TryRead(_tempDir); + Assert.Null(result); + } + + [Fact] + public void Write_Then_Read_RoundTripsAllFields() + { + var manifest = new InstallManifest( + Version: "0.2.0", + InstallDir: _tempDir, + WorkerDir: Path.Combine(_tempDir, "worker"), + InstalledAt: new DateTimeOffset(2026, 4, 15, 12, 34, 56, TimeSpan.Zero)); + + InstallManifestStore.Write(_tempDir, manifest); + + var round = InstallManifestStore.TryRead(_tempDir); + Assert.NotNull(round); + Assert.Equal("0.2.0", round!.Version); + Assert.Equal(manifest.InstallDir, round.InstallDir); + Assert.Equal(manifest.WorkerDir, round.WorkerDir); + Assert.Equal(manifest.InstalledAt, round.InstalledAt); + } + + [Fact] + public void Write_CreatesInstallDir_IfMissing() + { + var nested = Path.Combine(_tempDir, "nested"); + Assert.False(Directory.Exists(nested)); + + InstallManifestStore.Write(nested, new InstallManifest( + "0.0.1", nested, Path.Combine(nested, "worker"), DateTimeOffset.UtcNow)); + + Assert.True(File.Exists(Path.Combine(nested, "install.json"))); + } + + [Fact] + public void TryRead_ReturnsNull_WhenJsonMalformed() + { + File.WriteAllText(Path.Combine(_tempDir, "install.json"), "{ not json"); + var result = InstallManifestStore.TryRead(_tempDir); + Assert.Null(result); + } +}