diff --git a/go.mod b/go.mod index 2fd1cfb..eddbd2e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/warrenronsiek/ctask go 1.26.1 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index a6ee3e0..47edb24 100644 --- a/go.sum +++ b/go.sum @@ -7,4 +7,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/workspace/metadata.go b/internal/workspace/metadata.go new file mode 100644 index 0000000..c3c4d74 --- /dev/null +++ b/internal/workspace/metadata.go @@ -0,0 +1,45 @@ +package workspace + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +// TaskMeta represents the task.yaml schema. Exactly these fields, no extras in v0.1. +type TaskMeta struct { + ID string `yaml:"id"` + Slug string `yaml:"slug"` + Title string `yaml:"title"` + CreatedAt time.Time `yaml:"created_at"` + UpdatedAt time.Time `yaml:"updated_at"` + Status string `yaml:"status"` + Category string `yaml:"category"` + Mode string `yaml:"mode"` + Agent string `yaml:"agent"` + WorkspacePath string `yaml:"workspace_path"` + ArchivedAt *time.Time `yaml:"archived_at"` +} + +// WriteMeta writes a TaskMeta to a YAML file. +func WriteMeta(path string, meta *TaskMeta) error { + data, err := yaml.Marshal(meta) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// ReadMeta reads a TaskMeta from a YAML file. +func ReadMeta(path string) (*TaskMeta, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var meta TaskMeta + if err := yaml.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} diff --git a/internal/workspace/metadata_test.go b/internal/workspace/metadata_test.go new file mode 100644 index 0000000..68dee28 --- /dev/null +++ b/internal/workspace/metadata_test.go @@ -0,0 +1,121 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWriteAndReadMeta(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "20260405-143022", + Slug: "arch-notes", + Title: "arch notes", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: "claude", + WorkspacePath: "/home/warren/ai-workspaces/general/20260405_arch-notes", + ArchivedAt: nil, + } + + if err := WriteMeta(path, meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + got, err := ReadMeta(path) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + + if got.ID != meta.ID { + t.Errorf("ID: got %q, want %q", got.ID, meta.ID) + } + if got.Slug != meta.Slug { + t.Errorf("Slug: got %q, want %q", got.Slug, meta.Slug) + } + if got.Status != "active" { + t.Errorf("Status: got %q, want \"active\"", got.Status) + } + if got.ArchivedAt != nil { + t.Errorf("ArchivedAt: expected nil, got %v", got.ArchivedAt) + } + if !got.CreatedAt.Equal(now) { + t.Errorf("CreatedAt: got %v, want %v", got.CreatedAt, now) + } +} + +func TestMetaYAMLFieldsPresent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "20260405-143022", + Slug: "test", + Title: "test", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: "claude", + WorkspacePath: "/tmp/test", + } + + WriteMeta(path, meta) + + data, _ := os.ReadFile(path) + content := string(data) + + for _, field := range []string{"id:", "slug:", "title:", "created_at:", "updated_at:", "status:", "category:", "mode:", "agent:", "workspace_path:", "archived_at:"} { + if !strings.Contains(content, field) { + t.Errorf("missing field %s in YAML output", field) + } + } +} + +func TestMetaArchive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "20260405-143022", + Slug: "test", + Title: "test", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: "claude", + WorkspacePath: "/tmp/test", + } + + WriteMeta(path, meta) + + archiveTime := now.Add(time.Hour) + meta.Status = "archived" + meta.ArchivedAt = &archiveTime + WriteMeta(path, meta) + + got, _ := ReadMeta(path) + if got.Status != "archived" { + t.Errorf("Status: got %q, want \"archived\"", got.Status) + } + if got.ArchivedAt == nil { + t.Fatal("ArchivedAt: expected non-nil") + } + if !got.ArchivedAt.Equal(archiveTime) { + t.Errorf("ArchivedAt: got %v, want %v", got.ArchivedAt, archiveTime) + } +}