// 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.Fprint(out, "\n"+p.keys("[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, 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 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, 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 _, s := range c.Steps { fmt.Fprintf(out, " %s\n", styleAction(p, s, actionCell(s, root))) } fmt.Fprint(out, " "+p.keys("[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 }