// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "fmt" "io" "os" "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. p // styles the prompts and the per-file steps (spec §8.2). func reviewDir(out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, rune, error) { return reviewChains(keyReader{stdin}, out, chains, root, p) } // 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, p palette) (map[string]bool, rune, error) { fmt.Fprintln(out) for _, l := range wrapped("", "[a] apply all [c] choose per file [s] skip this directory [q] quit", 0, widthPolicy(out), p.keys) { fmt.Fprintln(out, l) } 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, p) 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 plan above it numbered them). // Each file shows the same block body the plan shows (stepLines): every // step, its rule and its reason, wrapped to the terminal. [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 down so a destination inside root renders root-relative and one // outside it renders ~-abbreviated, exactly as in the plan (review finding // 1, fix round 2026-09-12). func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (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 _, l := range stepLines(c, 7, root, p, widthPolicy(out)) { fmt.Fprintln(out, l) } for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) { fmt.Fprintln(out, l) } 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 } // perFileKeys is the per-file prompt of spec §8.3, shared by review and // undo, and wrapped to the terminal like every other long line. const perFileKeys = "[y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing" // 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 }