aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/krino')
-rw-r--r--cmd/krino/matching_test.go50
-rw-r--r--cmd/krino/render.go258
-rw-r--r--cmd/krino/render_test.go283
-rw-r--r--cmd/krino/sort.go208
4 files changed, 696 insertions, 103 deletions
diff --git a/cmd/krino/matching_test.go b/cmd/krino/matching_test.go
index eaf9494..614edc2 100644
--- a/cmd/krino/matching_test.go
+++ b/cmd/krino/matching_test.go
@@ -4,6 +4,7 @@ package main
import (
"bytes"
+ "encoding/json"
"os"
"path/filepath"
"strings"
@@ -61,12 +62,12 @@ func TestDryRun(t *testing.T) {
t.Fatalf("exit %d: %s", code, errOut)
}
for _, want := range []string{
- "krino: dl ~/dl\n8 scanned · 4 matched · 2 warnings · ",
- "\n inv1.txt acme: type txt, content \"acme ltd\"\n",
- "\n notes.txt rest: not matched, type txt\n",
- "\n report (1).pdf dups: duplicate of report.pdf\n",
+ "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 trash dups duplicate of report.pdf\n",
"\nwarnings\n brochure.doc acme: content unreadable: needs antiword or catdoc, not installed\n",
- "\nnot matched: 2 · ignored: 1 · busy: 1 (-v lists them)\n",
+ "\nnot acted on: 1 ignored · 1 busy · 2 unmatched (-v lists them)\n",
} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
@@ -74,11 +75,38 @@ func TestDryRun(t *testing.T) {
}
}
+// TestDryRunJSON is the --json counterpart of TestDryRun: with -n it now
+// prints the plan instead of refusing, and the document parses.
+func TestDryRunJSON(t *testing.T) {
+ matchingFixture(t)
+ code, out, errOut := runCLI(t, "-n", "--json")
+ if code != 0 {
+ t.Fatalf("exit %d: %s", code, errOut)
+ }
+ for _, want := range []string{`"action": "move"`, `"rel": "inv1.txt"`} {
+ if !strings.Contains(out, want) {
+ t.Errorf("json output lacks %s:\n%s", want, out)
+ }
+ }
+ var doc struct {
+ Version int `json:"version"`
+ Dirs []struct {
+ Name string `json:"name"`
+ } `json:"dirs"`
+ }
+ if err := json.Unmarshal([]byte(out), &doc); err != nil {
+ t.Fatalf("output does not parse as JSON: %v\n%s", err, out)
+ }
+ if doc.Version != 1 || len(doc.Dirs) != 1 || doc.Dirs[0].Name != "dl" {
+ t.Errorf("document = %+v", doc)
+ }
+}
+
func TestDryRunVerbose(t *testing.T) {
matchingFixture(t)
_, out, _ := runCLI(t, "-n", "-v")
for _, want := range []string{
- "not matched: 2 · ignored: 1 · busy: 1\n",
+ "not acted on: 1 ignored · 1 busy · 2 unmatched\n",
"\nnot matched\n brochure.doc\n report.pdf\n",
"\nskipped\n movie.mkv busy\n movie.mkv.part ignored\n",
} {
@@ -121,7 +149,7 @@ func TestSortFlags(t *testing.T) {
}{
{[]string{"-y", "-n"}, "-y and -n cannot be used together"},
{nil, "applying files is not implemented yet; use -n to see what would happen"},
- {[]string{"-n", "--json"}, "--json is not implemented yet"},
+ {[]string{"--json"}, "--json is not implemented yet"}, // --json without -n stays an error
}
for _, tt := range tests {
if code, _, errOut := runCLI(t, tt.args...); code != 2 || !strings.Contains(errOut, tt.want) {
@@ -355,7 +383,7 @@ func TestLongNameNotPaddedLayoutIntact(t *testing.T) {
conf := `
(path "~/dl")
(min-age 0s)
-(rule "r" (when (type txt)) (stop))
+(rule "r" (when (type txt)) (move "Other"))
`
if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil {
t.Fatal(err)
@@ -365,10 +393,10 @@ func TestLongNameNotPaddedLayoutIntact(t *testing.T) {
if code != 0 {
t.Fatalf("exit %d: %s", code, errOut)
}
- if want := "\n " + long + " r: type txt\n"; !strings.Contains(out, want) {
- t.Errorf("long name should be unpadded (exactly two trailing spaces before the rule column):\n%s\nwant substring:\n%s", out, want)
+ 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)
}
- if want := "\n " + short + strings.Repeat(" ", 40-len(short)) + " r: type txt\n"; !strings.Contains(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)
}
}
diff --git a/cmd/krino/render.go b/cmd/krino/render.go
new file mode 100644
index 0000000..b27d4d1
--- /dev/null
+++ b/cmd/krino/render.go
@@ -0,0 +1,258 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "krino/internal/engine"
+ "krino/internal/plan"
+ "krino/internal/xdg"
+)
+
+// actionKindWidth is the field an action cell's kind word is left-padded
+// to before its arrow: the widest of the three kinds that carry a
+// destination ("copy", "move", "rename"). Trash and DELETE permanently
+// have no destination, hence no arrow to align, so they are not padded.
+const actionKindWidth = len("rename")
+
+// printPlan renders one directory's plan the way krino -n shows it, per
+// spec §8.2, below the header line cmdSort has already written: a counts
+// line, the numbered action table, the warnings section and the "not
+// acted on" line, each present only when it has something to show. No
+// colour, pager or prompt: those arrive with the review UI in plan 4,
+// which wraps this same function, hence its plain (io.Writer, *DirPlan,
+// bool) signature.
+func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool) {
+ r := dp.Result
+ // C1 (plan 2): scanned counts matched, unmatched and skipped alike, not
+ // just matched plus unmatched - spec §8.2's worked example is "266
+ // scanned" against "41 to act on" and "not acted on: 3 busy · 12
+ // ignored · 210 unmatched", and 41+3+12+210 = 266.
+ scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped)
+ // B2: warning lines come from both the match itself (fm.Warnings) and
+ // the chains plan.Build produced (Chain.Warnings, e.g. "moved more than
+ // once") - both computed once here so the count and the section below
+ // agree on the exact same list.
+ lines := collectWarnings(r, dp.Chains)
+ // D12: dp.Elapsed spans Match plus Build, unlike r.Elapsed, which stops
+ // before Build ever runs - the label says "planning", so the number
+ // must cover all of it.
+ fmt.Fprintf(w, "%d scanned · %d to act on · %d warnings · %.2fs\n", scanned, countActing(dp.Chains), warnedCount(lines), dp.Elapsed.Seconds())
+
+ if rows := planRows(dp.Chains, dp.Dir.Root); len(rows) > 0 {
+ fmt.Fprintln(w)
+ printPlanTable(w, rows)
+ }
+
+ if len(lines) > 0 {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "warnings")
+ printWarnings(w, lines)
+ }
+
+ if line := skipSummaryLine(r, dp.Chains, verbose); line != "" {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, line)
+ }
+
+ if verbose {
+ if len(r.Unmatched) > 0 {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "not matched")
+ for _, fm := range r.Unmatched {
+ fmt.Fprintf(w, " %s\n", fm.File.Rel)
+ }
+ }
+ if len(r.Skipped) > 0 {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "skipped")
+ printSkipped(w, r.Skipped)
+ }
+ }
+}
+
+// countActing reports how many chains have at least one step that will
+// actually run. C1/ruling 2026-09-12: a rule with no actions is an
+// exclusion, and a chain every one of whose steps is skipped is not about
+// to do anything either - neither must inflate "to act on".
+func countActing(chains []plan.Chain) int {
+ n := 0
+ for _, c := range chains {
+ for _, s := range c.Steps {
+ if s.Skip == "" {
+ n++
+ break
+ }
+ }
+ }
+ return n
+}
+
+// planRow is one line of the action table: a file's first step (num and
+// file set) or a continuation line (both blank). chainIdx (D15) is the
+// index into the DirPlan's own Chains slice this row was built from, kept
+// alongside the display strings so plan 4's per-file approval ("row 2 is
+// chain index 1", spec §8.3) does not have to re-derive the mapping later;
+// it changes no rendering here and is not itself asserted by a test.
+type planRow struct {
+ num, file, actions, rule, reason string
+ chainIdx int
+}
+
+// planRows turns the chains that have at least one step into table rows,
+// per spec §8.2: a file's first step shares its numbered row; later steps
+// sit on continuation lines with the number and file columns blank. A
+// chain with no steps at all - an exclusion, or a rule that only stops -
+// has nothing to show and contributes no row and no number. root is the
+// directory being planned, threaded down to destText so a destination
+// inside it renders root-relative rather than home-abbreviated.
+func planRows(chains []plan.Chain, root string) []planRow {
+ var rows []planRow
+ n := 0
+ for ci, c := range chains {
+ if len(c.Steps) == 0 {
+ continue
+ }
+ n++
+ for i, s := range c.Steps {
+ row := planRow{actions: actionCell(s, root), rule: s.Rule, reason: s.Reason, chainIdx: ci}
+ if i == 0 {
+ row.num = strconv.Itoa(n)
+ row.file = c.File.Rel
+ }
+ rows = append(rows, row)
+ }
+ }
+ return rows
+}
+
+// actionCell renders one step's action column. A step with Skip set shows
+// its reason in place of the destination. Trash and DELETE permanently
+// have no destination to show - even when the step came from a duplicate
+// rule, the reason column already says what it is a duplicate of, so the
+// action column is just the kind word. Everything else (copy, move,
+// rename) shows the kind, padded so every arrow in the table lines up,
+// then its destination; Displaces adds a trailing note.
+func actionCell(s plan.Step, root string) string {
+ kind := s.Kind.String()
+ if s.Skip != "" {
+ return padCell(kind, actionKindWidth) + " skipped: " + s.Skip
+ }
+ switch s.Kind {
+ case plan.Trash, plan.DeletePermanent:
+ return kind
+ }
+ cell := padCell(kind, actionKindWidth) + " → " + destText(s, root)
+ if s.Displaces != "" {
+ cell += " (replaces the existing file)"
+ }
+ return cell
+}
+
+// destText renders a copy/move/rename step's destination, per spec §8.2:
+// for rename, just the new base name. For copy and move, a directory with
+// a trailing "/" so it reads as one - root-relative when it lies inside
+// the directory being planned (spec's own worked example: "Work/Acme/"),
+// abbreviated against $HOME otherwise (the same example's
+// "~/backup/invoices/2026/", outside the root entirely).
+func destText(s plan.Step, root string) string {
+ if s.Kind == plan.Rename {
+ return filepath.Base(s.Dst)
+ }
+ dir := filepath.Dir(s.Dst)
+ if rel, ok := relToRoot(root, dir); ok {
+ if rel == "" {
+ // D10: rel is "" exactly when dir is root itself (relToRoot's
+ // own case below); rendering that as bare rel+"/" would print
+ // "/", which reads as the filesystem root rather than "this
+ // directory".
+ return "./"
+ }
+ return rel + "/"
+ }
+ return xdg.Abbrev(dir) + "/"
+}
+
+// relToRoot returns dir relative to root (slash-separated) when dir is
+// root itself or lies inside it; ok is false when dir lies outside root,
+// including when the two cannot be related at all (e.g. one relative, one
+// absolute). C3: root itself counts as "inside" here (rel is "", ok true) -
+// unlike internal/engine/match.go's excludeDirs, which asks a different
+// question (what may a rule exclude from the walk) and treats root as
+// outside it; do not "unify" the two.
+func relToRoot(root, dir string) (rel string, ok bool) {
+ r, err := filepath.Rel(root, dir)
+ if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
+ return "", false
+ }
+ if r == "." {
+ return "", true // dir is root itself
+ }
+ return filepath.ToSlash(r), true
+}
+
+// printPlanTable prints rows in the table layout of spec §8.2: #, file,
+// actions and rule are padded to what is actually shown in this section,
+// in runes, exactly as relWidth/padCell already do elsewhere; the file
+// column is capped at 40 and the actions column at 46. The reason is the
+// last column and is never padded. The row number is right-aligned
+// (padLeft, not padCell) so the "#" column stays flush as it widens past
+// a single digit - the layout plan 4's review UI inherits unchanged.
+func printPlanTable(w io.Writer, rows []planRow) {
+ nums := make([]string, len(rows))
+ files := make([]string, len(rows))
+ actions := make([]string, len(rows))
+ rules := make([]string, len(rows))
+ for i, r := range rows {
+ nums[i], files[i], actions[i], rules[i] = r.num, r.file, r.actions, r.rule
+ }
+ // D13: numW and ruleW are left uncapped, unlike fileW and actionsW. The
+ // row number is at most a few digits regardless of how large a
+ // directory is, and a rule name is a config author's own identifier,
+ // not scan noise from a user's file names - truncating a name someone
+ // deliberately chose would only make the row harder to trace back to
+ // its rule, so there is nothing here worth capping.
+ numW := colWidth(nums, 0)
+ fileW := relWidth(files)
+ actionsW := colWidth(actions, 46)
+ ruleW := colWidth(rules, 0)
+
+ fmt.Fprintf(w, " %s %s %s rule\n", padLeft("#", numW), padCell("file", fileW), padCell("actions", actionsW))
+ for _, r := range rows {
+ fmt.Fprintf(w, " %s %s %s %s %s\n", padLeft(r.num, numW), padCell(r.file, fileW), padCell(r.actions, actionsW), padCell(r.rule, ruleW), r.reason)
+ }
+}
+
+// padLeft pads s to width w (runes, not bytes) with leading spaces,
+// right-aligning it; s already at or beyond w is left unpadded. Used only
+// for the row-number column - every other column reads left-aligned, per
+// padCell.
+func padLeft(s string, w int) string {
+ n := utf8.RuneCountInString(s)
+ if n >= w {
+ return s
+ }
+ return strings.Repeat(" ", w-n) + s
+}
+
+// colWidth returns the widest string in ss, in runes, capped at max when
+// max is positive; 0 leaves it uncapped. Shares relWidth's rune-counting
+// rule (C4): a name carrying diacritics must not misalign its column.
+func colWidth(ss []string, max int) int {
+ w := 0
+ for _, s := range ss {
+ if n := utf8.RuneCountInString(s); n > w {
+ w = n
+ }
+ }
+ if max > 0 && w > max {
+ w = max
+ }
+ return w
+}
diff --git a/cmd/krino/render_test.go b/cmd/krino/render_test.go
new file mode 100644
index 0000000..0a6ae26
--- /dev/null
+++ b/cmd/krino/render_test.go
@@ -0,0 +1,283 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/engine"
+ "krino/internal/plan"
+ "krino/internal/scan"
+)
+
+// 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.
+//
+// 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
+// ("Work/Acme/2026/"), while fv_123.pdf's "backup" step lands under $HOME
+// but outside root and stays home-abbreviated ("~/backup/invoices/2026/")
+// - spec §8.2's own worked example draws exactly this distinction.
+//
+// "excluded.txt" matched a rule with no actions (an exclusion, spec §4.5):
+// it has zero steps, so it must not appear in the table and must not
+// inflate "to act on" (ruling 2026-09-12).
+func TestPrintPlan(t *testing.T) {
+ h := home(t)
+ root := filepath.Join(h, "downloads")
+
+ scan001 := plan.Chain{
+ File: scan.File{Rel: "scan001.pdf"},
+ Steps: []plan.Step{
+ {
+ Kind: plan.Move,
+ Rule: "acme",
+ Src: filepath.Join(root, "scan001.pdf"),
+ Dst: filepath.Join(root, "Work", "Acme", "2026", "scan001.pdf"),
+ Reason: `content "acme ltd"`,
+ },
+ },
+ }
+ fv123 := plan.Chain{
+ File: scan.File{Rel: "fv_123.pdf"},
+ Steps: []plan.Step{
+ {
+ Kind: plan.Copy,
+ Rule: "backup",
+ Src: filepath.Join(root, "fv_123.pdf"),
+ Dst: filepath.Join(h, "backup", "invoices", "2026", "fv_123.pdf"),
+ Reason: `content "invoice"`,
+ },
+ {
+ Kind: plan.Move,
+ Rule: "acme",
+ Src: filepath.Join(h, "backup", "invoices", "2026", "fv_123.pdf"),
+ Dst: filepath.Join(root, "Work", "Acme", "2026", "fv_123.pdf"),
+ Reason: `name \bacme\b`,
+ },
+ },
+ }
+ setup := plan.Chain{
+ File: scan.File{Rel: "setup-1.2.deb"},
+ Steps: []plan.Step{
+ {Kind: plan.DeletePermanent, Rule: "old-pkgs", Src: filepath.Join(root, "setup-1.2.deb"), Reason: "age 94d"},
+ },
+ }
+ excluded := plan.Chain{File: scan.File{Rel: "excluded.txt"}} // matched a stop-only rule: no actions, no steps
+
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "downloads", Root: root},
+ Chains: []plan.Chain{scan001, fv123, setup, excluded},
+ Elapsed: 420 * time.Millisecond, // D12: the counts line renders DirPlan.Elapsed (Match plus Build), not Result.Elapsed alone
+ Result: &engine.Result{
+ Matched: []engine.FileMatch{
+ {File: scan.File{Rel: "scan001.pdf"}, Warnings: []string{"acme: content unreadable: needs pdftotext, not installed"}},
+ {File: scan.File{Rel: "fv_123.pdf"}},
+ {File: scan.File{Rel: "setup-1.2.deb"}},
+ {File: scan.File{Rel: "excluded.txt"}},
+ },
+ Unmatched: []engine.FileMatch{{File: scan.File{Rel: "unmatched.txt"}}},
+ Skipped: []scan.Skipped{{Rel: "busy.tmp", Reason: scan.Busy}},
+ },
+ }
+
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false)
+ 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",
+ "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",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("output lacks %q:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "excluded.txt") {
+ t.Errorf("excluded.txt has no steps and must not appear in the table:\n%s", out)
+ }
+}
+
+// TestPrintPlanSkippedStep: a step with Skip set shows its reason in place
+// of the destination, and a Displaces step notes that it replaces the
+// existing file.
+func TestPrintPlanSkippedStep(t *testing.T) {
+ home(t) // isolate HOME even though these paths do not use it
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: "/r"},
+ Chains: []plan.Chain{
+ {
+ File: scan.File{Rel: "a.pdf"},
+ Steps: []plan.Step{
+ {Kind: plan.Copy, Rule: "backup", Src: "/r/a.pdf", Dst: "/backup/a.pdf", Skip: "target exists"},
+ },
+ },
+ {
+ File: scan.File{Rel: "b.pdf"},
+ Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "acme", Src: "/r/b.pdf", Dst: "/r/Work/b.pdf", Displaces: "/r/Work/b.pdf"},
+ },
+ },
+ },
+ Result: &engine.Result{
+ Matched: []engine.FileMatch{{File: scan.File{Rel: "a.pdf"}}, {File: scan.File{Rel: "b.pdf"}}},
+ },
+ }
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false)
+ 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)
+ }
+ if !strings.Contains(out, "move → Work/ (replaces the existing file)") {
+ t.Errorf("a Displaces step should note it replaces the existing file:\n%s", out)
+ }
+ if !strings.Contains(out, "2 scanned · 1 to act on · 0 warnings ·") {
+ t.Errorf("a.pdf's only step is skipped, so it must not count as \"to act on\":\n%s", out)
+ }
+}
+
+// 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) {
+ h := home(t)
+ root := filepath.Join(h, "dl")
+ long := strings.Repeat("z", 42) + ".txt" // 46 runes: past the 40-column cap
+
+ names := make([]string, 11)
+ for i := range names {
+ names[i] = fmt.Sprintf("f%02d.txt", i+1)
+ }
+ names[10] = long // row 11 carries the long name
+
+ var chains []plan.Chain
+ var matched []engine.FileMatch
+ for i, name := range names {
+ rule := "r"
+ if i == 5 {
+ rule = "a-noticeably-longer-rule-name"
+ }
+ chains = append(chains, plan.Chain{
+ File: scan.File{Rel: name},
+ Steps: []plan.Step{
+ {Kind: plan.Move, Rule: rule, Src: filepath.Join(root, name), Dst: filepath.Join(root, "Out", name), Reason: "type txt"},
+ },
+ })
+ matched = append(matched, engine.FileMatch{File: scan.File{Rel: name}})
+ }
+
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: root},
+ Chains: chains,
+ Result: &engine.Result{Matched: matched},
+ }
+
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false)
+ 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",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("output lacks %q:\n%s", want, out)
+ }
+ }
+}
+
+// TestPrintPlanCountsFileOnceWithBothWarningKinds: B2 - a file may carry
+// both a match warning (Result.Matched[i].Warnings) and a chain warning
+// (Chain.Warnings, e.g. "moved more than once"). Both must reach the
+// warnings section, but the counts line's "N warnings" counts files with at
+// least one warning, not warning lines, so this one file must still count
+// as 1, not 2.
+func TestPrintPlanCountsFileOnceWithBothWarningKinds(t *testing.T) {
+ h := home(t)
+ root := filepath.Join(h, "dl")
+ dp := &engine.DirPlan{
+ Dir: &engine.Dir{Name: "dl", Root: root},
+ Chains: []plan.Chain{
+ {
+ File: scan.File{Rel: "a.pdf"},
+ Steps: []plan.Step{
+ {Kind: plan.Move, Rule: "r1", Src: filepath.Join(root, "a.pdf"), Dst: filepath.Join(root, "Out", "a.pdf")},
+ {Kind: plan.Move, Rule: "r2", Src: filepath.Join(root, "Out", "a.pdf"), Dst: filepath.Join(root, "Out2", "a.pdf")},
+ },
+ Warnings: []string{"moved more than once; a (stop) is probably missing"},
+ },
+ },
+ Result: &engine.Result{
+ Matched: []engine.FileMatch{
+ {File: scan.File{Rel: "a.pdf"}, Warnings: []string{"r1: content unreadable: needs pdftotext, not installed"}},
+ },
+ },
+ }
+ var buf bytes.Buffer
+ printPlan(&buf, dp, false)
+ out := buf.String()
+
+ for _, want := range []string{
+ "1 scanned · 1 to act on · 1 warnings ·",
+ " a.pdf r1: content unreadable: needs pdftotext, not installed\n",
+ " a.pdf moved more than once; a (stop) is probably missing\n",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("output lacks %q:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "2 warnings") {
+ t.Errorf("one file with two warnings must count once, not twice:\n%s", out)
+ }
+}
+
+// TestSkipSummaryLineAccountsForEveryFile pins that the footer's categories
+// add up to "scanned". The real downloads folder reported "267 scanned · 172
+// to act on" while saying nothing about the other 95, which had matched an
+// exclusion rule carrying no actions: they were neither acted on, nor
+// unmatched, nor skipped by the walk.
+func TestSkipSummaryLineAccountsForEveryFile(t *testing.T) {
+ r := &engine.Result{
+ Matched: make([]engine.FileMatch, 4),
+ Unmatched: make([]engine.FileMatch, 2),
+ Skipped: []scan.Skipped{{Rel: "a.part", Reason: scan.Ignored}, {Rel: "b.iso", Reason: scan.Busy}},
+ }
+ chains := []plan.Chain{
+ {Steps: []plan.Step{{Kind: plan.Move, Dst: "/r/W/x"}}}, // acting
+ {}, // excluded: matched an action-less rule
+ {}, // excluded
+ {Steps: []plan.Step{{Kind: plan.Move, Skip: "target exists"}}}, // every step skipped
+ }
+ got := skipSummaryLine(r, chains, false)
+ want := "not acted on: 1 ignored · 1 busy · 2 excluded · 1 all steps skipped · 2 unmatched (-v lists them)"
+ if got != want {
+ t.Errorf("line =\n%q\nwant\n%q", got, want)
+ }
+ scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped)
+ excluded, allSkipped := chainOutcomes(chains)
+ if acting := countActing(chains); acting+excluded+allSkipped+len(r.Unmatched)+len(r.Skipped) != scanned {
+ t.Errorf("categories do not sum to scanned: %d acting + %d excluded + %d all-skipped + %d unmatched + %d skipped != %d",
+ acting, excluded, allSkipped, len(r.Unmatched), len(r.Skipped), scanned)
+ }
+}
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index fe88cf8..0ba82bc 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -4,6 +4,7 @@ package main
import (
"context"
+ "encoding/json"
"fmt"
"io"
"os"
@@ -12,17 +13,18 @@ import (
"unicode/utf8"
"krino/internal/engine"
+ "krino/internal/plan"
"krino/internal/scan"
"krino/internal/xdg"
)
// cmdSort plans and applies the included directories. Only -n (dry run) is
-// implemented; applying arrives in plan 3.
+// implemented; applying arrives in plan 4.
func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
if g.yes && g.dry {
return usageError(stderr, "-y and -n cannot be used together")
}
- if g.json {
+ if g.json && !g.dry {
fmt.Fprintln(stderr, "krino: --json is not implemented yet")
return 2
}
@@ -39,106 +41,56 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
exit := 0
printed := false
+ jsonDirs := []plan.JSONDir{} // never nil: the document's "dirs" must marshal as [], not null
+ // A3: one Claims for the whole run, shared across every directory's
+ // Plan call below, so two directories that both plan a move to the
+ // same destination resolve the collision at planning time instead of
+ // each independently believing it owns that path.
+ claims := plan.NewClaims()
for _, d := range e.Dirs {
if fi, err := os.Stat(d.Root); err != nil || !fi.IsDir() {
fmt.Fprintf(stderr, "krino: skipping %s: %s is not a directory\n", d.Name, xdg.Abbrev(d.Root))
exit = 1
continue
}
- r, err := e.Match(context.Background(), d)
+ dp, err := e.Plan(context.Background(), d, claims)
if err != nil {
fmt.Fprintf(stderr, "krino: skipping %s: %v\n", d.Name, err)
exit = 1
continue
}
- if printed {
- fmt.Fprintln(stdout)
+ if !g.json {
+ if printed {
+ fmt.Fprintln(stdout)
+ }
+ printed = true
+ fmt.Fprintf(stdout, "krino: %s %s\n", d.Name, xdg.Abbrev(d.Root))
}
- printed = true
- fmt.Fprintf(stdout, "krino: %s %s\n", d.Name, xdg.Abbrev(d.Root))
// C3: directory-level warnings go to stderr after the header
// line above, not before it, so on a terminal they read as
// describing the directory just named instead of floating above it.
- for _, w := range r.Warnings {
+ for _, w := range dp.Result.Warnings {
fmt.Fprintf(stderr, "krino: %s: %s\n", d.Name, w)
}
- printResult(stdout, r, g.verbose)
- }
- return exit
-}
-
-// printResult renders one directory's match result the way krino -n shows
-// it, below the header line cmdSort has already written: a summary line,
-// then the matched, warnings and skip-count sections, each present only
-// when it has something to show. Plan 3 reuses this for the outcome of an
-// actual run.
-func printResult(w io.Writer, r *engine.Result, verbose bool) {
- // C1: 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 - so scanned counts matched, unmatched and skipped
- // alike, not just matched plus unmatched.
- scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped)
- warned := 0
- for _, fm := range r.Matched {
- if len(fm.Warnings) > 0 {
- warned++
- }
- }
- for _, fm := range r.Unmatched {
- if len(fm.Warnings) > 0 {
- warned++
- }
- }
- fmt.Fprintf(w, "%d scanned · %d matched · %d warnings · %.2fs\n", scanned, len(r.Matched), warned, r.Elapsed.Seconds())
-
- if len(r.Matched) > 0 {
- fmt.Fprintln(w)
- printMatched(w, r.Matched)
- }
-
- if lines := collectWarnings(r); len(lines) > 0 {
- fmt.Fprintln(w)
- fmt.Fprintln(w, "warnings")
- printWarnings(w, lines)
- }
-
- if line := skipSummaryLine(r, verbose); line != "" {
- fmt.Fprintln(w)
- fmt.Fprintln(w, line)
- }
-
- if verbose {
- if len(r.Unmatched) > 0 {
- fmt.Fprintln(w)
- fmt.Fprintln(w, "not matched")
- for _, fm := range r.Unmatched {
- fmt.Fprintf(w, " %s\n", fm.File.Rel)
- }
- }
- if len(r.Skipped) > 0 {
- fmt.Fprintln(w)
- fmt.Fprintln(w, "skipped")
- printSkipped(w, r.Skipped)
+ if g.json {
+ jsonDirs = append(jsonDirs, plan.NewJSONDir(d.Name, d.Root, dp.Chains, dp.Result.Warnings))
+ continue
}
+ printPlan(stdout, dp, g.verbose)
}
-}
-// printMatched lists each matched file, its Rel padded to the widest shown
-// (capped at 40), then each matching rule as "name: reasons", rules joined
-// by "; ".
-func printMatched(w io.Writer, matched []engine.FileMatch) {
- rels := make([]string, len(matched))
- for i, fm := range matched {
- rels[i] = fm.File.Rel
- }
- width := relWidth(rels)
- for _, fm := range matched {
- parts := make([]string, len(fm.Rules))
- for i, rm := range fm.Rules {
- parts[i] = rm.Rule.Name + ": " + strings.Join(rm.Reasons, ", ")
+ if g.json {
+ b, err := json.MarshalIndent(plan.NewJSON(jsonDirs), "", " ")
+ if err != nil {
+ // Unreachable in practice: every field the document carries
+ // marshals cleanly (strings, times, ints).
+ fmt.Fprintf(stderr, "krino: %v\n", err)
+ return 1
}
- fmt.Fprintf(w, " %s %s\n", padCell(fm.File.Rel, width), strings.Join(parts, "; "))
+ stdout.Write(b)
+ fmt.Fprintln(stdout)
}
+ return exit
}
// warnLine is one file's warning, for the warnings section.
@@ -152,22 +104,48 @@ type warnLine struct {
// section by file name and has no way to tell which group a file fell
// into, so grouping by match state is invisible structure that would only
// show up as an odd order. A file's own warnings (when it has more than
-// one) stay in the order they were recorded.
-func collectWarnings(r *engine.Result) []warnLine {
+// one) stay in the order they were recorded: its match warnings (if any)
+// first, then its chain warnings (B2) - match happens before planning, so
+// that is also the order they were actually produced in. chains supplies
+// the chain-level warnings (e.g. "moved more than once"), keyed by
+// Chain.File.Rel; every chain's file is necessarily also in r.Matched (only
+// matched files ever reach plan.Build), so it is visited exactly once here.
+func collectWarnings(r *engine.Result, chains []plan.Chain) []warnLine {
files := make([]engine.FileMatch, 0, len(r.Matched)+len(r.Unmatched))
files = append(files, r.Matched...)
files = append(files, r.Unmatched...)
sort.Slice(files, func(i, j int) bool { return files[i].File.Rel < files[j].File.Rel })
+ chainWarnings := make(map[string][]string, len(chains))
+ for _, c := range chains {
+ if len(c.Warnings) > 0 {
+ chainWarnings[c.File.Rel] = c.Warnings
+ }
+ }
+
var out []warnLine
for _, fm := range files {
for _, w := range fm.Warnings {
out = append(out, warnLine{fm.File.Rel, w})
}
+ for _, w := range chainWarnings[fm.File.Rel] {
+ out = append(out, warnLine{fm.File.Rel, w})
+ }
}
return out
}
+// warnedCount counts the distinct files behind lines: B2's "N warnings" in
+// the counts line must count a file once even when it carries both a match
+// warning and a chain warning, not once per warning line.
+func warnedCount(lines []warnLine) int {
+ seen := make(map[string]bool, len(lines))
+ for _, l := range lines {
+ seen[l.rel] = true
+ }
+ return len(seen)
+}
+
// printWarnings lists one line per warning, Rel padded to the widest shown
// (capped at 40).
func printWarnings(w io.Writer, lines []warnLine) {
@@ -194,37 +172,83 @@ func printSkipped(w io.Writer, skipped []scan.Skipped) {
}
}
-// skipReasonOrder is the order the last line reports skip reasons in,
-// after "not matched".
+// skipReasonOrder is plan 2's reviewed order for the skip reasons the last
+// line reports, before "unmatched".
var skipReasonOrder = []scan.Reason{scan.Ignored, scan.Busy, scan.TooNew, scan.Symlink, scan.NotRegular, scan.Unreadable}
-// skipSummaryLine builds the "not matched: N · ignored: N ..." line, only
-// the non-zero counts, or "" when every count is zero.
-func skipSummaryLine(r *engine.Result, verbose bool) string {
+// skipSummaryLine builds the "not acted on: N ignored · N busy · ... · N
+// unmatched" line per spec §8.2's item format ("<count> <label>", not
+// "<label>: <count>"), only the non-zero counts, or "" when every count is
+// zero. Ordering is plan 2's reviewed skipReasonOrder, with "unmatched"
+// last: the spec's own worked example shows only three of the seven
+// categories and states no ordering rule, so its incidental order is not
+// adopted, only its item format and unmatched's trailing position.
+func skipSummaryLine(r *engine.Result, chains []plan.Chain, verbose bool) string {
counts := map[scan.Reason]int{}
for _, s := range r.Skipped {
counts[s.Reason]++
}
var parts []string
- if n := len(r.Unmatched); n > 0 {
- parts = append(parts, fmt.Sprintf("not matched: %d", n))
- }
for _, reason := range skipReasonOrder {
if n := counts[reason]; n > 0 {
- parts = append(parts, fmt.Sprintf("%s: %d", reason.String(), n))
+ parts = append(parts, fmt.Sprintf("%d %s", n, reason.String()))
}
}
+ // excluded and allSkipped (chainOutcomes) close the same arithmetic gap:
+ // without them, a file matching only an action-less rule, or one whose
+ // every step was skipped, is neither "to act on", unmatched, nor a walk
+ // skip, so it appears nowhere.
+ excluded, allSkipped := chainOutcomes(chains)
+ if excluded > 0 {
+ parts = append(parts, fmt.Sprintf("%d excluded", excluded))
+ }
+ if allSkipped > 0 {
+ parts = append(parts, fmt.Sprintf("%d all steps skipped", allSkipped))
+ }
+ if n := len(r.Unmatched); n > 0 {
+ parts = append(parts, fmt.Sprintf("%d unmatched", n))
+ }
if len(parts) == 0 {
return ""
}
- line := strings.Join(parts, " · ")
+ line := "not acted on: " + strings.Join(parts, " · ")
if !verbose {
line += " (-v lists them)"
}
return line
}
+// chainOutcomes counts two of skipSummaryLine's categories over chains:
+// excluded is chains with no steps at all (a file that matched only rules
+// carrying no actions - spec §4.5: "a rule with only (stop) is an
+// exclusion"); allSkipped is chains with at least one step, none of them
+// unskipped (every step's Skip is set). Neither is "to act on", neither is
+// unmatched, and neither is a walk skip, so without these two counts they
+// appear nowhere: the real downloads folder reported "267 scanned · 172 to
+// act on" and said nothing at all about the other 95. With them the
+// arithmetic always closes - scanned = to act on + excluded + all steps
+// skipped + unmatched + walk skips - as spec §8.2's own example does.
+func chainOutcomes(chains []plan.Chain) (excluded, allSkipped int) {
+ for _, c := range chains {
+ if len(c.Steps) == 0 {
+ excluded++
+ continue
+ }
+ acting := false
+ for _, s := range c.Steps {
+ if s.Skip == "" {
+ acting = true
+ break
+ }
+ }
+ if !acting {
+ allSkipped++
+ }
+ }
+ return excluded, allSkipped
+}
+
// relWidth returns the column width for a list of Rel names: the widest in
// runes (C4: not bytes, or a name carrying diacritics misaligns its
// column), capped at 40.