aboutsummaryrefslogtreecommitdiff
path: root/internal/engine/match_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/engine/match_test.go')
-rw-r--r--internal/engine/match_test.go367
1 files changed, 367 insertions, 0 deletions
diff --git a/internal/engine/match_test.go b/internal/engine/match_test.go
new file mode 100644
index 0000000..1356074
--- /dev/null
+++ b/internal/engine/match_test.go
@@ -0,0 +1,367 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+const dlConf = `
+(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"))
+`
+
+// fixture builds ~/dl and returns a loaded engine. PATH is empty, so no
+// extraction tool exists and PDFs and .doc files are unreadable.
+func fixture(t *testing.T) (*Engine, *Dir, string) {
+ t.Helper()
+ h := sandbox(t)
+ t.Setenv("PATH", t.TempDir())
+ dl := filepath.Join(h, "dl")
+ files := map[string]string{
+ "inv1.txt": "Invoice from ACME LTD, tax 0000000000",
+ "notes.txt": "shopping list",
+ "photo.jpg": "\xff\xd8\xff\xe0 jpeg bytes",
+ "report.pdf": "%PDF same bytes",
+ "report (1).pdf": "%PDF same bytes",
+ "brochure.doc": "\xd0\xcf\x11\xe0 doc bytes",
+ "movie.mkv": "video",
+ "movie.mkv.part": "partial",
+ "Work/Acme/filed.txt": "acme ltd, already filed",
+ "Pictures/old.jpg": "\xff\xd8 old",
+ }
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for name, body := range files {
+ p := filepath.Join(dl, name)
+ os.MkdirAll(filepath.Dir(p), 0o755)
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ os.Chtimes(p, old, old)
+ }
+ newer := old.Add(time.Hour)
+ os.Chtimes(filepath.Join(dl, "report (1).pdf"), newer, newer)
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": dlConf})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ return e, e.Dirs[0], dl
+}
+
+// summary renders a result compactly for comparison.
+func summary(r *Result) []string {
+ var out []string
+ for _, m := range r.Matched {
+ var rs []string
+ for _, rm := range m.Rules {
+ rs = append(rs, rm.Rule.Name+"["+strings.Join(rm.Reasons, "; ")+"]")
+ }
+ out = append(out, "match "+m.File.Rel+" "+strings.Join(rs, " "))
+ }
+ for _, m := range r.Unmatched {
+ out = append(out, "none "+m.File.Rel+" "+strings.Join(m.Warnings, " | "))
+ }
+ for _, s := range r.Skipped {
+ out = append(out, "skip "+s.Rel+" "+s.Reason.String())
+ }
+ return out
+}
+
+func TestMatch(t *testing.T) {
+ e, d, _ := fixture(t)
+ r, err := e.Match(context.Background(), d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{
+ `match inv1.txt acme[type txt; content "acme ltd"]`,
+ `match notes.txt rest[not matched; type txt]`,
+ `match photo.jpg images[type jpg]`,
+ `match report (1).pdf dups[duplicate of report.pdf]`,
+ `none brochure.doc acme: content unreadable: needs antiword or catdoc, not installed`,
+ `none report.pdf acme: content unreadable: needs pdftotext, not installed`,
+ `skip movie.mkv busy`,
+ `skip movie.mkv.part ignored`,
+ }
+ if got := summary(r); !reflect.DeepEqual(got, want) {
+ t.Fatalf("got\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+ again, _ := e.Match(context.Background(), d)
+ if !reflect.DeepEqual(summary(again), summary(r)) {
+ t.Fatal("a second run gave a different result")
+ }
+}
+
+func TestMatchMissingRoot(t *testing.T) {
+ e, d, dl := fixture(t)
+ os.RemoveAll(dl)
+ if _, err := e.Match(context.Background(), d); err == nil {
+ t.Fatal("no error for a missing root")
+ }
+}
+
+func TestExplain(t *testing.T) {
+ e, _, dl := fixture(t)
+ x, err := e.Explain(context.Background(), filepath.Join(dl, "inv1.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, rt := range x.Rules {
+ got = append(got, fmt.Sprintf("%s match=%v stopped=%q trace=%v", rt.Rule.Name, rt.Match, rt.Stopped, rt.Trace != nil))
+ }
+ want := []string{
+ `dups match=false stopped="" trace=true`,
+ `acme match=true stopped="" trace=true`,
+ `images match=false stopped="stopped by rule acme" trace=false`,
+ `rest match=false stopped="stopped by rule acme" trace=false`,
+ }
+ if !reflect.DeepEqual(got, want) || x.Skip != "" {
+ t.Fatalf("skip=%q\n%s", x.Skip, strings.Join(got, "\n"))
+ }
+ for name, skip := range map[string]string{
+ "movie.mkv": "busy",
+ "movie.mkv.part": "ignored",
+ "Work/Acme/filed.txt": "inside a rule destination, which krino never scans",
+ } {
+ x, err := e.Explain(context.Background(), filepath.Join(dl, name))
+ if err != nil || x.Skip != skip {
+ t.Errorf("%s: skip %q, %v; want %q", name, x.Skip, err, skip)
+ }
+ }
+ if _, err := e.Explain(context.Background(), "/etc/hostname"); err == nil || !strings.HasSuffix(err.Error(), "is not inside any included directory") {
+ t.Errorf("outside: %v", err)
+ }
+}
+
+// TestMatchExcludesOnlyRuleDest checks that a rule destination with no
+// placeholder excludes exactly that directory, not its parent: a sibling
+// subdirectory of the destination's parent must still be scanned.
+func TestMatchExcludesOnlyRuleDest(t *testing.T) {
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ files := map[string]string{
+ "Work/Acme/filed.txt": "already filed",
+ "Work/Other/keep.txt": "keep me",
+ }
+ for name, body := range files {
+ p := filepath.Join(dl, name)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ conf := `
+(path "~/dl")
+(recursive yes)
+(min-age 0s)
+(rule "acme" (when (name "nope")) (move "Work/Acme"))
+`
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ r, err := e.Match(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ seen := map[string]bool{}
+ for _, m := range r.Matched {
+ seen[m.File.Rel] = true
+ }
+ for _, m := range r.Unmatched {
+ seen[m.File.Rel] = true
+ }
+ for _, s := range r.Skipped {
+ seen[s.Rel] = true
+ }
+ if !seen["Work/Other/keep.txt"] {
+ t.Error("Work/Other/keep.txt should have been scanned: only the rule's own destination (Work/Acme) may be excluded")
+ }
+ if seen["Work/Acme/filed.txt"] {
+ t.Error("Work/Acme/filed.txt should have been excluded as inside the rule's destination")
+ }
+}
+
+// TestMatchWarningsSorted checks that Result.Warnings is sorted, not in
+// whatever order concurrent workers happened to build the duplicate
+// indexes that failed: rule "b" (declared first, with a stop that must
+// not skip rule "a" since it never matches) names a missing directory
+// that sorts after rule "a"'s, and rule "c" names the very same missing
+// directory as rule "a" - which must fold into one warning, not two.
+func TestMatchWarningsSorted(t *testing.T) {
+ h := sandbox(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)
+ }
+ conf := `
+(path "~/dl")
+(recursive yes)
+(min-age 0s)
+(rule "b" (when (duplicate "~/zz-missing")) (stop))
+(rule "a" (when (duplicate "~/aa-missing")) (stop))
+(rule "c" (when (duplicate "~/aa-missing")) (stop))
+`
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ r, err := e.Match(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ aaDir := filepath.Join(h, "aa-missing")
+ zzDir := filepath.Join(h, "zz-missing")
+ if len(r.Warnings) != 2 {
+ t.Fatalf("got %d warnings, want 2 (the shared aa-missing dir should fold into one):\n%s", len(r.Warnings), strings.Join(r.Warnings, "\n"))
+ }
+ wantPrefix := []string{"duplicate: " + aaDir + ":", "duplicate: " + zzDir + ":"}
+ for i, want := range wantPrefix {
+ if !strings.HasPrefix(r.Warnings[i], want) {
+ t.Errorf("Warnings[%d] = %q, want prefix %q", i, r.Warnings[i], want)
+ }
+ }
+}
+
+// TestExcludeDirs is a table check of excludeDirs's rule-destination
+// handling, in rule order: a plain destination excludes itself exactly; a
+// destination with a placeholder excludes only the static part before it,
+// cut back to a full path component; a destination (after that cut) equal
+// to the root itself, or outside the root, excludes nothing; an absolute
+// destination inside the root excludes that directory; copy counts like
+// move; rename is not a destination at all.
+func TestExcludeDirs(t *testing.T) {
+ h := sandbox(t)
+ root := filepath.Join(h, "root")
+ absDest := filepath.Join(root, "AbsDest")
+ conf := `
+(path "` + root + `")
+(rule "r1" (move "Work/Acme"))
+(rule "r2" (move "Photos/{mtime:%Y}"))
+(rule "r3" (move "Work/Acme-{mtime:%Y}"))
+(rule "r4" (move "{ext}"))
+(rule "r5" (move "."))
+(rule "r6" (move "~/elsewhere"))
+(rule "r7" (move "` + absDest + `"))
+(rule "r8" (copy "Backup"))
+(rule "r9" (rename "x-{name}"))
+`
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ got := e.excludeDirs(e.Dirs[0])
+ want := []string{
+ filepath.Join(root, "Work", "Acme"), // r1: no placeholder, exact
+ filepath.Join(root, "Photos"), // r2: cut back to "Photos/"
+ filepath.Join(root, "Work"), // r3: cut back past "Acme-"
+ // r4 "{ext}": nothing before "{" at all -> resolves to the root
+ // itself -> not strictly inside it -> excludes nothing.
+ // r5 ".": no placeholder, resolves to the root itself -> nothing.
+ // r6 "~/elsewhere": outside the root -> nothing.
+ absDest, // r7: absolute, already inside root
+ filepath.Join(root, "Backup"), // r8: copy counts like move
+ // r9 rename "x-{name}": rename is never a destination.
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("excludeDirs =\n%v\nwant\n%v", got, want)
+ }
+}
+
+// TestMatchDrainsDupCandidateErrors: A1 plumbing across the dup/engine
+// boundary. Within one directory's own scan (no extra directories), a
+// candidate that cannot be hashed must not poison the duplicate answer for
+// its size-mates, and must surface exactly once in Result.Warnings, its
+// path abbreviated the way every other user-visible path is.
+func TestMatchDrainsDupCandidateErrors(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("permissions are not enforced running as root")
+ }
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ files := map[string]string{
+ "a.txt": "same content", // older: the original
+ "b.txt": "same content", // newer: reported as the duplicate
+ "c.txt": "diff content", // same size as a/b, different bytes
+ }
+ for name, body := range files {
+ p := filepath.Join(dl, name)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ os.Chtimes(filepath.Join(dl, "a.txt"), old, old)
+ os.Chtimes(filepath.Join(dl, "b.txt"), old.Add(time.Hour), old.Add(time.Hour))
+ os.Chtimes(filepath.Join(dl, "c.txt"), old, old)
+ cPath := filepath.Join(dl, "c.txt")
+ if err := os.Chmod(cPath, 0o000); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(cPath, 0o644) })
+
+ conf := `
+(path "~/dl")
+(recursive yes)
+(min-age 0s)
+(rule "dup" (when (duplicate)) (stop))
+`
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ r, err := e.Match(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var bReasons []string
+ for _, m := range r.Matched {
+ if m.File.Rel == "b.txt" {
+ for _, rm := range m.Rules {
+ bReasons = append(bReasons, rm.Reasons...)
+ }
+ }
+ }
+ if len(bReasons) == 0 || bReasons[0] != "duplicate of a.txt" {
+ t.Errorf("b.txt duplicate pair with a.txt broken by unreadable sibling c.txt: %v", summary(r))
+ }
+
+ want := "duplicate: ~/dl/c.txt: "
+ found := 0
+ for _, w := range r.Warnings {
+ if strings.HasPrefix(w, want) {
+ found++
+ }
+ }
+ if found != 1 {
+ t.Errorf("got %d warnings with prefix %q, want 1; warnings: %v", found, want, r.Warnings)
+ }
+}