ajhahn.de
← eeco
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
}