// 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) } } } // 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 } // 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 }