diff options
Diffstat (limited to 'cmd/krino/review.go')
| -rw-r--r-- | cmd/krino/review.go | 180 |
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") +} |
