Go 247 lines
// Package queue is eeco's single decision channel.
//
// Items are appended to <workspace>/state/queue.md as a Markdown
// checklist. The user resolves an item by checking its box; nothing in
// the engine deletes user data. Workflows and GC append; later
// milestones add list/resolve helpers.
package queue
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Filename is the queue file name inside <workspace>/state/.
const Filename = "queue.md"
// Item is one queue entry. Kind is a short tag ("gc-review", "evolve",
// etc.). Title is a one-line summary that fits on the checklist row.
// Project is a short project handle (typically the repo basename).
// Detail is an optional one- or few-line elaboration printed as an
// indented continuation line beneath the checklist row.
type Item struct {
Kind string
Title string
Project string
Detail string
Date time.Time
}
// Append writes item to <stateDir>/queue.md, creating the file and
// parent directory if missing. The format follows PLAN.md:
//
// - [ ] **<kind>** — <title> _(<project>, <date>)_
// <detail>
//
// A trailing newline is added if the existing file lacked one so the
// new item starts on its own line.
func Append(stateDir string, item Item) error {
if err := validateItem(stateDir, &item); err != nil {
return err
}
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return fmt.Errorf("queue.Append: create state dir: %w", err)
}
release, err := acquireLock(stateDir)
if err != nil {
return err
}
defer release()
existing, err := readQueue(stateDir)
if err != nil {
return err
}
return writeAppended(stateDir, existing, item)
}
// AppendUnique behaves like Append but skips the write when an open
// (unchecked) item with the same Kind and Title already sits in the
// queue, returning appended=false. It exists so a workflow that may run
// repeatedly — for example a drift check wired into a git hook — does
// not pile up duplicate items for the same unresolved finding. The
// dedup key is Kind+Title only: Project and Date are deliberately
// excluded, so the same finding reported on two different days still
// collapses to one open item. A resolved (checked) item never blocks a
// re-file: if the operator ticked it off and the finding persists,
// filing it again is the correct signal.
func AppendUnique(stateDir string, item Item) (appended bool, err error) {
if verr := validateItem(stateDir, &item); verr != nil {
return false, verr
}
if merr := os.MkdirAll(stateDir, 0o755); merr != nil {
return false, fmt.Errorf("queue.AppendUnique: create state dir: %w", merr)
}
release, err := acquireLock(stateDir)
if err != nil {
return false, err
}
defer release()
existing, err := readQueue(stateDir)
if err != nil {
return false, err
}
if hasOpenItem(existing, item.Kind, item.Title) {
return false, nil
}
if werr := writeAppended(stateDir, existing, item); werr != nil {
return false, werr
}
return true, nil
}
// validateItem checks the shared preconditions and defaults the date.
func validateItem(stateDir string, item *Item) error {
if stateDir == "" {
return errors.New("queue: stateDir is empty")
}
if item.Kind == "" || item.Title == "" {
return errors.New("queue: kind and title are required")
}
if item.Date.IsZero() {
item.Date = time.Now()
}
return nil
}
// readQueue reads the queue file, treating a missing file as empty.
// Callers hold the lock.
func readQueue(stateDir string) ([]byte, error) {
b, err := os.ReadFile(filepath.Join(stateDir, Filename))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("queue: read: %w", err)
}
return b, nil
}
// writeAppended renders item onto existing and writes the queue file.
// Callers hold the lock.
func writeAppended(stateDir string, existing []byte, item Item) error {
var buf bytes.Buffer
if len(existing) == 0 {
buf.WriteString("# eeco queue\n\n")
} else {
buf.Write(existing)
if !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteByte('\n')
}
}
fmt.Fprintf(&buf, "- [ ] **%s** — %s _(%s, %s)_\n",
item.Kind, item.Title, item.Project, item.Date.UTC().Format("2006-01-02"))
if d := strings.TrimSpace(item.Detail); d != "" {
for _, line := range strings.Split(d, "\n") {
fmt.Fprintf(&buf, " %s\n", strings.TrimRight(line, " \t\r"))
}
}
return os.WriteFile(filepath.Join(stateDir, Filename), buf.Bytes(), 0o644)
}
// hasOpenItem reports whether content carries an unchecked item whose
// kind and title match. Resolved (checked) items are ignored.
func hasOpenItem(content []byte, kind, title string) bool {
for _, line := range strings.Split(string(content), "\n") {
k, t, ok := parseOpenRow(line)
if ok && k == kind && t == title {
return true
}
}
return false
}
// parseOpenRow extracts the kind and title from an open checklist row in
// the frozen format `- [ ] **<kind>** — <title> _(<project>, <date>)_`.
// It returns ok=false for resolved rows, detail/continuation lines, and
// anything not matching the row shape.
func parseOpenRow(line string) (kind, title string, ok bool) {
return parseRow(line, "- [ ] **")
}
// parseResolvedRow is the resolved-checkbox counterpart of parseOpenRow.
// It returns ok=false for unresolved rows and non-row lines.
func parseResolvedRow(line string) (kind, title string, ok bool) {
return parseRow(line, "- [x] **")
}
// parseRow is the shared row parser. prefix selects the checkbox state:
// `- [ ] **` for open, `- [x] **` for resolved.
func parseRow(line, prefix string) (kind, title string, ok bool) {
rest := strings.TrimSpace(line)
if !strings.HasPrefix(rest, prefix) {
return "", "", false
}
rest = rest[len(prefix):]
end := strings.Index(rest, "**")
if end < 0 {
return "", "", false
}
kind = rest[:end]
rest = rest[end+2:]
const sep = " — "
if !strings.HasPrefix(rest, sep) {
return "", "", false
}
rest = rest[len(sep):]
// The title runs up to the trailing ` _(<project>, <date>)_` suffix.
// Trim from the last ` _(` so a title that itself contains "_(" is
// handled correctly.
if cut := strings.LastIndex(rest, " _("); cut >= 0 {
rest = rest[:cut]
}
title = rest
if kind == "" || title == "" {
return "", "", false
}
return kind, title, true
}
// Resolved reports whether <stateDir>/queue.md carries a resolved
// (checked) item with the given kind and title. A missing queue file
// is reported as not resolved with no error. Counterpart to the
// internal hasOpenItem used by AppendUnique; used by the evolve
// repetition ledger to reconcile its records against operator
// resolution.
func Resolved(stateDir, kind, title string) (bool, error) {
b, err := os.ReadFile(filepath.Join(stateDir, Filename))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("queue.Resolved: %w", err)
}
for _, line := range strings.Split(string(b), "\n") {
k, t, ok := parseResolvedRow(line)
if ok && k == kind && t == title {
return true, nil
}
}
return false, nil
}
// Count returns the number of unchecked items in <stateDir>/queue.md.
// A missing file is reported as zero.
func Count(stateDir string) (int, error) {
path := filepath.Join(stateDir, Filename)
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
return 0, fmt.Errorf("queue.Count: %w", err)
}
n := 0
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "- [ ]") {
n++
}
}
return n, nil
}