aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/render.go
blob: 5b75df36f25301d49171f00f20f3bb6507492a6a (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
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
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
// SPDX-License-Identifier: GPL-3.0-or-later

package main

import (
	"fmt"
	"io"
	"path/filepath"
	"strconv"
	"strings"
	"unicode"

	"golang.org/x/text/width"

	"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 lines := excludedLines(r, dp.Chains); len(lines) > 0 {
			fmt.Fprintln(w)
			fmt.Fprintln(w, "excluded")
			for _, l := range lines {
				fmt.Fprintln(w, l)
			}
		}
		if len(r.Unmatched) > 0 {
			fmt.Fprintln(w)
			fmt.Fprintln(w, "not matched")
			for _, fm := range r.Unmatched {
				fmt.Fprintf(w, "  %s\n", display(fm.File.Rel))
			}
		}
		if len(r.Skipped) > 0 {
			fmt.Fprintln(w)
			fmt.Fprintln(w, "skipped")
			printSkipped(w, r.Skipped)
		}
		if len(r.Unscanned) > 0 {
			fmt.Fprintln(w)
			fmt.Fprintln(w, "not scanned (a rule's destination)")
			for _, dir := range r.Unscanned {
				rel, _ := relToRoot(dp.Dir.Root, dir)
				fmt.Fprintf(w, "  %s/\n", display(rel))
			}
		}
	}
}

// excludedLines lists, for -v, every file that was set aside: each chain
// with no steps, with what set it aside - the (exclude ...) form, or the
// action-less rule it matched. Names are padded to the widest shown
// (capped at 40), as in the warnings section.
func excludedLines(r *engine.Result, chains []plan.Chain) []string {
	byRel := make(map[string]engine.FileMatch, len(r.Matched))
	for _, fm := range r.Matched {
		byRel[fm.File.Rel] = fm
	}
	var rels, why []string
	for _, c := range chains {
		if len(c.Steps) > 0 {
			continue
		}
		fm := byRel[c.File.Rel]
		reason := fm.Excluded
		if reason == "" && len(fm.Rules) > 0 {
			reason = "rule " + fm.Rules[len(fm.Rules)-1].Rule.Name
		}
		rels = append(rels, display(c.File.Rel))
		why = append(why, display(reason))
	}
	width := relWidth(rels)
	out := make([]string, len(rels))
	for i := range rels {
		out[i] = "  " + padCell(rels[i], width) + "  " + why[i]
	}
	return out
}

// 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) + "  "
		// A long name continues at the value column, never at the label
		// column, so its text cannot pass for a step line (triage 28l).
		for _, l := range wrapped(head, display(c.File.Rel), indent+labelWidth+1, 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", display(s.Rule), width, plainText, p.rule)...)
		if s.Reason != "" && s.Reason != "no condition" {
			out = append(out, field(indent, "because", display(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: " + display(s.Skip)
	}
	switch s.Kind {
	case plan.Trash, plan.DeletePermanent:
		return ""
	}
	v := "→ " + display(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 - cols(label)
	if pad < 0 {
		pad = 0
	}
	head := lead + styleLabel(label) + strings.Repeat(" ", pad) + " "
	valueCol := indent + cols(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 terminal columns (cols).
// Each break falls just after the last space, "/", "_" or "-" in the second
// half of the piece, or at the last rune that fits 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 cols(string(r)) > max {
		fit, used := 0, 0 // runes that fit in max columns
		for fit < len(r) && used+runeCols(r[fit]) <= max {
			used += runeCols(r[fit])
			fit++
		}
		if fit == 0 {
			fit = 1 // a rune wider than max still goes somewhere
		}
		cut, at := fit, used
		for i := fit; i > 0 && at > max/2; i-- {
			if c := r[i-1]; c == ' ' || c == '/' || c == '_' || c == '-' {
				cut = i
				break
			}
			at -= runeCols(r[i-1])
		}
		out = append(out, string(r[:cut]))
		r = r[cut:]
	}
	return append(out, string(r))
}

// cols is how many terminal columns s takes: two for a wide or full-width
// character (CJK), none for a combining mark, one for the rest - format
// characters included: a terminal may draw one (a soft hyphen), and
// counting a column too many only wraps early, while one too few runs past
// the edge (triage 28j, plan 11 review L4). A terminal can still draw some
// characters wider (emoji, ambiguous-width letters); see KNOWN LIMITATIONS.
func cols(s string) int {
	n := 0
	for _, r := range s {
		n += runeCols(r)
	}
	return n
}

func runeCols(r rune) int {
	if unicode.In(r, unicode.Mn, unicode.Me) {
		return 0
	}
	switch width.LookupRune(r).Kind() {
	case width.EastAsianWide, width.EastAsianFullwidth:
		return 2
	}
	return 1
}

// 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 := cols(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 := cols(s); n > w {
			w = n
		}
	}
	if max > 0 && w > max {
		w = max
	}
	return w
}