diff options
Diffstat (limited to 'internal/engine/match_test.go')
| -rw-r--r-- | internal/engine/match_test.go | 367 |
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) + } +} |
