summaryrefslogtreecommitdiff
path: root/cmd/krino/render.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/krino/render.go')
-rw-r--r--cmd/krino/render.go258
1 files changed, 258 insertions, 0 deletions
diff --git a/cmd/krino/render.go b/cmd/krino/render.go
new file mode 100644
index 0000000..b27d4d1
--- /dev/null
+++ b/cmd/krino/render.go
@@ -0,0 +1,258 @@
+// 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"
+)
+
+// actionKindWidth is the field an action cell's kind word is left-padded
+// to before its arrow: the widest of the three kinds that carry a
+// destination ("copy", "move", "rename"). Trash and DELETE permanently
+// have no destination, hence no arrow to align, so they are not padded.
+const actionKindWidth = len("rename")
+
+// 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, the numbered action table, the warnings section and the "not
+// acted on" line, each present only when it has something to show. No
+// colour, pager or prompt: those arrive with the review UI in plan 4,
+// which wraps this same function, hence its plain (io.Writer, *DirPlan,
+// bool) signature.
+func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool) {
+ 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.
+ fmt.Fprintf(w, "%d scanned · %d to act on · %d warnings · %.2fs\n", scanned, countActing(dp.Chains), warnedCount(lines), dp.Elapsed.Seconds())
+
+ if rows := planRows(dp.Chains, dp.Dir.Root); len(rows) > 0 {
+ fmt.Fprintln(w)
+ printPlanTable(w, rows)
+ }
+
+ if len(lines) > 0 {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "warnings")
+ printWarnings(w, lines)
+ }
+
+ if line := skipSummaryLine(r, dp.Chains, verbose); line != "" {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, line)
+ }
+
+ 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)
+ }
+ }
+}
+
+// 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 {
+ for _, s := range c.Steps {
+ if s.Skip == "" {
+ n++
+ break
+ }
+ }
+ }
+ return n
+}
+
+// planRow is one line of the action table: a file's first step (num and
+// file set) or a continuation line (both blank). chainIdx (D15) is the
+// index into the DirPlan's own Chains slice this row was built from, kept
+// alongside the display strings so plan 4's per-file approval ("row 2 is
+// chain index 1", spec §8.3) does not have to re-derive the mapping later;
+// it changes no rendering here and is not itself asserted by a test.
+type planRow struct {
+ num, file, actions, rule, reason string
+ chainIdx int
+}
+
+// planRows turns the chains that have at least one step into table rows,
+// per spec §8.2: a file's first step shares its numbered row; later steps
+// sit on continuation lines with the number and file columns blank. A
+// chain with no steps at all - an exclusion, or a rule that only stops -
+// has nothing to show and contributes no row and no number. root is the
+// directory being planned, threaded down to destText so a destination
+// inside it renders root-relative rather than home-abbreviated.
+func planRows(chains []plan.Chain, root string) []planRow {
+ var rows []planRow
+ n := 0
+ for ci, c := range chains {
+ if len(c.Steps) == 0 {
+ continue
+ }
+ n++
+ for i, s := range c.Steps {
+ row := planRow{actions: actionCell(s, root), rule: s.Rule, reason: s.Reason, chainIdx: ci}
+ if i == 0 {
+ row.num = strconv.Itoa(n)
+ row.file = c.File.Rel
+ }
+ rows = append(rows, row)
+ }
+ }
+ return rows
+}
+
+// actionCell renders one step's action column. A step with Skip set shows
+// its reason in place of the destination. Trash and DELETE permanently
+// have no destination to show - even when the step came from a duplicate
+// rule, the reason column already says what it is a duplicate of, so the
+// action column is just the kind word. Everything else (copy, move,
+// rename) shows the kind, padded so every arrow in the table lines up,
+// then its destination; Displaces adds a trailing note.
+func actionCell(s plan.Step, root string) string {
+ kind := s.Kind.String()
+ if s.Skip != "" {
+ return padCell(kind, actionKindWidth) + " skipped: " + s.Skip
+ }
+ switch s.Kind {
+ case plan.Trash, plan.DeletePermanent:
+ return kind
+ }
+ cell := padCell(kind, actionKindWidth) + " → " + destText(s, root)
+ if s.Displaces != "" {
+ cell += " (replaces the existing file)"
+ }
+ return cell
+}
+
+// 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
+}
+
+// printPlanTable prints rows in the table layout of spec §8.2: #, file,
+// actions and rule are padded to what is actually shown in this section,
+// in runes, exactly as relWidth/padCell already do elsewhere; the file
+// column is capped at 40 and the actions column at 46. The reason is the
+// last column and is never padded. The row number is right-aligned
+// (padLeft, not padCell) so the "#" column stays flush as it widens past
+// a single digit - the layout plan 4's review UI inherits unchanged.
+func printPlanTable(w io.Writer, rows []planRow) {
+ nums := make([]string, len(rows))
+ files := make([]string, len(rows))
+ actions := make([]string, len(rows))
+ rules := make([]string, len(rows))
+ for i, r := range rows {
+ nums[i], files[i], actions[i], rules[i] = r.num, r.file, r.actions, r.rule
+ }
+ // D13: numW and ruleW are left uncapped, unlike fileW and actionsW. The
+ // row number is at most a few digits regardless of how large a
+ // directory is, and a rule name is a config author's own identifier,
+ // not scan noise from a user's file names - truncating a name someone
+ // deliberately chose would only make the row harder to trace back to
+ // its rule, so there is nothing here worth capping.
+ numW := colWidth(nums, 0)
+ fileW := relWidth(files)
+ actionsW := colWidth(actions, 46)
+ ruleW := colWidth(rules, 0)
+
+ fmt.Fprintf(w, " %s %s %s rule\n", padLeft("#", numW), padCell("file", fileW), padCell("actions", actionsW))
+ for _, r := range rows {
+ fmt.Fprintf(w, " %s %s %s %s %s\n", padLeft(r.num, numW), padCell(r.file, fileW), padCell(r.actions, actionsW), padCell(r.rule, ruleW), r.reason)
+ }
+}
+
+// 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 only
+// for the row-number column - 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
+}