package todos import ( "errors" "os" "path/filepath" "strings" "testing" ) const fixture = `{ "id": "deadbeef", "title": "A brace } and quote \"", "tags": ["qa", "pi"], "status": "in_progress", "created_at": "2026-01-25T17:00:00.000Z", "assigned_to_session": "session.json" } ## Details Keep {braces} in the body. ` func TestParseAndSerializePiFixture(t *testing.T) { todo, err := Parse("deadbeef", []byte(fixture)) if err != nil { t.Fatal(err) } if todo.ID != "deadbeef" || todo.Title != `A brace } and quote "` || todo.Status != "in_progress" || todo.AssignedToSession != "session.json" || !strings.Contains(todo.Body, "{braces}") { t.Fatalf("unexpected parsed todo: %+v", todo) } data, err := Serialize(todo) if err != nil { t.Fatal(err) } again, err := Parse(todo.ID, data) if err != nil || again.Title != todo.Title || again.Status != todo.Status || again.Body != todo.Body { t.Fatalf("round trip mismatch: %+v, %v", again, err) } } func TestCreateUpdateAndList(t *testing.T) { dir := filepath.Join(t.TempDir(), ".pi", "todos") if items, err := List(dir); err != nil || len(items) != 0 { t.Fatalf("missing directory should be empty: %v, %v", items, err) } created, err := Create(dir, Todo{Title: "First", Body: "hello\n"}) if err != nil || !idPattern.MatchString(created.ID) || created.Status != "open" || created.CreatedAt == "" { t.Fatalf("unexpected create: %+v, %v", created, err) } loaded, err := Read(dir, created.ID) if err != nil || loaded.Title != "First" || loaded.Body != "hello\n" { t.Fatalf("unexpected read: %+v, %v", loaded, err) } loaded.Title = "Changed" loaded.Status = "blocked" // Pi supports statuses besides open/closed loaded.Tags = []string{"test"} loaded.AssignedToSession = "session.json" if err := Update(dir, loaded); err != nil { t.Fatal(err) } items, err := List(dir) if err != nil || len(items) != 1 || items[0].Title != "Changed" || items[0].Status != "blocked" || items[0].CreatedAt != created.CreatedAt || items[0].AssignedToSession != "" { t.Fatalf("unexpected update: %+v, %v", items, err) } if _, err := os.Stat(filepath.Join(dir, created.ID+".lock")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("lock left behind: %v", err) } } func TestLockedFileUnchanged(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "deadbeef.md") if err := os.WriteFile(path, []byte(fixture), 0600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "deadbeef.lock"), []byte(`{"session":"other"}`), 0600); err != nil { t.Fatal(err) } err := Update(dir, Todo{ID: "deadbeef", Title: "Changed"}) if !errors.Is(err, ErrLocked) { t.Fatalf("expected lock error: %v", err) } content, _ := os.ReadFile(path) if string(content) != fixture { t.Fatal("locked file was changed") } } func TestUpdatePreservesAssignment(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte(fixture), 0600); err != nil { t.Fatal(err) } if err := Update(dir, Todo{ID: "deadbeef", Title: "Edited", Body: "text"}); err != nil { t.Fatal(err) } result, err := Read(dir, "deadbeef") if err != nil || result.AssignedToSession != "session.json" || result.CreatedAt != "2026-01-25T17:00:00.000Z" || result.Status != "in_progress" || len(result.Tags) != 2 { t.Fatalf("metadata lost: %+v, %v", result, err) } } func TestSerializePreservesBodyIndentation(t *testing.T) { data, err := Serialize(Todo{ID: "deadbeef", Body: "\n indented text\n"}) if err != nil || !strings.HasSuffix(string(data), "\n\n indented text\n") { t.Fatalf("indentation lost: %s, %v", data, err) } } func TestMalformedAndUnsafeIDs(t *testing.T) { if _, err := Parse("../bad", []byte(fixture)); err == nil { t.Fatal("unsafe ID accepted") } if _, err := Parse("deadbeef", []byte(`{bad`)); err == nil { t.Fatal("bad JSON accepted") } if _, err := Parse("deadbeef", []byte(`{"id":"cafebabe"}`)); err == nil { t.Fatal("mismatched ID accepted") } dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte(`{bad`), 0600); err != nil { t.Fatal(err) } if _, err := List(dir); err == nil { t.Fatal("malformed file silently ignored") } }