aboutsummaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/krino/colour.go53
-rw-r--r--cmd/krino/colour_test.go88
-rw-r--r--cmd/krino/main.go12
-rw-r--r--cmd/krino/matching_test.go20
-rw-r--r--cmd/krino/render.go258
-rw-r--r--cmd/krino/render_test.go144
-rw-r--r--cmd/krino/review.go39
-rw-r--r--cmd/krino/review_test.go46
-rw-r--r--cmd/krino/sort.go16
-rw-r--r--cmd/krino/undo.go12
10 files changed, 461 insertions, 227 deletions
diff --git a/cmd/krino/colour.go b/cmd/krino/colour.go
index ee9055c..1b58171 100644
--- a/cmd/krino/colour.go
+++ b/cmd/krino/colour.go
@@ -6,9 +6,7 @@ import (
"fmt"
"io"
"strings"
- "unicode/utf8"
- "krino/internal/plan"
"krino/internal/tui"
)
@@ -52,39 +50,6 @@ func (p palette) keys(menu string) string {
return b.String()
}
-// padStyled styles s and pads it to w runes measured on s itself, so the
-// escape bytes never count toward the column's width.
-func padStyled(s string, w int, style func(string) string) string {
- pad := w - utf8.RuneCountInString(s)
- if pad < 0 {
- pad = 0
- }
- return style(s) + strings.Repeat(" ", pad)
-}
-
-// styleAction styles an action cell actionCell rendered for s: a skipped
-// step faint throughout; otherwise only its leading kind word, green for
-// copy, move and rename, yellow for trash, bold red for DELETE permanently.
-func styleAction(p palette, s plan.Step, cell string) string {
- if s.Skip != "" {
- return p.faint(cell)
- }
- word := s.Kind.String()
- if !strings.HasPrefix(cell, word) {
- return cell
- }
- var styled string
- switch s.Kind {
- case plan.Trash:
- styled = p.warn(word)
- case plan.DeletePermanent:
- styled = p.alarm(word)
- default:
- styled = p.good(word)
- }
- return styled + cell[len(word):]
-}
-
// outcome renders the "N applied · N failed · N declined" line: the applied
// count green and the failed count red, each only when above 0.
func outcome(p palette, applied, failed, declined int) string {
@@ -102,6 +67,24 @@ func outcome(p palette, applied, failed, declined int) string {
// it, since no test runs on a terminal.
var colourPolicy = tui.Colour
+// widthPolicy is tui.Width, the terminal's columns (0: never wrap); pager
+// is tui.Page. Tests replace both, for the same reason.
+var (
+ widthPolicy = tui.Width
+ pager = tui.Page
+)
+
+// show writes text to w: straight out with -P (--no-pager), otherwise
+// through the pager, which only engages when text is taller than the
+// terminal.
+func show(g *globals, w io.Writer, text string) error {
+ if g.noPager {
+ _, err := io.WriteString(w, text)
+ return err
+ }
+ return pager(w, text)
+}
+
// colourOn reports whether output to w is coloured: never with --no-color,
// otherwise as colourPolicy decides.
func colourOn(g *globals, w io.Writer) bool {
diff --git a/cmd/krino/colour_test.go b/cmd/krino/colour_test.go
index fa014e6..64dbb24 100644
--- a/cmd/krino/colour_test.go
+++ b/cmd/krino/colour_test.go
@@ -7,7 +7,9 @@ import (
"strings"
"testing"
+ "krino/internal/engine"
"krino/internal/plan"
+ "krino/internal/scan"
)
func TestPaletteZeroValueIsPlain(t *testing.T) {
@@ -41,16 +43,6 @@ func TestPaletteStylesWithAnsiSlotsOnly(t *testing.T) {
}
}
-func TestPadStyledPadsOnPlainWidth(t *testing.T) {
- p := palette{on: true}
- if got, want := padStyled("ab", 5, p.rule), "\x1b[34mab\x1b[0m "; got != want {
- t.Errorf("got %q, want %q", got, want)
- }
- if got := padStyled("abcdef", 3, p.rule); got != "\x1b[34mabcdef\x1b[0m" {
- t.Errorf("over-wide cell = %q", got)
- }
-}
-
func TestColourOnHonoursNoColorFlag(t *testing.T) {
old := colourPolicy
t.Cleanup(func() { colourPolicy = old })
@@ -80,34 +72,38 @@ func TestNoColorFlagBeforeAndAfterSubcommand(t *testing.T) {
}
}
-// TestPlanTableColouredAndAligned: with colour on, each element of the
-// action table carries its style, and stripping the escapes gives exactly
-// the plain rendering, so the columns line up.
-func TestPlanTableColouredAndAligned(t *testing.T) {
- root := "/r"
- rows := []planRow{
- {num: "1", file: "a.pdf", step: plan.Step{Kind: plan.Move, Rule: "acme", Dst: "/r/Work/a.pdf", Reason: "type pdf"}},
- {step: plan.Step{Kind: plan.Trash, Rule: "old", Skip: "a duplicate is never deleted", Reason: "matched"}},
- {num: "2", file: "setup.deb", step: plan.Step{Kind: plan.DeletePermanent, Rule: "pkgs", Reason: "age > 90d"}},
- }
- for i := range rows {
- rows[i].actions = actionCell(rows[i].step, root)
- rows[i].rule = rows[i].step.Rule
- rows[i].reason = rows[i].step.Reason
+// TestPlanBlocksColouredAndAligned: with colour on, each element of a
+// block carries its style, a wrapped value keeps its style on every piece,
+// and stripping the escapes gives exactly the plain rendering, so the
+// labels and the wrapping line up with colour on.
+func TestPlanBlocksColouredAndAligned(t *testing.T) {
+ home(t)
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: "/r"},
+ Chains: []plan.Chain{
+ {File: scan.File{Rel: "a.pdf"}, Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "acme", Src: "/r/a.pdf", Dst: "/r/Work/a.pdf", Reason: "type pdf"},
+ {Kind: plan.Trash, Rule: "old", Src: "/r/Work/a.pdf", Skip: "a duplicate is never deleted", Reason: "matched"},
+ }},
+ {File: scan.File{Rel: "setup.deb"}, Steps: []plan.Step{
+ {Kind: plan.DeletePermanent, Rule: "pkgs", Src: "/r/setup.deb", Reason: "age > 90d"},
+ }},
+ },
+ Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: "a.pdf"}}, {File: scan.File{Rel: "setup.deb"}}}},
}
var plain, coloured strings.Builder
- printPlanTable(&plain, rows, palette{})
- printPlanTable(&coloured, rows, palette{on: true})
+ printPlan(&plain, dp, false, palette{}, 30)
+ printPlan(&coloured, dp, false, palette{on: true}, 30)
for _, want := range []string{
"\x1b[32mmove\x1b[0m", "\x1b[34macme\x1b[0m", "\x1b[2mtype pdf\x1b[0m",
- "\x1b[2mtrash ", "\x1b[1;31mDELETE permanently\x1b[0m",
+ "\x1b[2mtrash\x1b[0m", "\x1b[2mskipped: ", "\x1b[1;31mDELETE permanently\x1b[0m",
} {
if !strings.Contains(coloured.String(), want) {
- t.Errorf("coloured table lacks %q:\n%q", want, coloured.String())
+ t.Errorf("coloured plan lacks %q:\n%q", want, coloured.String())
}
}
if got := stripSGR(coloured.String()); got != plain.String() {
- t.Errorf("stripped coloured table differs from plain:\n%s\nvs\n%s", got, plain.String())
+ t.Errorf("stripped coloured plan differs from plain:\n%s\nvs\n%s", got, plain.String())
}
}
@@ -162,3 +158,37 @@ func TestDryRunColouredAndNoColor(t *testing.T) {
}
}
}
+
+// TestNoPagerFlagBypassesThePager: with -P or --no-pager, in either
+// position, the plan is written straight out and never handed to the
+// pager; without it, the pager gets it.
+func TestNoPagerFlagBypassesThePager(t *testing.T) {
+ matchingFixture(t)
+ old := pager
+ t.Cleanup(func() { pager = old })
+ paged := 0
+ pager = func(w io.Writer, text string) error {
+ paged++
+ _, err := io.WriteString(w, text)
+ return err
+ }
+ if _, out, _ := runCLI(t, "-n"); paged != 1 || !strings.Contains(out, "report (1).pdf") {
+ t.Errorf("without -P: paged %d times, want 1", paged)
+ }
+ for _, args := range [][]string{{"-P", "-n"}, {"--no-pager", "-n"}, {"-n", "-P"}} {
+ paged = 0
+ _, out, errOut := runCLI(t, args...)
+ if paged != 0 {
+ t.Errorf("%v went through the pager", args)
+ }
+ if !strings.Contains(out, "report (1).pdf") {
+ t.Errorf("%v printed no plan:\n%s\n%s", args, out, errOut)
+ }
+ }
+ if code, _, errOut := runCLI(t, "log", "-P"); code != 0 {
+ t.Errorf("log -P: exit %d: %s", code, errOut)
+ }
+ if !strings.Contains(usage, "--no-pager") {
+ t.Error("usage does not mention --no-pager")
+ }
+}
diff --git a/cmd/krino/main.go b/cmd/krino/main.go
index 12fdf5f..6d1ef96 100644
--- a/cmd/krino/main.go
+++ b/cmd/krino/main.go
@@ -51,6 +51,7 @@ Sort the files in the directories listed in krino.conf by their rules.
--json with -n: print the plan as JSON
-c FILE use FILE instead of ~/.config/krino/krino.conf
--no-color never colour the output, as when NO_COLOR is set
+ -P, --no-pager print the plan straight out, never through the pager
-h, --help show this help
--version print the version
`
@@ -58,7 +59,7 @@ Sort the files in the directories listed in krino.conf by their rules.
// globals holds the flags that may appear before or after a subcommand.
type globals struct {
yes, dry, verbose, json bool
- noColor bool
+ noColor, noPager bool
conf string
}
@@ -110,14 +111,17 @@ func run(args []string, stdout, stderr io.Writer) int {
return cmdSort(g, rest, stdout, stderr)
}
-// flagSet returns a silent flag set with -c and --no-color bound to g,
-// shared by every command. Each defaults to the value already parsed, so a
-// flag given before a subcommand survives the subcommand's own flag set.
+// flagSet returns a silent flag set with -c, --no-color and -P/--no-pager
+// bound to g, shared by every command. Each defaults to the value already
+// parsed, so a flag given before a subcommand survives the subcommand's own
+// flag set.
func flagSet(name string, g *globals) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&g.conf, "c", g.conf, "")
fs.BoolVar(&g.noColor, "no-color", g.noColor, "")
+ fs.BoolVar(&g.noPager, "no-pager", g.noPager, "")
+ fs.BoolVar(&g.noPager, "P", g.noPager, "")
return fs
}
diff --git a/cmd/krino/matching_test.go b/cmd/krino/matching_test.go
index 25a2993..6986e99 100644
--- a/cmd/krino/matching_test.go
+++ b/cmd/krino/matching_test.go
@@ -64,9 +64,9 @@ func TestDryRun(t *testing.T) {
}
for _, want := range []string{
"krino: dl ~/dl\n8 scanned · 4 to act on · 2 warnings · ",
- "\n 1 inv1.txt move → Work/Acme/ acme type txt, content \"acme ltd\"\n",
- "\n 2 notes.txt move → Other/ rest not matched, type txt\n",
- "\n 4 report (1).pdf move → Dupes/ dups duplicate of report.pdf\n",
+ "\n 1 inv1.txt\n move → Work/Acme/\n rule acme\n because type txt, content \"acme ltd\"\n",
+ "\n 2 notes.txt\n move → Other/\n rule rest\n because not matched, type txt\n",
+ "\n 4 report (1).pdf\n move → Dupes/\n rule dups\n because duplicate of report.pdf\n",
"\nwarnings\n brochure.doc acme: content unreadable: needs antiword or catdoc, not installed\n",
"\nnot acted on: 1 ignored · 1 busy · 2 unmatched (-v lists them)\n",
} {
@@ -451,7 +451,10 @@ func TestNoColourEscapeToNonTerminal(t *testing.T) {
}
}
-func TestLongNameNotPaddedLayoutIntact(t *testing.T) {
+// TestLongNameGetsItsOwnLine: a long file name sits on its own numbered
+// line, and the block below it is laid out exactly as a short name's is.
+// Output to a non-terminal is never wrapped.
+func TestLongNameGetsItsOwnLine(t *testing.T) {
h := home(t)
dl := filepath.Join(h, "dl")
long := strings.Repeat("a", 42) + ".txt" // 46 runes: past the 40-column cap
@@ -488,10 +491,11 @@ func TestLongNameNotPaddedLayoutIntact(t *testing.T) {
if code != 0 {
t.Fatalf("exit %d: %s", code, errOut)
}
- if want := "\n 1 " + long + " move → Other/ r type txt\n"; !strings.Contains(out, want) {
- t.Errorf("long name should be unpadded (exactly two trailing spaces before the actions column):\n%s\nwant substring:\n%s", out, want)
+ body := " move → Other/\n rule r\n because type txt\n"
+ if want := "\n 1 " + long + "\n" + body; !strings.Contains(out, want) {
+ t.Errorf("long name block:\n%s\nwant substring:\n%s", out, want)
}
- if want := "\n 2 " + short + strings.Repeat(" ", 40-len(short)) + " move → Other/ r type txt\n"; !strings.Contains(out, want) {
- t.Errorf("short name should still be padded to the 40-column cap:\n%s\nwant substring:\n%s", out, want)
+ if want := "\n 2 " + short + "\n" + body; !strings.Contains(out, want) {
+ t.Errorf("short name block:\n%s\nwant substring:\n%s", out, want)
}
}
diff --git a/cmd/krino/render.go b/cmd/krino/render.go
index d8e6e47..8031767 100644
--- a/cmd/krino/render.go
+++ b/cmd/krino/render.go
@@ -15,20 +15,23 @@ import (
"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")
+// 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, the numbered action table, the warnings section and the "not
-// acted on" line, each present only when it has something to show. No
-// pager or prompt: those arrive with the review UI in plan 4, which wraps
-// this same function. p styles the table and the warnings (spec §8.2); the
-// zero palette prints plain text.
-func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette) {
+// 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
@@ -43,22 +46,24 @@ func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette) {
// 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, p)
+ 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)
+ printWarnings(w, lines, p, width)
}
if line := skipSummaryLine(r, dp.Chains, verbose); line != "" {
fmt.Fprintln(w)
- fmt.Fprintln(w, line)
+ for _, l := range wrapped("", line, 2, width, plainText) {
+ fmt.Fprintln(w, l)
+ }
}
if verbose {
@@ -110,66 +115,155 @@ func countActing(chains []plan.Chain) int {
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
- step plan.Step // the step this row shows, for styling its cells
-}
-
-// 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
+// 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 ci, c := range chains {
+ 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
}
- n++
- for i, s := range c.Steps {
- row := planRow{actions: actionCell(s, root), rule: s.Rule, reason: s.Reason, chainIdx: ci, step: s}
- if i == 0 {
- row.num = strconv.Itoa(n)
- row.file = c.File.Rel
- }
- rows = append(rows, row)
+ i++
+ fmt.Fprintln(w)
+ head := " " + padLeft(strconv.Itoa(i), numW) + " "
+ for _, l := range wrapped(head, c.File.Rel, indent, 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", s.Rule, width, plainText, p.rule)...)
+ if s.Reason != "" && s.Reason != "no condition" {
+ out = append(out, field(indent, "because", s.Reason, width, plainText, p.faint)...)
}
}
- return rows
+ return out
}
-// 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()
+// 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 padCell(kind, actionKindWidth) + " skipped: " + s.Skip
+ return "skipped: " + s.Skip
}
switch s.Kind {
case plan.Trash, plan.DeletePermanent:
- return kind
+ return ""
}
- cell := padCell(kind, actionKindWidth) + " → " + destText(s, root)
+ v := "→ " + destText(s, root)
if s.Displaces != "" {
- cell += " (replaces the existing file)"
+ v += " (replaces the existing file)"
}
- return cell
+ 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 - utf8.RuneCountInString(label)
+ if pad < 0 {
+ pad = 0
+ }
+ head := lead + styleLabel(label) + strings.Repeat(" ", pad) + " "
+ valueCol := indent + utf8.RuneCountInString(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 runes. Each break falls just
+// after the last space, "/", "_" or "-" in the second half of the piece,
+// or exactly at max 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 len(r) > max {
+ cut := max
+ for i := max; i > max/2; i-- {
+ if c := r[i-1]; c == ' ' || c == '/' || c == '_' || c == '-' {
+ cut = i
+ break
+ }
+ }
+ out = append(out, string(r[:cut]))
+ r = r[cut:]
+ }
+ return append(out, string(r))
}
// destText renders a copy/move/rename step's destination, per spec §8.2:
@@ -214,43 +308,9 @@ func relToRoot(root, dir string) (rel string, ok bool) {
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. p
-// styles the action, rule and reason cells; widths are measured on the
-// plain text, so the columns line up with colour on.
-func printPlanTable(w io.Writer, rows []planRow, p palette) {
- 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), styleAction(p, r.step, padCell(r.actions, actionsW)), padStyled(r.rule, ruleW, p.rule), p.faint(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
+// 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 := utf8.RuneCountInString(s)
diff --git a/cmd/krino/render_test.go b/cmd/krino/render_test.go
index 6f29b51..9df75f4 100644
--- a/cmd/krino/render_test.go
+++ b/cmd/krino/render_test.go
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
"time"
+ "unicode/utf8"
"krino/internal/engine"
"krino/internal/plan"
@@ -17,9 +18,10 @@ import (
// TestPrintPlan is the golden render test of spec §8.2. The DirPlan is
// built by hand, not by running a scan, so the expected output cannot
-// drift with a fixture: it asserts the header row, a numbered row, a
-// continuation line, the "DELETE permanently" capitalisation and the
-// counts line.
+// drift with a fixture: it asserts one block per file (the numbered name,
+// each step, and each rule's name and reason after its steps), a file
+// whose steps come from two rules, the "DELETE permanently"
+// capitalisation and the counts line. Width 0 never wraps.
//
// The two destinations pin both branches of destText: scan001.pdf's and
// fv_123.pdf's "acme" step lands inside root and renders root-relative
@@ -90,16 +92,26 @@ func TestPrintPlan(t *testing.T) {
}
var buf bytes.Buffer
- printPlan(&buf, dp, false, palette{})
+ printPlan(&buf, dp, false, palette{}, 0)
out := buf.String()
for _, want := range []string{
"6 scanned · 3 to act on · 1 warnings · 0.42s\n",
- " # file actions rule\n",
- " 1 scan001.pdf move → Work/Acme/2026/ acme content \"acme ltd\"\n",
- " 2 fv_123.pdf copy → ~/backup/invoices/2026/ backup content \"invoice\"\n",
- " move → Work/Acme/2026/ acme name \\bacme\\b\n",
- " 3 setup-1.2.deb DELETE permanently old-pkgs age 94d\n",
+ "\n 1 scan001.pdf\n" +
+ " move → Work/Acme/2026/\n" +
+ " rule acme\n" +
+ " because content \"acme ltd\"\n",
+ "\n 2 fv_123.pdf\n" +
+ " copy → ~/backup/invoices/2026/\n" +
+ " rule backup\n" +
+ " because content \"invoice\"\n" +
+ " move → Work/Acme/2026/\n" +
+ " rule acme\n" +
+ " because name \\bacme\\b\n",
+ "\n 3 setup-1.2.deb\n" +
+ " DELETE permanently\n" +
+ " rule old-pkgs\n" +
+ " because age 94d\n",
"warnings\n scan001.pdf acme: content unreadable: needs pdftotext, not installed\n",
"not acted on: 1 busy · 1 excluded · 1 unmatched (-v lists them)\n",
} {
@@ -108,7 +120,91 @@ func TestPrintPlan(t *testing.T) {
}
}
if strings.Contains(out, "excluded.txt") {
- t.Errorf("excluded.txt has no steps and must not appear in the table:\n%s", out)
+ t.Errorf("excluded.txt has no steps and must not appear in the plan:\n%s", out)
+ }
+ if strings.Contains(out, "# file") {
+ t.Errorf("the plan is blocks now, with no table header:\n%s", out)
+ }
+}
+
+// TestPrintPlanOmitsBecauseForUnconditionalRule: a rule with no condition
+// has nothing to say under "because", so the line is left out.
+func TestPrintPlanOmitsBecauseForUnconditionalRule(t *testing.T) {
+ home(t)
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: "/r"},
+ Chains: []plan.Chain{{File: scan.File{Rel: "a.iso"}, Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "to-sort", Src: "/r/a.iso", Dst: "/r/TO_SORT/a.iso", Reason: "no condition"},
+ }}},
+ Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: "a.iso"}}}},
+ }
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false, palette{}, 0)
+ want := "\n 1 a.iso\n move → TO_SORT/\n rule to-sort\n"
+ if out := buf.String(); !strings.Contains(out, want) || strings.Contains(out, "because") {
+ t.Errorf("output lacks %q or still says because:\n%s", want, out)
+ }
+}
+
+// TestPrintPlanWrapsToWidth: given a width, every line fits it, and a long
+// name or reason continues on lines indented under its own first column,
+// so reading the pieces back in order gives the whole text.
+func TestPrintPlanWrapsToWidth(t *testing.T) {
+ home(t)
+ name := "A Rather Long Book Title -- First Author & Second Author -- 2005.pdf"
+ reason := `content "acme ltd" "long street 12" "0000000000" "000000000"`
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: "/r"},
+ Chains: []plan.Chain{{File: scan.File{Rel: name}, Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "work-content", Src: "/r/" + name, Dst: "/r/work/Acme_main/accounting_acme/2026_08_acme/" + name, Reason: reason},
+ }}},
+ Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: name}}}},
+ }
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false, palette{}, 40)
+ out := buf.String()
+ lines := strings.Split(out, "\n")
+ for _, l := range lines {
+ if n := utf8.RuneCountInString(l); n > 40 {
+ t.Errorf("line of %d runes exceeds width 40: %q", n, l)
+ }
+ }
+
+ start := -1
+ for i, l := range lines {
+ if strings.HasPrefix(l, " 1 ") {
+ start = i
+ break
+ }
+ }
+ if start < 0 {
+ t.Fatalf("no numbered line:\n%s", out)
+ }
+ gotName := strings.TrimPrefix(lines[start], " 1 ")
+ i := start + 1
+ for ; i < len(lines) && strings.HasPrefix(lines[i], " ") && !strings.HasPrefix(lines[i], " move"); i++ {
+ gotName += strings.TrimPrefix(lines[i], " ")
+ }
+ if gotName != name {
+ t.Errorf("wrapped name reads %q, want %q\n%s", gotName, name, out)
+ }
+
+ // The reason's value starts at column 5 + len("because") + 1 = 13, and so
+ // does every continuation line of it.
+ const valueCol = 13
+ gotReason, inReason := "", false
+ for _, l := range lines[i:] {
+ switch {
+ case strings.HasPrefix(l, " because "):
+ gotReason, inReason = strings.TrimPrefix(l, " because "), true
+ case inReason && len(l) > valueCol && strings.TrimLeft(l[:valueCol], " ") == "" && l[valueCol] != ' ':
+ gotReason += l[valueCol:]
+ default:
+ inReason = false
+ }
+ }
+ if gotReason != reason {
+ t.Errorf("wrapped reason reads %q, want %q\n%s", gotReason, reason, out)
}
}
@@ -138,7 +234,7 @@ func TestPrintPlanSkippedStep(t *testing.T) {
},
}
var buf bytes.Buffer
- printPlan(&buf, dp, false, palette{})
+ printPlan(&buf, dp, false, palette{}, 0)
out := buf.String()
if !strings.Contains(out, "copy skipped: target exists") {
t.Errorf("skipped step should show its reason in place of the destination:\n%s", out)
@@ -151,13 +247,12 @@ func TestPrintPlanSkippedStep(t *testing.T) {
}
}
-// TestPrintPlanRowNumberAlignment pins the table layout plan 4's review UI
-// inherits: with 11 acted-on files the row-number column has to widen past
-// a single digit, and the row number is right-aligned so "#" stays flush.
-// The fixture also carries a file name past the 40-character cap and a
-// rule name noticeably longer than the rest, exercising the file and rule
-// columns' own per-section widths at the same time.
-func TestPrintPlanRowNumberAlignment(t *testing.T) {
+// TestPrintPlanBlockNumberAlignment: with 11 acted-on files the number
+// widens past a single digit, numbers are right-aligned, and every block's
+// body is indented one step further so the labels line up across the whole
+// plan. A long file name and a long rule name no longer push anything out
+// of line: each sits on its own line.
+func TestPrintPlanBlockNumberAlignment(t *testing.T) {
h := home(t)
root := filepath.Join(h, "dl")
long := strings.Repeat("z", 42) + ".txt" // 46 runes: past the 40-column cap
@@ -191,15 +286,14 @@ func TestPrintPlanRowNumberAlignment(t *testing.T) {
}
var buf bytes.Buffer
- printPlan(&buf, dp, false, palette{})
+ printPlan(&buf, dp, false, palette{}, 0)
out := buf.String()
for _, want := range []string{
- " # file actions rule\n",
- " 1 f01.txt move → Out/ r type txt\n",
- " 6 f06.txt move → Out/ a-noticeably-longer-rule-name type txt\n",
- " 10 f10.txt move → Out/ r type txt\n",
- " 11 " + long + " move → Out/ r type txt\n",
+ "\n 1 f01.txt\n move → Out/\n rule r\n because type txt\n",
+ "\n 6 f06.txt\n move → Out/\n rule a-noticeably-longer-rule-name\n because type txt\n",
+ "\n 10 f10.txt\n move → Out/\n",
+ "\n 11 " + long + "\n move → Out/\n rule r\n",
} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
@@ -235,7 +329,7 @@ func TestPrintPlanCountsFileOnceWithBothWarningKinds(t *testing.T) {
},
}
var buf bytes.Buffer
- printPlan(&buf, dp, false, palette{})
+ printPlan(&buf, dp, false, palette{}, 0)
out := buf.String()
for _, want := range []string{
diff --git a/cmd/krino/review.go b/cmd/krino/review.go
index a6c1b5a..66e17b9 100644
--- a/cmd/krino/review.go
+++ b/cmd/krino/review.go
@@ -58,7 +58,10 @@ func (k keyReader) Read(p []byte) (int, error) {
// reviewPerFile's destination rendering (review finding 1, fix round
// 2026-09-12); nothing here uses it directly.
func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, rune, error) {
- fmt.Fprint(out, "\n"+p.keys("[a] apply all [c] choose per file [s] skip this directory [q] quit")+"\n")
+ fmt.Fprintln(out)
+ for _, l := range wrapped("", "[a] apply all [c] choose per file [s] skip this directory [q] quit", 0, widthPolicy(out), p.keys) {
+ fmt.Fprintln(out, l)
+ }
for {
key, err := readKey(in)
if err != nil {
@@ -87,18 +90,16 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string,
}
// reviewPerFile is spec §8.3: one prompt per file, in the order chains
-// already carries them (the same order the numbered table above it was
-// shown in, per D15 in render.go). [y]/[n] decide just that file; [a]
-// approves it and every remaining file without asking again; [d] stops
-// asking and applies whatever was already chosen, declining the rest; [q]
-// aborts the review entirely, discarding even files already marked yes -
-// reported back to reviewChains via quit=true. root is passed to
-// actionCell exactly as the directory-level table (render.go's planRows)
-// already does, so a destination inside root renders root-relative and one
-// outside it renders ~-abbreviated - review finding 1 (fix round
-// 2026-09-12): passing "" here always fails filepath.Rel("", dir) and
-// silently fell back to the abbreviated form even for a destination inside
-// root, which is not what spec §8.3's own worked example shows.
+// already carries them (the same order the plan above it numbered them).
+// Each file shows the same block body the plan shows (stepLines): every
+// step, its rule and its reason, wrapped to the terminal. [y]/[n] decide
+// just that file; [a] approves it and every remaining file without asking
+// again; [d] stops asking and applies whatever was already chosen,
+// declining the rest; [q] aborts the review entirely, discarding even files
+// already marked yes - reported back to reviewChains via quit=true. root is
+// passed down so a destination inside root renders root-relative and one
+// outside it renders ~-abbreviated, exactly as in the plan (review finding
+// 1, fix round 2026-09-12).
func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (approved map[string]bool, quit bool, err error) {
approved = map[string]bool{}
yesRest := false
@@ -109,10 +110,12 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string
}
fmt.Fprintf(out, "\n[%d/%d] %s\n", i+1, len(chains), c.File.Rel)
- for _, s := range c.Steps {
- fmt.Fprintf(out, " %s\n", styleAction(p, s, actionCell(s, root)))
+ for _, l := range stepLines(c, 7, root, p, widthPolicy(out)) {
+ fmt.Fprintln(out, l)
+ }
+ for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) {
+ fmt.Fprintln(out, l)
}
- fmt.Fprint(out, " "+p.keys("[y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing")+"\n")
for {
key, kerr := readKey(in)
@@ -141,6 +144,10 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string
return approved, false, nil
}
+// perFileKeys is the per-file prompt of spec §8.3, shared by review and
+// undo, and wrapped to the terminal like every other long line.
+const perFileKeys = "[y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing"
+
// readKey reads the single byte reviewChains treats as one keypress. Over
// the real terminal that byte already came from tui.ReadKey (via
// keyReader); in a test, it is just the next byte of a strings.Reader.
diff --git a/cmd/krino/review_test.go b/cmd/krino/review_test.go
index 6bf5ea7..a387022 100644
--- a/cmd/krino/review_test.go
+++ b/cmd/krino/review_test.go
@@ -3,8 +3,10 @@
package main
import (
+ "io"
"strings"
"testing"
+ "unicode/utf8"
"krino/internal/plan"
"krino/internal/scan"
@@ -124,3 +126,47 @@ func TestPerFileDestinationIsRootRelative(t *testing.T) {
t.Errorf("destination outside root should be ~-abbreviated:\n%s", text)
}
}
+
+// TestPerFileShowsTheWholeBlock: choosing per file shows each step, its
+// rule and its reason: the same block the plan shows, not the steps alone.
+func TestPerFileShowsTheWholeBlock(t *testing.T) {
+ cs := []plan.Chain{{File: scan.File{Rel: "a.pdf"}, Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "acme", Dst: "/w/a.pdf", Reason: `content "acme ltd"`},
+ }}}
+ out := new(strings.Builder)
+ if _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil {
+ t.Fatal(err)
+ }
+ want := "\n[1/1] a.pdf\n move → /w/\n rule acme\n because content \"acme ltd\"\n"
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("per-file prompt:\n%s\nwant substring:\n%s", out, want)
+ }
+}
+
+// TestPerFileWrapsToTheTerminal: on a narrow terminal the per-file block
+// and its key prompt both fit, and the prompt's pieces read back as the
+// whole prompt.
+func TestPerFileWrapsToTheTerminal(t *testing.T) {
+ old := widthPolicy
+ t.Cleanup(func() { widthPolicy = old })
+ widthPolicy = func(io.Writer) int { return 60 }
+ cs := []plan.Chain{{File: scan.File{Rel: "a.pdf"}, Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "acme", Dst: "/w/some/deeply/nested/destination/directory/for/invoices/a.pdf", Reason: `content "acme ltd"`},
+ }}}
+ out := new(strings.Builder)
+ if _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil {
+ t.Fatal(err)
+ }
+ var prompt string
+ for _, l := range strings.Split(out.String(), "\n") {
+ if n := utf8.RuneCountInString(l); n > 60 {
+ t.Errorf("line of %d runes exceeds width 60: %q", n, l)
+ }
+ if strings.HasPrefix(l, " [y] yes") || (prompt != "" && strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " ")) {
+ prompt += strings.TrimPrefix(l, " ")
+ }
+ }
+ if prompt != perFileKeys {
+ t.Errorf("wrapped prompt reads %q, want %q\n%s", prompt, perFileKeys, out)
+ }
+}
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index dd056e0..6d11238 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -23,7 +23,6 @@ import (
"krino/internal/lock"
"krino/internal/plan"
"krino/internal/scan"
- "krino/internal/tui"
"krino/internal/xdg"
)
@@ -187,8 +186,8 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
// exists for. printPlan is reused as-is (render.go), never
// re-rendered here.
var buf bytes.Buffer
- printPlan(&buf, dp, g.verbose, p)
- if err := tui.Page(stdout, buf.String()); err != nil {
+ printPlan(&buf, dp, g.verbose, p, widthPolicy(stdout))
+ if err := show(g, stdout, buf.String()); err != nil {
fmt.Fprintf(stderr, "krino: %s: %v\n", d.Name, err)
exit = 1
return false
@@ -432,15 +431,18 @@ func warnedCount(lines []warnLine) int {
}
// printWarnings lists one line per warning, Rel padded to the widest shown
-// (capped at 40), each line styled with p's warning colour.
-func printWarnings(w io.Writer, lines []warnLine, p palette) {
+// (capped at 40), each line styled with p's warning colour. width wraps a
+// long line with its continuation indented four columns (0 never wraps).
+func printWarnings(w io.Writer, lines []warnLine, p palette, width int) {
rels := make([]string, len(lines))
for i, l := range lines {
rels[i] = l.rel
}
- width := relWidth(rels)
+ relW := relWidth(rels)
for _, l := range lines {
- fmt.Fprintf(w, " %s\n", p.warn(padCell(l.rel, width)+" "+l.text))
+ for _, piece := range wrapped(" ", padCell(l.rel, relW)+" "+l.text, 4, width, p.warn) {
+ fmt.Fprintln(w, piece)
+ }
}
}
diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go
index cc65eea..d31a5b3 100644
--- a/cmd/krino/undo.go
+++ b/cmd/krino/undo.go
@@ -19,7 +19,6 @@ import (
"krino/internal/engine"
"krino/internal/journal"
"krino/internal/lock"
- "krino/internal/tui"
"krino/internal/xdg"
)
@@ -147,7 +146,7 @@ func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
text := colourRefused(buf.String(), p)
// Ruling 6 (Task 7), carried over: the plan goes through tui.Page for
// -n as much as for -y and the interactive path.
- if err := tui.Page(stdout, text); err != nil {
+ if err := show(g, stdout, text); err != nil {
fmt.Fprintf(stderr, "krino: %v\n", err)
return 1
}
@@ -334,7 +333,10 @@ func reviewUndoDir(out io.Writer, files []engine.UndoFile, p palette) (map[int]b
// folding rule reviewChains uses: a [c] session's own [q] becomes the same
// top-level 'q', and approved is emptied to match.
func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile, p palette) (map[int]bool, rune, error) {
- fmt.Fprint(out, "\n"+p.keys("[a] apply all [c] choose per file [s] skip [q] quit")+"\n")
+ fmt.Fprintln(out)
+ for _, l := range wrapped("", "[a] apply all [c] choose per file [s] skip [q] quit", 0, widthPolicy(out), p.keys) {
+ fmt.Fprintln(out, l)
+ }
for {
key, err := readKey(in)
if err != nil {
@@ -384,7 +386,9 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p
continue
}
- fmt.Fprint(out, " "+p.keys("[y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing")+"\n")
+ for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) {
+ fmt.Fprintln(out, l)
+ }
for {
key, kerr := readKey(in)
if kerr != nil {