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) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:24:56 -07:00
parent 6cadda01a8
commit 7a3d78d741
4 changed files with 328 additions and 6 deletions

91
manifest/manifest.go Normal file
View File

@@ -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
}

228
manifest/manifest_test.go Normal file
View File

@@ -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))
}
}