// 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 }) }