diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/krino/matching_test.go | 50 | ||||
| -rw-r--r-- | cmd/krino/render.go | 258 | ||||
| -rw-r--r-- | cmd/krino/render_test.go | 283 | ||||
| -rw-r--r-- | cmd/krino/sort.go | 208 |
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. |
