// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "fmt" "io" "os" "git.labunix.xyz/krino/internal/plan" "git.labunix.xyz/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, map[string]plan.Kind, 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 [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing // // prompt. action is always one of 'a', 'c', 's', 'w' 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 [w] ("write, apply chosen so // far"), which keeps it and is returned as 'w': the caller applies what was // decided and stops. approved holds every file decided, true for yes and // false for no; a file [w] left unreviewed is absent. replaced holds the // files [t] or [d] chose to trash or delete instead of what the rules // planned; replaceChains applies it. Enter is ignored at both prompts. root // is the directory being reviewed - passed only to reviewPerFile's // destination rendering; nothing here uses it directly. func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, map[string]plan.Kind, 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, nil, 0, err } switch key { case '\r', '\n': continue case 'a': return approveAll(chains), nil, 'a', nil case 's': return map[string]bool{}, nil, 's', nil case 'q': return map[string]bool{}, nil, 'q', nil case 'c': approved, replaced, end, err := reviewPerFile(in, out, chains, root, p) if err != nil { return nil, nil, 0, err } switch end { case 'q': return map[string]bool{}, nil, 'q', nil case 'w': return approved, replaced, 'w', nil } return approved, replaced, '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; [t] and [d] approve it with its chain replaced by one trash or // permanent delete step, [d] only after a y to its own confirmation (any // other key asks about the same file again); every choice is echoed in red // on its own line; [w] stops asking and applies whatever was already chosen, // leaving the rest unreviewed - reported back as end 'w'; [q] aborts the // review entirely, discarding even files already marked yes - end 'q'. root // is passed down so a destination inside root renders root-relative and one // outside it renders ~-abbreviated, exactly as in the plan. func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (approved map[string]bool, replaced map[string]plan.Kind, end rune, err error) { approved = map[string]bool{} replaced = map[string]plan.Kind{} yesRest := false for i, c := range chains { if yesRest { approved[c.File.Rel] = true continue } // The heading wraps with its continuation past the label column, // so a long name cannot pass for a step line. fmt.Fprintln(out) for _, l := range wrapped("", fmt.Sprintf("[%d/%d] %s", i+1, len(chains), display(c.File.Rel)), 7+labelWidth+1, widthPolicy(out), plainText) { fmt.Fprintln(out, l) } for _, l := range stepLines(c, 7, root, p, widthPolicy(out)) { fmt.Fprintln(out, l) } showKeys := true for { if showKeys { for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) { fmt.Fprintln(out, l) } showKeys = false } key, kerr := readKey(in) if kerr != nil { return nil, nil, 0, kerr } var choice string switch key { case '\r', '\n': continue case 'y': approved[c.File.Rel] = true choice = "yes" case 'n': approved[c.File.Rel] = false choice = "no" case 'a': approved[c.File.Rel] = true yesRest = true choice = "yes, and all remaining" case 't': approved[c.File.Rel] = true replaced[c.File.Rel] = plan.Trash choice = "trash" case 'd': fmt.Fprintf(out, " delete %s permanently? [y/N] ", display(c.File.Rel)) confirm, kerr := readKey(in) if kerr != nil { return nil, nil, 0, kerr } fmt.Fprintln(out) if confirm != 'y' { fmt.Fprintln(out, " not deleted") showKeys = true continue } approved[c.File.Rel] = true replaced[c.File.Rel] = plan.DeletePermanent choice = "DELETE permanently" case 'w': return approved, replaced, 'w', nil case 'q': return nil, nil, 'q', nil default: fmt.Fprintf(out, "%q is not y, n, a, t, d, w or q\n", key) continue } fmt.Fprintln(out, " "+p.bad("→ "+choice)) break } } return approved, replaced, 0, nil } // reviewedChains returns the chains of the files decided holds, yes or no, // in order: after [w], only these go to Apply, so a file never reviewed is // neither applied nor logged as declined. func reviewedChains(chains []plan.Chain, decided map[string]bool) []plan.Chain { var out []plan.Chain for _, c := range chains { if _, ok := decided[c.File.Rel]; ok { out = append(out, c) } } return out } // withNotReviewed appends to an outcome line how many files [w] left // unreviewed, when any were. func withNotReviewed(line string, n int) string { if n == 0 { return line } return fmt.Sprintf("%s · %d not reviewed", line, n) } // perFileKeys is the per-file prompt of spec §8.3, wrapped to the terminal // like every other long line. Undo's has no [t] or [d] (undoPerFileKeys). const perFileKeys = "[y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing" // reviewRule is the rule name a step chosen in review is planned and logged // under, in parentheses so no configured rule can be mistaken for it. const reviewRule = "(review)" // replaceChains returns chains with the chain of every file in replaced // swapped for one step of the chosen kind on the file itself, under // reviewRule: [t] and [d] set aside what the rules planned. chains itself // is not modified. func replaceChains(chains []plan.Chain, replaced map[string]plan.Kind) []plan.Chain { out := make([]plan.Chain, len(chains)) for i, c := range chains { if k, ok := replaced[c.File.Rel]; ok { c.Steps = []plan.Step{{Kind: k, Rule: reviewRule, Src: c.File.Path, Reason: "chosen in review"}} c.Warnings = nil } out[i] = c } return out } // 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 }