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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
|
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"unicode/utf8"
"krino/internal/engine"
"krino/internal/plan"
"krino/internal/xdg"
)
// labelWidth is the column every label in a file's block is padded to, so
// the values line up: the widest label, "because". A kind word longer than
// it (DELETE permanently) has no value beside it, so nothing is misaligned.
const labelWidth = len("because")
// minWrap is the narrowest value column worth wrapping into; below it a
// value is left whole rather than cut into a column of fragments.
const minWrap = 10
// printPlan renders one directory's plan the way krino -n shows it, per
// spec §8.2, below the header line cmdSort has already written: a counts
// line, one block per file that has steps, the warnings section and the
// "not acted on" line, each present only when it has something to show.
// p styles the blocks and the warnings; the zero palette prints plain
// text. width wraps every line to that many columns (the terminal's); 0
// never wraps, which keeps a plan piped to a file one field per line.
func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette, width int) {
r := dp.Result
// C1 (plan 2): scanned counts matched, unmatched and skipped alike, not
// just matched plus unmatched - spec §8.2's worked example is "266
// scanned" against "41 to act on" and "not acted on: 3 busy · 12
// ignored · 210 unmatched", and 41+3+12+210 = 266.
scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped)
// B2: warning lines come from both the match itself (fm.Warnings) and
// the chains plan.Build produced (Chain.Warnings, e.g. "moved more than
// once") - both computed once here so the count and the section below
// agree on the exact same list.
lines := collectWarnings(r, dp.Chains)
// D12: dp.Elapsed spans Match plus Build, unlike r.Elapsed, which stops
// before Build ever runs - the label says "planning", so the number
// must cover all of it.
counts := fmt.Sprintf("%d scanned · %d to act on · %d warnings · %.2fs", scanned, countActing(dp.Chains), warnedCount(lines), dp.Elapsed.Seconds())
for _, l := range wrapped("", counts, 2, width, plainText) {
fmt.Fprintln(w, l)
}
printBlocks(w, dp.Chains, dp.Dir.Root, p, width)
if len(lines) > 0 {
fmt.Fprintln(w)
fmt.Fprintln(w, p.warn("warnings"))
printWarnings(w, lines, p, width)
}
if line := skipSummaryLine(r, dp.Chains, verbose); line != "" {
fmt.Fprintln(w)
for _, l := range wrapped("", line, 2, width, plainText) {
fmt.Fprintln(w, l)
}
}
if verbose {
if len(r.Unmatched) > 0 {
fmt.Fprintln(w)
fmt.Fprintln(w, "not matched")
for _, fm := range r.Unmatched {
fmt.Fprintf(w, " %s\n", fm.File.Rel)
}
}
if len(r.Skipped) > 0 {
fmt.Fprintln(w)
fmt.Fprintln(w, "skipped")
printSkipped(w, r.Skipped)
}
}
}
// chainActing reports whether c has at least one step that will actually
// run - the single definition of "actionable" that countActing,
// actionableChains (sort.go) and chainOutcomes (sort.go) all share (fix
// wave item 4 / Minor 5). Before this fix, countActing and actionableChains
// each kept their own copy of this question and disagreed: countActing
// excluded an all-skipped chain (len(Steps) > 0, but every step's Skip is
// set) while actionableChains's own len(Steps) > 0 check included it, so a
// directory could print "N scanned · 0 to act on" and then still ask the
// user to approve a file it had just said there were none of - and on
// approval, log a run-start/run-end pair holding only "skipped" entries.
func chainActing(c plan.Chain) bool {
for _, s := range c.Steps {
if s.Skip == "" {
return true
}
}
return false
}
// countActing reports how many chains have at least one step that will
// actually run. C1/ruling 2026-09-12: a rule with no actions is an
// exclusion, and a chain every one of whose steps is skipped is not about
// to do anything either - neither must inflate "to act on".
func countActing(chains []plan.Chain) int {
n := 0
for _, c := range chains {
if chainActing(c) {
n++
}
}
return n
}
// printBlocks writes one block per chain that has steps (spec §8.2), a
// blank line before each: the file's number, right-aligned, and its name,
// then its steps (stepLines). A chain with no steps at all - an exclusion,
// or a rule that only stops - has nothing to show and gets no block and no
// number. The body is indented past the widest number, so labels line up
// across the whole plan.
func printBlocks(w io.Writer, chains []plan.Chain, root string, p palette, width int) {
n := 0
for _, c := range chains {
if len(c.Steps) > 0 {
n++
}
}
numW := len(strconv.Itoa(n))
indent := 2 + numW + 2
i := 0
for _, c := range chains {
if len(c.Steps) == 0 {
continue
}
i++
fmt.Fprintln(w)
head := " " + padLeft(strconv.Itoa(i), numW) + " "
for _, l := range wrapped(head, c.File.Rel, indent, width, plainText) {
fmt.Fprintln(w, l)
}
for _, l := range stepLines(c, indent, root, p, width) {
fmt.Fprintln(w, l)
}
}
}
// stepLines renders a chain's steps as a block body starting at column
// indent: each step on its own line under its kind word, and after each
// run of consecutive steps from one rule, that rule's name and - unless
// the rule has no condition - its reason. Per-file review prints the same
// lines under its own heading, so both views show the same information.
func stepLines(c plan.Chain, indent int, root string, p palette, width int) []string {
var out []string
for i, s := range c.Steps {
label, value := s.Kind.String(), stepValue(s, root)
styleLabel, styleValue := kindStyle(p, s.Kind), plainText
if s.Skip != "" {
styleLabel, styleValue = p.faint, p.faint
}
out = append(out, field(indent, label, value, width, styleLabel, styleValue)...)
if i+1 < len(c.Steps) && c.Steps[i+1].Rule == s.Rule {
continue
}
out = append(out, field(indent, "rule", s.Rule, width, plainText, p.rule)...)
if s.Reason != "" && s.Reason != "no condition" {
out = append(out, field(indent, "because", s.Reason, width, plainText, p.faint)...)
}
}
return out
}
// stepValue is what a step's line shows after its kind word: the reason a
// skipped step will not run; nothing for trash and DELETE permanently,
// which have no destination; otherwise the destination (destText), with a
// note when the step replaces an existing file.
func stepValue(s plan.Step, root string) string {
if s.Skip != "" {
return "skipped: " + s.Skip
}
switch s.Kind {
case plan.Trash, plan.DeletePermanent:
return ""
}
v := "→ " + destText(s, root)
if s.Displaces != "" {
v += " (replaces the existing file)"
}
return v
}
// kindStyle is the style of a step's kind word (spec §8.2): green for copy,
// move and rename, yellow for trash, bold red for DELETE permanently.
func kindStyle(p palette, k plan.Kind) func(string) string {
switch k {
case plan.Trash:
return p.warn
case plan.DeletePermanent:
return p.alarm
}
return p.good
}
// plainText is the identity style.
func plainText(s string) string { return s }
// field lays out one "label value" line at column indent, the label padded
// to labelWidth and the value wrapped (see wrapped) under its own first
// column. A label with no value is written alone. Widths are measured on
// the plain text; styleLabel and styleValue colour each piece afterwards,
// so escapes never shift a column.
func field(indent int, label, value string, width int, styleLabel, styleValue func(string) string) []string {
lead := strings.Repeat(" ", indent)
if value == "" {
return []string{lead + styleLabel(label)}
}
pad := labelWidth - utf8.RuneCountInString(label)
if pad < 0 {
pad = 0
}
head := lead + styleLabel(label) + strings.Repeat(" ", pad) + " "
valueCol := indent + utf8.RuneCountInString(label) + pad + 1
return wrapped(head, value, valueCol, width, styleValue)
}
// wrapped returns head followed by text, wrapped so no line is wider than
// width: the first piece of text follows head, and every further piece
// starts at column col. head is already styled; style colours each piece
// of text. width 0, or too little room to be worth wrapping into
// (minWrap), leaves text whole on one line.
func wrapped(head, text string, col, width int, style func(string) string) []string {
room := width - col
if width <= 0 || room < minWrap {
return []string{head + style(text)}
}
pieces := wrapText(text, room)
out := []string{head + style(pieces[0])}
pad := strings.Repeat(" ", col)
for _, piece := range pieces[1:] {
out = append(out, pad+style(piece))
}
return out
}
// wrapText splits s into pieces of at most max runes. Each break falls just
// after the last space, "/", "_" or "-" in the second half of the piece,
// or exactly at max when there is none, so a long word is cut rather than
// overflowing. Every rune of s is in exactly one piece, in order: joining
// the pieces gives s back.
func wrapText(s string, max int) []string {
r := []rune(s)
var out []string
for len(r) > max {
cut := max
for i := max; i > max/2; i-- {
if c := r[i-1]; c == ' ' || c == '/' || c == '_' || c == '-' {
cut = i
break
}
}
out = append(out, string(r[:cut]))
r = r[cut:]
}
return append(out, string(r))
}
// destText renders a copy/move/rename step's destination, per spec §8.2:
// for rename, just the new base name. For copy and move, a directory with
// a trailing "/" so it reads as one - root-relative when it lies inside
// the directory being planned (spec's own worked example: "Work/Acme/"),
// abbreviated against $HOME otherwise (the same example's
// "~/backup/invoices/2026/", outside the root entirely).
func destText(s plan.Step, root string) string {
if s.Kind == plan.Rename {
return filepath.Base(s.Dst)
}
dir := filepath.Dir(s.Dst)
if rel, ok := relToRoot(root, dir); ok {
if rel == "" {
// D10: rel is "" exactly when dir is root itself (relToRoot's
// own case below); rendering that as bare rel+"/" would print
// "/", which reads as the filesystem root rather than "this
// directory".
return "./"
}
return rel + "/"
}
return xdg.Abbrev(dir) + "/"
}
// relToRoot returns dir relative to root (slash-separated) when dir is
// root itself or lies inside it; ok is false when dir lies outside root,
// including when the two cannot be related at all (e.g. one relative, one
// absolute). C3: root itself counts as "inside" here (rel is "", ok true) -
// unlike internal/engine/match.go's excludeDirs, which asks a different
// question (what may a rule exclude from the walk) and treats root as
// outside it; do not "unify" the two.
func relToRoot(root, dir string) (rel string, ok bool) {
r, err := filepath.Rel(root, dir)
if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
return "", false
}
if r == "." {
return "", true // dir is root itself
}
return filepath.ToSlash(r), true
}
// padLeft pads s to width w (runes, not bytes) with leading spaces,
// right-aligning it; s already at or beyond w is left unpadded. Used for
// block and table row numbers - every other column reads left-aligned, per
// padCell.
func padLeft(s string, w int) string {
n := utf8.RuneCountInString(s)
if n >= w {
return s
}
return strings.Repeat(" ", w-n) + s
}
// colWidth returns the widest string in ss, in runes, capped at max when
// max is positive; 0 leaves it uncapped. Shares relWidth's rune-counting
// rule (C4): a name carrying diacritics must not misalign its column.
func colWidth(ss []string, max int) int {
w := 0
for _, s := range ss {
if n := utf8.RuneCountInString(s); n > w {
w = n
}
}
if max > 0 && w > max {
w = max
}
return w
}
|