summaryrefslogtreecommitdiff
path: root/cmd/krino/review.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/krino/review.go')
-rw-r--r--cmd/krino/review.go180
1 files changed, 180 insertions, 0 deletions
diff --git a/cmd/krino/review.go b/cmd/krino/review.go
new file mode 100644
index 0000000..4f5ad05
--- /dev/null
+++ b/cmd/krino/review.go
@@ -0,0 +1,180 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "krino/internal/plan"
+ "krino/internal/tui"
+)
+
+// reviewDir drives spec §8.2/§8.3's interactive review over the real
+// terminal. It is a thin wrapper: reviewChains holds all the actual
+// approval logic, driven here by tui.ReadKey (raw mode while stdin is a
+// terminal, a plain single-byte read otherwise) via keyReader, so the exact
+// same code path runs whether the input is a real keypress or, in tests, a
+// strings.Reader. root is the directory being reviewed, threaded through to
+// reviewChains so a per-file destination renders the same way the
+// directory-level table does (root-relative inside root, ~-abbreviated
+// outside it) instead of always falling back to the abbreviated form.
+func reviewDir(out io.Writer, chains []plan.Chain, root string) (map[string]bool, rune, error) {
+ return reviewChains(keyReader{stdin}, out, chains, root)
+}
+
+// keyReader adapts tui.ReadKey - one key at a time, from a real *os.File -
+// to the io.Reader reviewChains expects. tui.ReadKey's own doc comment is
+// why this is safe to call once per key: it always reads exactly one byte
+// and restores the terminal on every path before returning.
+type keyReader struct{ f *os.File }
+
+func (k keyReader) Read(p []byte) (int, error) {
+ r, err := tui.ReadKey(k.f)
+ if err != nil {
+ return 0, err
+ }
+ p[0] = byte(r)
+ return 1, nil
+}
+
+// reviewChains is spec §8.2/§8.3's approval flow, and the testable core
+// reviewDir wraps: the top-level
+//
+// [a] apply all [c] choose per file [s] skip this directory [q] quit
+//
+// menu, and, for [c], the per-file
+//
+// [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing
+//
+// prompt. action is always one of 'a', 'c', 's' or 'q': a [c] session's own
+// [q] ("quit, apply nothing") folds into the same 'q' the caller already
+// handles for the top-level menu, and approved is emptied to match - even a
+// file already marked yes in that session is discarded, per spec §8.3's
+// wording ("apply nothing"), unlike [d] ("apply chosen so far"), which
+// keeps it. root is the directory being reviewed - passed only to
+// reviewPerFile's destination rendering (review finding 1, fix round
+// 2026-09-12); nothing here uses it directly.
+func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string) (map[string]bool, rune, error) {
+ fmt.Fprint(out, "\n[a] apply all [c] choose per file [s] skip this directory [q] quit\n")
+ for {
+ key, err := readKey(in)
+ if err != nil {
+ return nil, 0, err
+ }
+ switch key {
+ case 'a':
+ return approveAll(chains), 'a', nil
+ case 's':
+ return map[string]bool{}, 's', nil
+ case 'q':
+ return map[string]bool{}, 'q', nil
+ case 'c':
+ approved, quit, err := reviewPerFile(in, out, chains, root)
+ if err != nil {
+ return nil, 0, err
+ }
+ if quit {
+ return map[string]bool{}, 'q', nil
+ }
+ return approved, 'c', nil
+ default:
+ fmt.Fprintf(out, "%q is not a, c, s or q\n", key)
+ }
+ }
+}
+
+// reviewPerFile is spec §8.3: one prompt per file, in the order chains
+// already carries them (the same order the numbered table above it was
+// shown in, per D15 in render.go). [y]/[n] decide just that file; [a]
+// approves it and every remaining file without asking again; [d] stops
+// asking and applies whatever was already chosen, declining the rest; [q]
+// aborts the review entirely, discarding even files already marked yes -
+// reported back to reviewChains via quit=true. root is passed to
+// actionCell exactly as the directory-level table (render.go's planRows)
+// already does, so a destination inside root renders root-relative and one
+// outside it renders ~-abbreviated - review finding 1 (fix round
+// 2026-09-12): passing "" here always fails filepath.Rel("", dir) and
+// silently fell back to the abbreviated form even for a destination inside
+// root, which is not what spec §8.3's own worked example shows.
+func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string) (approved map[string]bool, quit bool, err error) {
+ approved = map[string]bool{}
+ yesRest := false
+ for i, c := range chains {
+ if yesRest {
+ approved[c.File.Rel] = true
+ continue
+ }
+
+ fmt.Fprintf(out, "\n[%d/%d] %s\n", i+1, len(chains), c.File.Rel)
+ for _, s := range c.Steps {
+ fmt.Fprintf(out, " %s\n", actionCell(s, root))
+ }
+ fmt.Fprint(out, " [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing\n")
+
+ for {
+ key, kerr := readKey(in)
+ if kerr != nil {
+ return nil, false, kerr
+ }
+ switch key {
+ case 'y':
+ approved[c.File.Rel] = true
+ case 'n':
+ // leave unapproved
+ case 'a':
+ approved[c.File.Rel] = true
+ yesRest = true
+ case 'd':
+ return approved, false, nil
+ case 'q':
+ return nil, true, nil
+ default:
+ fmt.Fprintf(out, "%q is not y, n, a, d or q\n", key)
+ continue
+ }
+ break
+ }
+ }
+ return approved, false, nil
+}
+
+// readKey reads the single byte reviewChains treats as one keypress. Over
+// the real terminal that byte already came from tui.ReadKey (via
+// keyReader); in a test, it is just the next byte of a strings.Reader.
+func readKey(in io.Reader) (rune, error) {
+ var b [1]byte
+ if _, err := io.ReadFull(in, b[:]); err != nil {
+ return 0, err
+ }
+ return rune(b[0]), nil
+}
+
+// approveAll approves every one of chains by File.Rel - [a] apply all at
+// the top level, and [a] yes to this and all remaining once it fires
+// mid per-file review.
+func approveAll(chains []plan.Chain) map[string]bool {
+ approved := make(map[string]bool, len(chains))
+ for _, c := range chains {
+ approved[c.File.Rel] = true
+ }
+ return approved
+}
+
+// colourDeletePermanently wraps spec §8.2's "DELETE permanently" marker in
+// the terminal's own ANSI red (bold, slot 1 - never hex), the one thing the
+// spec singles out for emphasis (Ruling 2026-09-12/7). It is applied to
+// text render.go's printPlan already produced, rather than threading a
+// colour parameter through the renderer itself: that keeps render.go and
+// its golden-file tests exactly as plan 4 built them. colour is always the
+// caller's own tui.Colour(stdout) decision - with it false this is a no-op,
+// which is what keeps every escape byte out of a plan piped to a file or
+// read by another tool.
+func colourDeletePermanently(text string, colour bool) string {
+ if !colour {
+ return text
+ }
+ return strings.ReplaceAll(text, "DELETE permanently", "\x1b[1;31mDELETE permanently\x1b[0m")
+}