From 3b36a48b7ce5a53a9366f3b31f94311f178e2553 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 01:22:12 +0200 Subject: krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/krino/check.go | 38 +++-- cmd/krino/commands_test.go | 6 +- cmd/krino/explain.go | 70 +++++++++ cmd/krino/main.go | 6 - cmd/krino/main_test.go | 1 + cmd/krino/matching_test.go | 374 +++++++++++++++++++++++++++++++++++++++++++++ cmd/krino/sort.go | 252 ++++++++++++++++++++++++++++++ cmd/krino/sort_test.go | 23 +++ 8 files changed, 749 insertions(+), 21 deletions(-) create mode 100644 cmd/krino/explain.go create mode 100644 cmd/krino/matching_test.go create mode 100644 cmd/krino/sort.go create mode 100644 cmd/krino/sort_test.go (limited to 'cmd') diff --git a/cmd/krino/check.go b/cmd/krino/check.go index fcd507d..1820bbe 100644 --- a/cmd/krino/check.go +++ b/cmd/krino/check.go @@ -5,40 +5,52 @@ package main import ( "fmt" "io" - "os" - "krino/internal/config" + "krino/internal/engine" "krino/internal/xdg" ) func init() { commands["check"] = cmdCheck } -// cmdCheck validates the configuration and lists each directory's rules. +// cmdCheck validates the configuration and lists each directory's rules, +// its current state, and the extractors available for content tests. func cmdCheck(g *globals, args []string, stdout, stderr io.Writer) int { fs := flagSet("check", g) if code, ok := parse(fs, args, stdout, stderr); !ok { return code } - cfg, errs := config.Load(mainFile(g), fs.Args()...) + e, errs := engine.Load(mainFile(g), fs.Args()...) if len(errs) > 0 { printDiags(stderr, errs) return 2 } - fmt.Fprintf(stdout, "config: %s\n", xdg.Abbrev(cfg.Main.File)) - if len(cfg.Dirs) == 0 { + r := e.Check() + + fmt.Fprintf(stdout, "config: %s\n", xdg.Abbrev(r.MainFile)) + fmt.Fprintf(stdout, "log: %s\n", xdg.Abbrev(r.LogFile)) + if len(r.Dirs) == 0 { fmt.Fprintln(stdout, "no directories included; add one with: krino new NAME PATH") } - for _, d := range cfg.Dirs { - fmt.Fprintf(stdout, "\n%s %s\n", d.Name, xdg.Abbrev(d.Path)) - if fi, err := os.Stat(d.Path); err != nil || !fi.IsDir() { - fmt.Fprintf(stdout, " warning: %s is not a directory right now; it will be skipped\n", xdg.Abbrev(d.Path)) + for _, dr := range r.Dirs { + fmt.Fprintf(stdout, "\n%s %s\n", dr.Dir.Name, xdg.Abbrev(dr.Dir.Root)) + if dr.Missing { + fmt.Fprintf(stdout, " warning: %s is not a directory right now; it will be skipped\n", xdg.Abbrev(dr.Dir.Root)) } - if len(d.Rules) == 0 { + if len(dr.Dir.Rules) == 0 { fmt.Fprintln(stdout, " no rules yet") } - for i, r := range d.Rules { - fmt.Fprintf(stdout, " %2d %-16s %s\n", i+1, r.Name, describeActions(r)) + for i, rule := range dr.Dir.Rules { + fmt.Fprintf(stdout, " %2d %-16s %s\n", i+1, rule.Name, describeActions(rule.Conf)) + } + } + + fmt.Fprintln(stdout, "\nextractors:") + for _, tool := range r.Tools { + path := "not installed" + if tool.Path != "" { + path = xdg.Abbrev(tool.Path) } + fmt.Fprintf(stdout, " %-9s %s\n", tool.Name, path) } return 0 } diff --git a/cmd/krino/commands_test.go b/cmd/krino/commands_test.go index 68a2174..2156da2 100644 --- a/cmd/krino/commands_test.go +++ b/cmd/krino/commands_test.go @@ -9,12 +9,14 @@ import ( "testing" ) -// home gives each test its own HOME with an empty XDG config. +// home gives each test its own HOME with no XDG overrides. func home(t *testing.T) string { t.Helper() h := t.TempDir() t.Setenv("HOME", h) - t.Setenv("XDG_CONFIG_HOME", "") + for _, v := range []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} { + t.Setenv(v, "") + } return h } diff --git a/cmd/krino/explain.go b/cmd/krino/explain.go new file mode 100644 index 0000000..a4d0253 --- /dev/null +++ b/cmd/krino/explain.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + + "krino/internal/cond" + "krino/internal/engine" + "krino/internal/xdg" +) + +func init() { commands["explain"] = cmdExplain } + +// cmdExplain evaluates every rule of FILE's directory against it and shows +// each test's result. +func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int { + fs := flagSet("explain", g) + if code, ok := parse(fs, args, stdout, stderr); !ok { + return code + } + if fs.NArg() != 1 { + return usageError(stderr, "usage: krino explain FILE") + } + + e, errs := engine.Load(mainFile(g)) + if len(errs) > 0 { + printDiags(stderr, errs) + return 2 + } + + x, err := e.Explain(context.Background(), xdg.Expand(fs.Arg(0))) + if err != nil { + fmt.Fprintf(stderr, "krino: %v\n", err) + return 2 + } + + fmt.Fprintf(stdout, "%s (directory %s)\n", xdg.Abbrev(x.File.Path), x.Dir.Name) + if x.Skip != "" { + fmt.Fprintf(stdout, "krino would not look at this file: %s\n", x.Skip) + } + fmt.Fprintln(stdout) + for _, rt := range x.Rules { + if rt.Stopped != "" { + fmt.Fprintf(stdout, "rule %s: not evaluated, %s\n", rt.Rule.Name, rt.Stopped) + continue + } + status := "no" + if rt.Match { + status = "MATCH" + } + fmt.Fprintf(stdout, "rule %s: %s\n", rt.Rule.Name, status) + printTrace(stdout, rt.Trace) + } + return 0 +} + +// printTrace writes t's Format output with every line prefixed by two +// spaces. +func printTrace(w io.Writer, t *cond.Trace) { + var buf bytes.Buffer + t.Format(&buf) + for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") { + fmt.Fprintf(w, " %s\n", line) + } +} diff --git a/cmd/krino/main.go b/cmd/krino/main.go index 0048098..9618f6c 100644 --- a/cmd/krino/main.go +++ b/cmd/krino/main.go @@ -97,9 +97,3 @@ func parse(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) (int, bool return 2, false } } - -// cmdSort plans and applies the included directories. It arrives in plan 3. -func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int { - fmt.Fprintln(stderr, "krino: sorting is not implemented yet; try 'krino check'") - return 2 -} diff --git a/cmd/krino/main_test.go b/cmd/krino/main_test.go index 1caab6b..46ceb44 100644 --- a/cmd/krino/main_test.go +++ b/cmd/krino/main_test.go @@ -52,6 +52,7 @@ func TestCommandsAreReserved(t *testing.T) { } func TestSortNotYet(t *testing.T) { + home(t) code, _, errOut := runCLI(t) if code != 2 || !strings.Contains(errOut, "not implemented yet") { t.Fatalf("got %d %q", code, errOut) diff --git a/cmd/krino/matching_test.go b/cmd/krino/matching_test.go new file mode 100644 index 0000000..eaf9494 --- /dev/null +++ b/cmd/krino/matching_test.go @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const dlRules = ` +(path "~/dl") +(recursive yes) +(min-age 0s) +(ignore "*.part") +(rule "dups" (when (duplicate)) (delete) (stop)) +(rule "acme" (when (type document) (content "acme ltd")) (move "Work/Acme") (stop)) +(rule "images" (when (type image)) (move "Pictures")) +(rule "rest" (when (not (matched)) (type text)) (move "Other")) +` + +// matchingFixture creates ~/dl, a config for it, and an empty PATH, so no +// extraction tool exists. +func matchingFixture(t *testing.T) string { + t.Helper() + h := home(t) + t.Setenv("PATH", t.TempDir()) + dl := filepath.Join(h, "dl") + files := map[string]string{ + "inv1.txt": "Invoice from ACME LTD", "notes.txt": "shopping list", + "photo.jpg": "\xff\xd8 jpeg", "report.pdf": "%PDF same", "report (1).pdf": "%PDF same", + "brochure.doc": "\xd0\xcf doc", "movie.mkv": "v", "movie.mkv.part": "p", + } + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for n, b := range files { + p := filepath.Join(dl, n) + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte(b), 0o644) + os.Chtimes(p, old, old) + } + os.Chtimes(filepath.Join(dl, "report (1).pdf"), old.Add(time.Hour), old.Add(time.Hour)) + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(dlRules), 0o644); err != nil { + t.Fatal(err) + } + return h +} + +func TestDryRun(t *testing.T) { + matchingFixture(t) + code, out, errOut := runCLI(t, "-n") + if code != 0 { + 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", + "\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", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } +} + +func TestDryRunVerbose(t *testing.T) { + matchingFixture(t) + _, out, _ := runCLI(t, "-n", "-v") + for _, want := range []string{ + "not matched: 2 · ignored: 1 · busy: 1\n", + "\nnot matched\n brochure.doc\n report.pdf\n", + "\nskipped\n movie.mkv busy\n movie.mkv.part ignored\n", + } { + if !strings.Contains(out, want) { + t.Errorf("verbose output lacks %q:\n%s", want, out) + } + } +} + +func TestExplainCommand(t *testing.T) { + h := matchingFixture(t) + code, out, errOut := runCLI(t, "explain", filepath.Join(h, "dl", "inv1.txt")) + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + for _, want := range []string{ + "~/dl/inv1.txt (directory dl)\n", + "rule dups: no\n no duplicate\n", + "rule acme: MATCH\n yes and\n yes type document\n yes content \"acme ltd\"\n", + "rule images: not evaluated, stopped by rule acme\n", + } { + if !strings.Contains(out, want) { + t.Errorf("explain output lacks %q:\n%s", want, out) + } + } + _, out, _ = runCLI(t, "explain", "~/dl/movie.mkv") + if !strings.Contains(out, "krino would not look at this file: busy\n") { + t.Errorf("busy file:\n%s", out) + } + if code, _, errOut := runCLI(t, "explain"); code != 2 || !strings.Contains(errOut, "usage: krino explain FILE") { + t.Errorf("no argument: %d %q", code, errOut) + } +} + +func TestSortFlags(t *testing.T) { + matchingFixture(t) + tests := []struct { + args []string + want string + }{ + {[]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"}, + } + for _, tt := range tests { + if code, _, errOut := runCLI(t, tt.args...); code != 2 || !strings.Contains(errOut, tt.want) { + t.Errorf("%v: %d %q, want %q", tt.args, code, errOut, tt.want) + } + } +} + +func TestCheckListsExtractors(t *testing.T) { + h := matchingFixture(t) + bin := filepath.Join(h, "bin") + os.Mkdir(bin, 0o755) + os.WriteFile(filepath.Join(bin, "pdftotext"), []byte("#!/bin/sh\n"), 0o755) + t.Setenv("PATH", bin) + code, out, errOut := runCLI(t, "check") + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + for _, want := range []string{"log: ~/.local/state/krino/krino.log\n", "\nextractors:\n pdftotext ~/bin/pdftotext\n antiword not installed\n"} { + if !strings.Contains(out, want) { + t.Errorf("check output lacks %q:\n%s", want, out) + } + } + os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(`(path "~/dl") (rule "x" (when (type "pdf")) (stop))`), 0o644) + if code, _, errOut := runCLI(t, "check"); code != 2 || !strings.Contains(errOut, `dl.conf:1:37: type names are bare words: write (type pdf)`) { + t.Errorf("condition error: %d %q", code, errOut) + } +} + +// TestDryRunWarningsSortedByRel is controller ruling 2026-09-12: the +// warnings section is one Rel-sorted list across matched and unmatched +// files, not matched files followed by unmatched files - a reader scans it +// by name and has no way to see which group a file fell into. "cover.pdf" +// matches "pdfs" but still carries the warning "acme" recorded before it +// gave up; "brochure.doc" never matches at all. Their Rel order +// ("brochure.doc" < "cover.pdf") is the reverse of matched-then-unmatched +// grouping, so a grouped rendering fails this. +func TestDryRunWarningsSortedByRel(t *testing.T) { + h := home(t) + t.Setenv("PATH", t.TempDir()) + dl := filepath.Join(h, "dl") + files := map[string]string{ + "brochure.doc": "\xd0\xcf doc", + "cover.pdf": "%PDF fake", + } + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for n, b := range files { + p := filepath.Join(dl, n) + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte(b), 0o644) + os.Chtimes(p, old, old) + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + conf := ` +(path "~/dl") +(recursive yes) +(min-age 0s) +(rule "acme" (when (content "acme ltd")) (move "Work/Acme")) +(rule "pdfs" (when (type pdf)) (move "PDFs")) +` + if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil { + t.Fatal(err) + } + + code, out, errOut := runCLI(t, "-n") + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + want := "\nwarnings\n brochure.doc acme: content unreadable: needs antiword or catdoc, not installed\n cover.pdf acme: content unreadable: needs pdftotext, not installed\n" + if !strings.Contains(out, want) { + t.Errorf("warnings not Rel-sorted across matched and unmatched:\n%s\nwant substring:\n%s", out, want) + } +} + +// TestDirectoryWarningAfterHeader: C3. A directory-level warning must be +// emitted after its own header line, not before it, so on a terminal (both +// streams sharing one tty, hence stdout and stderr driven into the same +// buffer here to observe their relative order) it reads as describing the +// directory just named instead of floating above it. +func TestDirectoryWarningAfterHeader(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + if err := os.MkdirAll(dl, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dl, "only.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + conf := ` +(path "~/dl") +(recursive yes) +(min-age 0s) +(rule "r" (when (duplicate "~/missing")) (stop)) +` + if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if code := run([]string{"-n"}, &buf, &buf); code != 0 { + t.Fatalf("exit %d: %s", code, buf.String()) + } + out := buf.String() + header := strings.Index(out, "krino: dl ~/dl\n") + warning := strings.Index(out, "krino: dl: duplicate: ") + if header == -1 || warning == -1 || warning < header { + t.Fatalf("directory-level warning not after its header line:\n%s", out) + } +} + +// TestSortSkipsMissingRootAndContinues: C5, the exit-1 skip path. A +// directory whose root has vanished since it was configured is skipped +// with one line on stderr naming it, but every other directory is still +// processed, with a blank line still separating their two outputs, and +// the run as a whole exits 1. +func TestSortSkipsMissingRootAndContinues(t *testing.T) { + h := home(t) + a, b, gone := filepath.Join(h, "a"), filepath.Join(h, "b"), filepath.Join(h, "gone") + for _, p := range []string{a, b, gone} { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + for _, tt := range []struct{ name, path string }{{"a", a}, {"gone", gone}, {"b", b}} { + if code, _, errOut := runCLI(t, "new", tt.name, tt.path); code != 0 { + t.Fatal(errOut) + } + } + if err := os.RemoveAll(gone); err != nil { + t.Fatal(err) + } + + code, out, errOut := runCLI(t, "-n") + if code != 1 { + t.Fatalf("exit = %d, want 1", code) + } + if want := "krino: skipping gone: ~/gone is not a directory\n"; errOut != want { + t.Fatalf("stderr = %q, want %q", errOut, want) + } + ai := strings.Index(out, "krino: a ~/a\n") + bi := strings.Index(out, "\n\nkrino: b ~/b\n") + if ai == -1 || bi == -1 || bi < ai { + t.Fatalf("directories a and b not both processed with a blank line between them:\n%s", out) + } +} + +// TestDirectoryWarningNotCountedInWarningsField: C5. A directory-level +// warning is printed as "krino: NAME: " on stderr, but is not one +// of the per-file warnings the "N warnings" field in the summary line +// counts. +func TestDirectoryWarningNotCountedInWarningsField(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + if err := os.MkdirAll(dl, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dl, "only.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + conf := ` +(path "~/dl") +(recursive yes) +(min-age 0s) +(rule "r" (when (duplicate "~/missing")) (stop)) +` + if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil { + t.Fatal(err) + } + + code, out, errOut := runCLI(t, "-n") + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + if want := "krino: dl: duplicate: "; !strings.Contains(errOut, want) { + t.Fatalf("stderr lacks the directory-level warning %q:\n%s", want, errOut) + } + if want := "0 warnings"; !strings.Contains(out, want) { + t.Fatalf("summary line should not count the directory-level warning:\n%s", out) + } +} + +// TestLongNameNotPaddedLayoutIntact: C5, the 40-character cap. A file name +// longer than the 40-character column cap is left unpadded (not truncated, +// not stretched further), while a short name alongside it is still padded +// out to the full 40-column cap — the layout stays a clean two-column grid +// even though one row's first cell overruns it. +func TestLongNameNotPaddedLayoutIntact(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + long := strings.Repeat("a", 42) + ".txt" // 46 runes: past the 40-column cap + short := "b.txt" + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for _, name := range []string{long, short} { + p := filepath.Join(dl, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + conf := ` +(path "~/dl") +(min-age 0s) +(rule "r" (when (type txt)) (stop)) +` + if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil { + t.Fatal(err) + } + + code, out, errOut := runCLI(t, "-n") + 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 " + short + strings.Repeat(" ", 40-len(short)) + " 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/sort.go b/cmd/krino/sort.go new file mode 100644 index 0000000..fe88cf8 --- /dev/null +++ b/cmd/krino/sort.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "context" + "fmt" + "io" + "os" + "sort" + "strings" + "unicode/utf8" + + "krino/internal/engine" + "krino/internal/scan" + "krino/internal/xdg" +) + +// cmdSort plans and applies the included directories. Only -n (dry run) is +// implemented; applying arrives in plan 3. +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 { + fmt.Fprintln(stderr, "krino: --json is not implemented yet") + return 2 + } + if !g.dry { + fmt.Fprintln(stderr, "krino: applying files is not implemented yet; use -n to see what would happen") + return 2 + } + + e, errs := engine.Load(mainFile(g), names...) + if len(errs) > 0 { + printDiags(stderr, errs) + return 2 + } + + exit := 0 + printed := false + 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) + if err != nil { + fmt.Fprintf(stderr, "krino: skipping %s: %v\n", d.Name, err) + exit = 1 + continue + } + if printed { + fmt.Fprintln(stdout) + } + 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 { + 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) + } + } +} + +// 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, ", ") + } + fmt.Fprintf(w, " %s %s\n", padCell(fm.File.Rel, width), strings.Join(parts, "; ")) + } +} + +// warnLine is one file's warning, for the warnings section. +type warnLine struct { + rel string + text string +} + +// collectWarnings gathers every file's warnings into a single list sorted +// by Rel across matched and unmatched files alike: a reader scans this +// 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 { + 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 }) + + var out []warnLine + for _, fm := range files { + for _, w := range fm.Warnings { + out = append(out, warnLine{fm.File.Rel, w}) + } + } + return out +} + +// printWarnings lists one line per warning, Rel padded to the widest shown +// (capped at 40). +func printWarnings(w io.Writer, lines []warnLine) { + rels := make([]string, len(lines)) + for i, l := range lines { + rels[i] = l.rel + } + width := relWidth(rels) + for _, l := range lines { + fmt.Fprintf(w, " %s %s\n", padCell(l.rel, width), l.text) + } +} + +// printSkipped lists each skipped file, Rel padded to the widest shown +// (capped at 40), then its reason. +func printSkipped(w io.Writer, skipped []scan.Skipped) { + rels := make([]string, len(skipped)) + for i, s := range skipped { + rels[i] = s.Rel + } + width := relWidth(rels) + for _, s := range skipped { + fmt.Fprintf(w, " %s %s\n", padCell(s.Rel, width), s.Reason.String()) + } +} + +// skipReasonOrder is the order the last line reports skip reasons in, +// after "not matched". +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 { + 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)) + } + } + if len(parts) == 0 { + return "" + } + line := strings.Join(parts, " · ") + if !verbose { + line += " (-v lists them)" + } + return line +} + +// 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. +func relWidth(rels []string) int { + w := 0 + for _, s := range rels { + if n := utf8.RuneCountInString(s); n > w { + w = n + } + } + if w > 40 { + w = 40 + } + return w +} + +// padCell pads s to width w (runes, not bytes) with trailing spaces; s +// already at or beyond w is left unpadded. +func padCell(s string, w int) string { + n := utf8.RuneCountInString(s) + if n >= w { + return s + } + return s + strings.Repeat(" ", w-n) +} diff --git a/cmd/krino/sort_test.go b/cmd/krino/sort_test.go new file mode 100644 index 0000000..a646503 --- /dev/null +++ b/cmd/krino/sort_test.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import "testing" + +// TestRelWidthAndPadCellCountRunes: C4. relWidth and padCell must measure +// column width in runes, not bytes, or a name carrying diacritics +// misaligns its column - "próba.txt" is 9 runes but 10 bytes (ó is a +// two-byte UTF-8 sequence), one column narrower than its byte length +// would suggest. +func TestRelWidthAndPadCellCountRunes(t *testing.T) { + rels := []string{"a.txt", "próba.txt"} + if w := relWidth(rels); w != 9 { + t.Fatalf("relWidth(%q) = %d, want 9 (rune count of próba.txt, not its %d bytes)", rels, w, len("próba.txt")) + } + if got, want := padCell("a.txt", 9), "a.txt "; got != want { + t.Fatalf("padCell(%q, 9) = %q, want %q", "a.txt", got, want) + } + if got, want := padCell("próba.txt", 9), "próba.txt"; got != want { + t.Fatalf("padCell(%q, 9) = %q, want %q (already at width: no padding)", "próba.txt", got, want) + } +} -- cgit v1.3