diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 13:26:51 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 13:26:51 +0200 |
| commit | 07c24054cab965800983ef40f53a05c2db131ede (patch) | |
| tree | 741db561d614b35f8f94fee04fb5ce663a629a0b /cmd/krino | |
| parent | 70dccf8d573028aaed64185acb8e134e5339afa3 (diff) | |
| download | krino-07c24054cab965800983ef40f53a05c2db131ede.tar.gz krino-07c24054cab965800983ef40f53a05c2db131ede.zip | |
krino: 0.0.3 — the plan as one block per file, wrapped, and -Pv0.0.3
Each file shows its steps, then the rule and the reason it matched, one
field per line; on a terminal every line wraps to its width with
continuation lines under their own column, and piped output is never
wrapped. Choosing per file shows the same block. -P / --no-pager prints
the plan without the pager. A duplicate's original is shown with ~.
Diffstat (limited to 'cmd/krino')
| -rw-r--r-- | cmd/krino/colour.go | 53 | ||||
| -rw-r--r-- | cmd/krino/colour_test.go | 88 | ||||
| -rw-r--r-- | cmd/krino/main.go | 12 | ||||
| -rw-r--r-- | cmd/krino/matching_test.go | 20 | ||||
| -rw-r--r-- | cmd/krino/render.go | 258 | ||||
| -rw-r--r-- | cmd/krino/render_test.go | 144 | ||||
| -rw-r--r-- | cmd/krino/review.go | 39 | ||||
| -rw-r--r-- | cmd/krino/review_test.go | 46 | ||||
| -rw-r--r-- | cmd/krino/sort.go | 16 | ||||
| -rw-r--r-- | cmd/krino/undo.go | 12 |
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 { |
