diff options
Diffstat (limited to 'cmd/krino/render.go')
| -rw-r--r-- | cmd/krino/render.go | 258 |
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 +} |
