app frame + storage
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
// Package todos reads and writes the Markdown todo files used by Pi's todo extension.
|
||||
package todos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var idPattern = regexp.MustCompile(`^[a-f0-9]{8}$`)
|
||||
|
||||
// ErrLocked means another session owns the todo's .lock file. We never steal locks.
|
||||
var ErrLocked = errors.New("todo is locked by another session")
|
||||
|
||||
// Todo matches the pi extension's JSON header and optional Markdown body.
|
||||
type Todo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
AssignedToSession string `json:"assigned_to_session,omitempty"`
|
||||
Body string `json:"-"`
|
||||
}
|
||||
|
||||
// Dir returns Pi's default todo directory for a project.
|
||||
func Dir(project string) string { return filepath.Join(project, ".pi", "todos") }
|
||||
|
||||
func validateID(id string) error {
|
||||
if !idPattern.MatchString(id) {
|
||||
return fmt.Errorf("invalid todo ID %q: expected 8 lowercase hex digits", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse reads a JSON object followed by an optional blank line and Markdown body.
|
||||
// The filename is authoritative for the ID, as in the Pi extension.
|
||||
func Parse(id string, data []byte) (Todo, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return Todo{}, err
|
||||
}
|
||||
if len(data) == 0 || data[0] != '{' {
|
||||
return Todo{}, fmt.Errorf("todo %s: missing JSON header", id)
|
||||
}
|
||||
var header struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
AssignedToSession string `json:"assigned_to_session"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
if err := decoder.Decode(&header); err != nil {
|
||||
return Todo{}, fmt.Errorf("todo %s: invalid JSON header: %w", id, err)
|
||||
}
|
||||
if header.ID != "" && header.ID != id {
|
||||
return Todo{}, fmt.Errorf("todo %s: header ID %q differs from filename", id, header.ID)
|
||||
}
|
||||
rest := data[decoder.InputOffset():]
|
||||
if len(rest) > 0 && rest[0] != '\n' && !bytes.HasPrefix(rest, []byte("\r\n")) {
|
||||
return Todo{}, fmt.Errorf("todo %s: expected newline after JSON header", id)
|
||||
}
|
||||
body := strings.TrimLeft(string(rest), "\r\n")
|
||||
if header.Status == "" {
|
||||
header.Status = "open"
|
||||
}
|
||||
if header.Tags == nil {
|
||||
header.Tags = []string{}
|
||||
}
|
||||
return Todo{ID: id, Title: header.Title, Tags: header.Tags, Status: header.Status,
|
||||
CreatedAt: header.CreatedAt, AssignedToSession: header.AssignedToSession, Body: body}, nil
|
||||
}
|
||||
|
||||
// Serialize emits the extension's JSON header and a blank line before any body.
|
||||
func Serialize(todo Todo) ([]byte, error) {
|
||||
if err := validateID(todo.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if todo.Tags == nil {
|
||||
todo.Tags = []string{}
|
||||
}
|
||||
if todo.Status == "" {
|
||||
todo.Status = "open"
|
||||
}
|
||||
header, err := json.MarshalIndent(todo, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := strings.TrimRight(strings.TrimLeft(todo.Body, "\r\n"), " \t\r\n")
|
||||
if body == "" {
|
||||
return append(header, '\n'), nil
|
||||
}
|
||||
return []byte(string(header) + "\n\n" + body + "\n"), nil
|
||||
}
|
||||
|
||||
// Read loads one todo. It does not modify or acquire a lock on the file.
|
||||
func Read(dir, id string) (Todo, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return Todo{}, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, id+".md"))
|
||||
if err != nil {
|
||||
return Todo{}, fmt.Errorf("read todo %s: %w", id, err)
|
||||
}
|
||||
return Parse(id, data)
|
||||
}
|
||||
|
||||
// List reads todos in filename order. Malformed files are reported, not overwritten.
|
||||
func List(dir string) ([]Todo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Todo{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read todo directory: %w", err)
|
||||
}
|
||||
result := make([]Todo, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(entry.Name(), ".md")
|
||||
if err := validateID(id); err != nil {
|
||||
continue // unrelated Markdown file
|
||||
}
|
||||
todo, err := Read(dir, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, todo)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func withLock(dir, id string, fn func() error) error {
|
||||
lockPath := filepath.Join(dir, id+".lock")
|
||||
lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("todo %s: %w", id, ErrLocked)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock todo %s: %w", id, err)
|
||||
}
|
||||
// Include the fields Pi's extension expects, without claiming a Pi session.
|
||||
info, _ := json.Marshal(map[string]any{"id": id, "pid": os.Getpid(), "created_at": time.Now().UTC().Format(time.RFC3339Nano)})
|
||||
if _, err := lock.Write(info); err != nil {
|
||||
lock.Close()
|
||||
os.Remove(lockPath)
|
||||
return fmt.Errorf("write lock for todo %s: %w", id, err)
|
||||
}
|
||||
lock.Close()
|
||||
defer os.Remove(lockPath)
|
||||
return fn()
|
||||
}
|
||||
|
||||
func writeTemp(dir string, data []byte, perm os.FileMode) (string, error) {
|
||||
temp, err := os.CreateTemp(dir, ".todoapp-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := temp.Name()
|
||||
defer func() {
|
||||
temp.Close()
|
||||
if err != nil {
|
||||
os.Remove(name)
|
||||
}
|
||||
}()
|
||||
if err = temp.Chmod(perm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = temp.Write(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = temp.Sync(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = temp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// Create allocates a random Pi-compatible ID and never replaces an existing file.
|
||||
func Create(dir string, todo Todo) (Todo, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return Todo{}, fmt.Errorf("create todo directory: %w", err)
|
||||
}
|
||||
for range 10 {
|
||||
var random [4]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return Todo{}, err
|
||||
}
|
||||
todo.ID = hex.EncodeToString(random[:])
|
||||
todo.CreatedAt = time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if todo.Status == "" {
|
||||
todo.Status = "open"
|
||||
}
|
||||
data, err := Serialize(todo)
|
||||
if err != nil {
|
||||
return Todo{}, err
|
||||
}
|
||||
err = withLock(dir, todo.ID, func() error {
|
||||
name, err := writeTemp(dir, data, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(name)
|
||||
return os.Link(name, filepath.Join(dir, todo.ID+".md")) // atomic, no overwrite
|
||||
})
|
||||
if errors.Is(err, os.ErrExist) || errors.Is(err, ErrLocked) {
|
||||
continue // rare ID collision
|
||||
}
|
||||
if err != nil {
|
||||
return Todo{}, fmt.Errorf("create todo %s: %w", todo.ID, err)
|
||||
}
|
||||
return todo, nil
|
||||
}
|
||||
return Todo{}, errors.New("failed to allocate an unused todo ID")
|
||||
}
|
||||
|
||||
// Update edits a todo under a Pi-compatible lock and atomically replaces the file.
|
||||
// ID, creation time, and assignment always come from the existing file.
|
||||
func Update(dir string, todo Todo) error {
|
||||
if err := validateID(todo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return withLock(dir, todo.ID, func() error {
|
||||
old, err := Read(dir, todo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
todo.CreatedAt = old.CreatedAt
|
||||
todo.AssignedToSession = old.AssignedToSession
|
||||
if todo.Status == "" {
|
||||
todo.Status = old.Status
|
||||
}
|
||||
if todo.Tags == nil {
|
||||
todo.Tags = old.Tags
|
||||
}
|
||||
data, err := Serialize(todo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, todo.ID+".md")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := writeTemp(dir, data, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(name)
|
||||
if err := os.Rename(name, path); err != nil {
|
||||
return fmt.Errorf("replace todo %s: %w", todo.ID, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user