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>
229 lines
5.0 KiB
Go
229 lines
5.0 KiB
Go
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))
|
|
}
|
|
}
|