aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/review.go
blob: 4f5ad05b20977119da66eebd07f585d1272ed5e7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
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")
}