Add tasks port and Amazing Marvin adapter
The tasks.Provider port answers "what should I be doing?". The Marvin adapter shells out to ampy's `am --json` and parses today's open tasks, dropping done/empty-title entries. Leaf package mirroring ai. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user