// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "fmt" "io" "path/filepath" "strconv" "strings" "unicode" "golang.org/x/text/width" "git.labunix.xyz/krino/internal/engine" "git.labunix.xyz/krino/internal/plan" "git.labunix.xyz/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 // 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) // 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) // 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. // countActing and actionableChains used to each keep their own copy of // this question and disagree: 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. 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. 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. 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 == "" { // 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). 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: 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 }