diff --git a/PROGRESS.md b/PROGRESS.md index 17d6804..328bc86 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Phase 2 in progress. Step 9 complete, ready for Steps 10+11. +**Phase:** Phase 2 in progress. Steps 9–11 complete, ready for Step 12. **Last updated:** 2026-03-23 @@ -42,7 +42,7 @@ Phase 2: gRPC Remote Sync. ## Up Next -Steps 10 (Garden accessors) + 11 (proto-manifest conversion) — can be parallel. +Step 12: Server Implementation (No Auth). ## Known Issues / Decisions Deferred @@ -69,3 +69,5 @@ Steps 10 (Garden accessors) + 11 (proto-manifest conversion) — can be parallel | 2026-03-23 | — | README, goreleaser config, version command, Nix flake, homebrew formula, release pipeline validated (v0.1.0–v0.1.2). | | 2026-03-23 | — | v1.0.0 released. Docs updated for release. | | 2026-03-23 | 9 | Proto definitions: 5 RPCs (Push/Pull manifest+blobs, Prune), generated sgardpb, Makefile, deps added. | +| 2026-03-23 | 10 | Garden accessor methods: GetManifest, BlobExists, ReadBlob, WriteBlob, ReplaceManifest. 5 tests. | +| 2026-03-23 | 11 | Proto-manifest conversion: ManifestToProto/ProtoToManifest with round-trip tests. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index e495393..37f0916 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -106,17 +106,17 @@ Depends on Step 5. *Can be done in parallel with Step 11.* -- [ ] `garden/garden.go`: `GetManifest()`, `BlobExists()`, `ReadBlob()`, `WriteBlob()`, `ReplaceManifest()` -- [ ] Tests for each accessor -- [ ] Verify: `go test ./garden/...` +- [x] `garden/garden.go`: `GetManifest()`, `BlobExists()`, `ReadBlob()`, `WriteBlob()`, `ReplaceManifest()` +- [x] Tests for each accessor +- [x] Verify: `go test ./garden/...` ### Step 11: Proto-Manifest Conversion *Can be done in parallel with Step 10.* -- [ ] `server/convert.go`: `ManifestToProto`, `ProtoToManifest`, entry helpers -- [ ] `server/convert_test.go`: round-trip test -- [ ] Verify: `go test ./server/...` +- [x] `server/convert.go`: `ManifestToProto`, `ProtoToManifest`, entry helpers +- [x] `server/convert_test.go`: round-trip test +- [x] Verify: `go test ./server/...` ### Step 12: Server Implementation (No Auth) diff --git a/server/convert.go b/server/convert.go new file mode 100644 index 0000000..04ac0d2 --- /dev/null +++ b/server/convert.go @@ -0,0 +1,62 @@ +package server + +import ( + "github.com/kisom/sgard/manifest" + "github.com/kisom/sgard/sgardpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// ManifestToProto converts a manifest.Manifest to its protobuf representation. +func ManifestToProto(m *manifest.Manifest) *sgardpb.Manifest { + files := make([]*sgardpb.ManifestEntry, len(m.Files)) + for i, e := range m.Files { + files[i] = EntryToProto(e) + } + return &sgardpb.Manifest{ + Version: int32(m.Version), + Created: timestamppb.New(m.Created), + Updated: timestamppb.New(m.Updated), + Message: m.Message, + Files: files, + } +} + +// ProtoToManifest converts a protobuf Manifest to a manifest.Manifest. +func ProtoToManifest(p *sgardpb.Manifest) *manifest.Manifest { + pFiles := p.GetFiles() + files := make([]manifest.Entry, len(pFiles)) + for i, e := range pFiles { + files[i] = ProtoToEntry(e) + } + return &manifest.Manifest{ + Version: int(p.GetVersion()), + Created: p.GetCreated().AsTime(), + Updated: p.GetUpdated().AsTime(), + Message: p.GetMessage(), + Files: files, + } +} + +// EntryToProto converts a manifest.Entry to its protobuf representation. +func EntryToProto(e manifest.Entry) *sgardpb.ManifestEntry { + return &sgardpb.ManifestEntry{ + Path: e.Path, + Hash: e.Hash, + Type: e.Type, + Mode: e.Mode, + Target: e.Target, + Updated: timestamppb.New(e.Updated), + } +} + +// ProtoToEntry converts a protobuf ManifestEntry to a manifest.Entry. +func ProtoToEntry(p *sgardpb.ManifestEntry) manifest.Entry { + return manifest.Entry{ + Path: p.GetPath(), + Hash: p.GetHash(), + Type: p.GetType(), + Mode: p.GetMode(), + Target: p.GetTarget(), + Updated: p.GetUpdated().AsTime(), + } +} diff --git a/server/convert_test.go b/server/convert_test.go new file mode 100644 index 0000000..c846c4f --- /dev/null +++ b/server/convert_test.go @@ -0,0 +1,124 @@ +package server + +import ( + "testing" + "time" + + "github.com/kisom/sgard/manifest" +) + +func TestManifestRoundTrip(t *testing.T) { + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + m := &manifest.Manifest{ + Version: 1, + Created: now, + Updated: now, + Message: "test checkpoint", + Files: []manifest.Entry{ + {Path: "~/.bashrc", Hash: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", 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}, + }, + } + + proto := ManifestToProto(m) + back := ProtoToManifest(proto) + + if back.Version != m.Version { + t.Errorf("Version: got %d, want %d", back.Version, m.Version) + } + if !back.Created.Equal(m.Created) { + t.Errorf("Created: got %v, want %v", back.Created, m.Created) + } + if !back.Updated.Equal(m.Updated) { + t.Errorf("Updated: got %v, want %v", back.Updated, m.Updated) + } + if back.Message != m.Message { + t.Errorf("Message: got %q, want %q", back.Message, m.Message) + } + if len(back.Files) != len(m.Files) { + t.Fatalf("Files count: got %d, want %d", len(back.Files), len(m.Files)) + } + for i, want := range m.Files { + got := back.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 TestEmptyManifestRoundTrip(t *testing.T) { + now := time.Date(2026, 6, 15, 8, 30, 0, 0, time.UTC) + m := &manifest.Manifest{ + Version: 1, + Created: now, + Updated: now, + Files: []manifest.Entry{}, + } + + proto := ManifestToProto(m) + back := ProtoToManifest(proto) + + if back.Version != m.Version { + t.Errorf("Version: got %d, want %d", back.Version, m.Version) + } + if !back.Created.Equal(m.Created) { + t.Errorf("Created: got %v, want %v", back.Created, m.Created) + } + if !back.Updated.Equal(m.Updated) { + t.Errorf("Updated: got %v, want %v", back.Updated, m.Updated) + } + if back.Message != "" { + t.Errorf("Message: got %q, want empty", back.Message) + } + if len(back.Files) != 0 { + t.Errorf("Files count: got %d, want 0", len(back.Files)) + } +} + +func TestEntryEmptyOptionalFieldsRoundTrip(t *testing.T) { + now := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + e := manifest.Entry{ + Path: "~/.profile", + Type: "file", + Updated: now, + // Hash, Mode, Target intentionally empty + } + + proto := EntryToProto(e) + back := ProtoToEntry(proto) + + if back.Path != e.Path { + t.Errorf("Path: got %q, want %q", back.Path, e.Path) + } + if back.Hash != "" { + t.Errorf("Hash: got %q, want empty", back.Hash) + } + if back.Type != e.Type { + t.Errorf("Type: got %q, want %q", back.Type, e.Type) + } + if back.Mode != "" { + t.Errorf("Mode: got %q, want empty", back.Mode) + } + if back.Target != "" { + t.Errorf("Target: got %q, want empty", back.Target) + } + if !back.Updated.Equal(e.Updated) { + t.Errorf("Updated: got %v, want %v", back.Updated, e.Updated) + } +}