summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
commit3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch)
treeecbb277ff916b719f2ee45fba017792b85d5faf9 /cmd
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'cmd')
-rw-r--r--cmd/krino/check.go38
-rw-r--r--cmd/krino/commands_test.go6
-rw-r--r--cmd/krino/explain.go70
-rw-r--r--cmd/krino/main.go6
-rw-r--r--cmd/krino/main_test.go1
-rw-r--r--cmd/krino/matching_test.go374
-rw-r--r--cmd/krino/sort.go252
-rw-r--r--cmd/krino/sort_test.go23
8 files changed, 749 insertions, 21 deletions
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: <warning>" 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)
+ }
+}