diff --git a/internal/tasks/marvin.go b/internal/tasks/marvin.go new file mode 100644 index 0000000..caea6d1 --- /dev/null +++ b/internal/tasks/marvin.go @@ -0,0 +1,85 @@ +package tasks + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// runner executes a command and returns its stdout. Production shells out; +// tests inject a fake to avoid spawning a process. +type runner func(ctx context.Context, name string, args ...string) ([]byte, error) + +func execRunner(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + if err := cmd.Run(); err != nil { + if s := strings.TrimSpace(errb.String()); s != "" { + return nil, fmt.Errorf("%s: %w: %s", name, err, s) + } + return nil, fmt.Errorf("%s: %w", name, err) + } + return out.Bytes(), nil +} + +// Marvin is the Amazing Marvin adapter. It shells out to ampy's CLI +// (`am --json`, no subcommand) and parses the open tasks due today or earlier. +type Marvin struct { + cmd string + args []string + run runner +} + +// NewMarvin builds the adapter. command is split on spaces so both "am" and +// "uv run am" work; empty defaults to "am". The "--json" flag is always +// appended, and no subcommand is passed (ampy lists today's tasks by default). +func NewMarvin(command string) *Marvin { + fields := strings.Fields(command) + if len(fields) == 0 { + fields = []string{"am"} + } + args := append([]string{}, fields[1:]...) + args = append(args, "--json") + return &Marvin{cmd: fields[0], args: args, run: execRunner} +} + +// Today returns the open tasks due today or earlier, or an error if the CLI +// fails. Callers degrade gracefully on error. +func (m *Marvin) Today(ctx context.Context) ([]Task, error) { + out, err := m.run(ctx, m.cmd, m.args...) + if err != nil { + return nil, err + } + return parse(out) +} + +type rawTask struct { + ID string `json:"id"` + Title string `json:"title"` + Day string `json:"day"` + Done bool `json:"done"` +} + +// parse maps `am --json` output (a JSON array of task objects) to []Task. It +// drops tasks that are done or have an empty title. A JSON null or empty array +// yields an empty slice, not an error. +func parse(data []byte) ([]Task, error) { + var raw []rawTask + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("tasks: parse: %w", err) + } + out := make([]Task, 0, len(raw)) + for _, r := range raw { + title := strings.TrimSpace(r.Title) + if r.Done || title == "" { + continue + } + out = append(out, Task{ID: r.ID, Title: title, Day: r.Day}) + } + return out, nil +} diff --git a/internal/tasks/marvin_test.go b/internal/tasks/marvin_test.go new file mode 100644 index 0000000..1124af2 --- /dev/null +++ b/internal/tasks/marvin_test.go @@ -0,0 +1,108 @@ +package tasks + +import ( + "context" + "errors" + "testing" +) + +func TestParse(t *testing.T) { + cases := []struct { + name string + in string + want []Task + wantErr bool + }{ + { + name: "valid array", + in: `[{"id":"a","title":"Write spec","parentId":"p","day":"2026-05-31","done":false}]`, + want: []Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}, + }, + {name: "empty array", in: `[]`, want: []Task{}}, + {name: "json null", in: `null`, want: []Task{}}, + { + name: "drops done and empty-title", + in: `[{"id":"a","title":"keep","day":"","done":false},{"id":"b","title":"done one","done":true},{"id":"c","title":" ","done":false}]`, + want: []Task{{ID: "a", Title: "keep", Day: ""}}, + }, + {name: "malformed", in: `not json`, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parse([]byte(tc.in)) + if tc.wantErr { + if err == nil { + t.Fatal("want error, got nil") + } + return + } + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("len = %d, want %d (%+v)", len(got), len(tc.want), got) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("[%d] = %+v, want %+v", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestNewMarvinCommandSplitting(t *testing.T) { + cases := []struct { + in string + wantCmd string + wantArgs []string + }{ + {"", "am", []string{"--json"}}, + {"am", "am", []string{"--json"}}, + {"uv run am", "uv", []string{"run", "am", "--json"}}, + } + for _, tc := range cases { + m := NewMarvin(tc.in) + if m.cmd != tc.wantCmd { + t.Errorf("NewMarvin(%q).cmd = %q, want %q", tc.in, m.cmd, tc.wantCmd) + } + if len(m.args) != len(tc.wantArgs) { + t.Fatalf("NewMarvin(%q).args = %v, want %v", tc.in, m.args, tc.wantArgs) + } + for i := range m.args { + if m.args[i] != tc.wantArgs[i] { + t.Errorf("NewMarvin(%q).args[%d] = %q, want %q", tc.in, i, m.args[i], tc.wantArgs[i]) + } + } + } +} + +func TestTodayUsesRunnerOutput(t *testing.T) { + m := NewMarvin("am") + var gotName string + var gotArgs []string + m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { + gotName, gotArgs = name, args + return []byte(`[{"id":"x","title":"Do thing","day":"2026-05-31","done":false}]`), nil + } + got, err := m.Today(context.Background()) + if err != nil { + t.Fatalf("Today: %v", err) + } + if gotName != "am" || len(gotArgs) != 1 || gotArgs[0] != "--json" { + t.Fatalf("runner called with %q %v", gotName, gotArgs) + } + if len(got) != 1 || got[0].Title != "Do thing" { + t.Fatalf("Today result = %+v", got) + } +} + +func TestTodayPropagatesError(t *testing.T) { + m := NewMarvin("am") + m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { + return nil, errors.New("am not found") + } + if _, err := m.Today(context.Background()); err == nil { + t.Fatal("want error from failing runner") + } +} diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go new file mode 100644 index 0000000..1f52779 --- /dev/null +++ b/internal/tasks/tasks.go @@ -0,0 +1,19 @@ +// Package tasks is the Tasks port: it answers "what should I be doing?" by +// listing the open to-do items due today or earlier. It imports nothing from +// the rest of the app, so it stays a leaf package. +package tasks + +import "context" + +// Task is one to-do item. Primitives only, so tasks stays a leaf package. +type Task struct { + ID string + Title string + Day string // "YYYY-MM-DD", or "" if unscheduled +} + +// Provider answers "what should I be doing?" — the open tasks due today or +// earlier. +type Provider interface { + Today(ctx context.Context) ([]Task, error) +}