From 7a3d78d741a0435d0bf6878fedc1a90018c56ab2 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 23 Mar 2026 21:24:56 -0700 Subject: [PATCH] Step 2: Add manifest package with YAML data model and persistence. Implements Manifest and Entry structs with YAML tags, New() constructor, Load(path) for reading, and Save(path) with atomic write (temp file + rename). Tests cover round-trip serialization, atomic save cleanup, entry type invariants, nonexistent file error, and empty manifest. Co-Authored-By: Claude Opus 4.6 (1M context) --- PROGRESS.md | 9 +- PROJECT_PLAN.md | 6 +- manifest/manifest.go | 91 +++++++++++++++ manifest/manifest_test.go | 228 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 manifest/manifest.go create mode 100644 manifest/manifest_test.go diff --git a/PROGRESS.md b/PROGRESS.md index eea00e9..51b1d81 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Step 1 complete. Ready for Steps 2 & 3 (can be parallel). +**Phase:** Step 2 complete. Ready for Step 3 (Store Package) and then Step 4. **Last updated:** 2026-03-23 @@ -16,6 +16,9 @@ ARCHITECTURE.md for design details. - **Step 1: Project Scaffolding** — removed old C++ files and `.trunk/` config, initialized Go module, added cobra + yaml.v3 deps, created package dirs, set up cobra root command with `--repo` flag. +- **Step 2: Manifest Package** — `Manifest` and `Entry` structs with YAML tags, + `New()`, `Load(path)`, and `Save(path)` with atomic write. Tests cover + round-trip, atomic save, entry types, nonexistent file, and empty manifest. ## In Progress @@ -23,8 +26,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 2 (Manifest Package) and Step 3 (Store Package) — these can be done -in parallel. +Step 3 (Store Package), then Step 4 (Garden Core). ## Known Issues / Decisions Deferred @@ -39,3 +41,4 @@ in parallel. |---|---|---| | 2026-03-23 | — | Design phase complete. ARCHITECTURE.md and PROJECT_PLAN.md written. | | 2026-03-23 | 1 | Scaffolding complete. Old C++ removed, Go module initialized, cobra root command. | +| 2026-03-23 | 2 | Manifest package complete. Structs, Load/Save with atomic write, full test suite. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index bb503fe..09aaa19 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -18,13 +18,13 @@ Remove old C++ source files and set up the Go project. *Can be done in parallel with Step 3.* -- [ ] `manifest/manifest.go`: `Manifest` and `Entry` structs with YAML tags +- [x] `manifest/manifest.go`: `Manifest` and `Entry` structs with YAML tags - Entry types: `file`, `directory`, `link` - Mode as string type to avoid YAML octal coercion - Per-file `updated` timestamp -- [ ] `manifest/manifest.go`: `Load(path)` and `Save(path)` functions +- [x] `manifest/manifest.go`: `Load(path)` and `Save(path)` functions - Save uses atomic write (write to `.tmp`, rename) -- [ ] `manifest/manifest_test.go`: round-trip marshal/unmarshal, atomic save, entry type validation +- [x] `manifest/manifest_test.go`: round-trip marshal/unmarshal, atomic save, entry type validation ## Step 3: Store Package diff --git a/manifest/manifest.go b/manifest/manifest.go new file mode 100644 index 0000000..3d6c657 --- /dev/null +++ b/manifest/manifest.go @@ -0,0 +1,91 @@ +package manifest + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// Entry represents a single tracked file, directory, or symlink. +type Entry struct { + Path string `yaml:"path"` + Hash string `yaml:"hash,omitempty"` + Type string `yaml:"type"` + Mode string `yaml:"mode,omitempty"` + Target string `yaml:"target,omitempty"` + Updated time.Time `yaml:"updated"` +} + +// Manifest is the top-level manifest describing all tracked entries. +type Manifest struct { + Version int `yaml:"version"` + Created time.Time `yaml:"created"` + Updated time.Time `yaml:"updated"` + Message string `yaml:"message,omitempty"` + Files []Entry `yaml:"files"` +} + +// New creates a new empty manifest with Version 1 and timestamps set to now. +func New() *Manifest { + now := time.Now().UTC() + return &Manifest{ + Version: 1, + Created: now, + Updated: now, + Files: []Entry{}, + } +} + +// Load reads a manifest from the given file path. +func Load(path string) (*Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading manifest: %w", err) + } + + var m Manifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing manifest: %w", err) + } + + return &m, nil +} + +// Save writes the manifest to the given file path using an atomic +// write: data is marshalled to a temporary file in the same directory, +// then renamed to the target path. This prevents corruption if the +// process crashes mid-write. +func (m *Manifest) Save(path string) error { + data, err := yaml.Marshal(m) + if err != nil { + return fmt.Errorf("marshalling manifest: %w", err) + } + + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpName := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("writing temp file: %w", err) + } + + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("closing temp file: %w", err) + } + + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("renaming temp file: %w", err) + } + + return nil +} diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go new file mode 100644 index 0000000..795171a --- /dev/null +++ b/manifest/manifest_test.go @@ -0,0 +1,228 @@ +package manifest + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRoundTrip(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + + m := &Manifest{ + Version: 1, + Created: now, + Updated: now, + Message: "test checkpoint", + Files: []Entry{ + { + Path: "~/.bashrc", + Hash: "a1b2c3d4e5f6", + Type: "file", + Mode: "0644", + Updated: now, + }, + { + Path: "~/.config/nvim", + Type: "directory", + Mode: "0755", + Updated: now, + }, + { + Path: "~/.vimrc", + Type: "link", + Target: "~/.config/nvim/init.vim", + Updated: now, + }, + }, + } + + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + + if err := m.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if loaded.Version != m.Version { + t.Errorf("Version: got %d, want %d", loaded.Version, m.Version) + } + if !loaded.Created.Equal(m.Created) { + t.Errorf("Created: got %v, want %v", loaded.Created, m.Created) + } + if !loaded.Updated.Equal(m.Updated) { + t.Errorf("Updated: got %v, want %v", loaded.Updated, m.Updated) + } + if loaded.Message != m.Message { + t.Errorf("Message: got %q, want %q", loaded.Message, m.Message) + } + if len(loaded.Files) != len(m.Files) { + t.Fatalf("Files: got %d entries, want %d", len(loaded.Files), len(m.Files)) + } + + for i, got := range loaded.Files { + want := m.Files[i] + if got.Path != want.Path { + t.Errorf("Files[%d].Path: got %q, want %q", i, got.Path, want.Path) + } + if got.Hash != want.Hash { + t.Errorf("Files[%d].Hash: got %q, want %q", i, got.Hash, want.Hash) + } + if got.Type != want.Type { + t.Errorf("Files[%d].Type: got %q, want %q", i, got.Type, want.Type) + } + if got.Mode != want.Mode { + t.Errorf("Files[%d].Mode: got %q, want %q", i, got.Mode, want.Mode) + } + if got.Target != want.Target { + t.Errorf("Files[%d].Target: got %q, want %q", i, got.Target, want.Target) + } + if !got.Updated.Equal(want.Updated) { + t.Errorf("Files[%d].Updated: got %v, want %v", i, got.Updated, want.Updated) + } + } +} + +func TestAtomicSave(t *testing.T) { + m := New() + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + + if err := m.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + // Target file must exist. + if _, err := os.Stat(path); err != nil { + t.Errorf("target file missing: %v", err) + } + + // No .tmp files should remain. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + for _, e := range entries { + if strings.Contains(e.Name(), ".tmp") { + t.Errorf("temp file remains: %s", e.Name()) + } + } + + // Verify content is valid YAML that loads back. + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load after save: %v", err) + } + if loaded.Version != 1 { + t.Errorf("loaded version: got %d, want 1", loaded.Version) + } +} + +func TestEntryTypes(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + + m := &Manifest{ + Version: 1, + Created: now, + Updated: now, + Files: []Entry{ + { + Path: "~/.bashrc", + Hash: "abc123", + Type: "file", + Mode: "0644", + Updated: now, + }, + { + Path: "~/.config", + Type: "directory", + Mode: "0755", + Updated: now, + }, + { + Path: "~/.vimrc", + Type: "link", + Target: "~/.config/nvim/init.vim", + Updated: now, + }, + }, + } + + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + + if err := m.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + // File entry: has hash and mode. + file := loaded.Files[0] + if file.Hash == "" { + t.Error("file entry should have hash") + } + if file.Mode == "" { + t.Error("file entry should have mode") + } + + // Directory entry: has mode, no hash. + directory := loaded.Files[1] + if directory.Hash != "" { + t.Errorf("directory entry should have no hash, got %q", directory.Hash) + } + if directory.Mode == "" { + t.Error("directory entry should have mode") + } + + // Link entry: has target, no hash. + link := loaded.Files[2] + if link.Hash != "" { + t.Errorf("link entry should have no hash, got %q", link.Hash) + } + if link.Target == "" { + t.Error("link entry should have target") + } +} + +func TestLoadNonexistent(t *testing.T) { + _, err := Load("/nonexistent/path/manifest.yaml") + if err == nil { + t.Fatal("Load of nonexistent file should return error") + } +} + +func TestNew(t *testing.T) { + before := time.Now().UTC() + m := New() + after := time.Now().UTC() + + if m.Version != 1 { + t.Errorf("Version: got %d, want 1", m.Version) + } + if m.Created.Before(before) || m.Created.After(after) { + t.Errorf("Created %v not between %v and %v", m.Created, before, after) + } + if m.Updated.Before(before) || m.Updated.After(after) { + t.Errorf("Updated %v not between %v and %v", m.Updated, before, after) + } + if m.Message != "" { + t.Errorf("Message: got %q, want empty", m.Message) + } + if m.Files == nil { + t.Error("Files should be non-nil empty slice") + } + if len(m.Files) != 0 { + t.Errorf("Files: got %d entries, want 0", len(m.Files)) + } +}