aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/review.go
blob: dcf7663f5a66b74e6bc3b9e6590f9cbba0b60dd8 (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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
// 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, 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 (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, 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 (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, 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
		}

		fmt.Fprintf(out, "\n[%d/%d] %s\n", i+1, len(chains), display(c.File.Rel))
		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
}