// SPDX-License-Identifier: GPL-3.0-or-later package cond import ( "errors" "math/rand" "reflect" "strings" "testing" "time" "krino/internal/norm" ) var now = time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC) type fake struct { name, rel, raw string rawErr error size int64 age time.Duration matched bool dupOrig string dupOK bool contentCalls int } func (f *fake) Name() string { return f.name } func (f *fake) Rel() string { if f.rel != "" { return f.rel } return f.name } func (f *fake) Size() int64 { return f.size } func (f *fake) ModTime() time.Time { return now.Add(-f.age) } func (f *fake) Now() time.Time { return now } func (f *fake) Matched() bool { return f.matched } func (f *fake) ContentContains(opt Options, keywords []string) (int, error) { f.contentCalls++ if f.rawErr != nil { return -1, f.rawErr } text := norm.Text(f.raw, opt.IgnoreCase, opt.Fold) for i, kw := range keywords { if strings.Contains(text, kw) { return i, nil } } return -1, nil } func (f *fake) Duplicate(dirs []string) (string, bool, error) { return f.dupOrig, f.dupOK, nil } func eval(t *testing.T, src string, opt Options, f Facts) Result { t.Helper() c, errs := Compile("d.conf", nodes(t, src), opt) if len(errs) > 0 { t.Fatalf("%s: %v", src, errs) } return c.Eval(f) } func TestTruth(t *testing.T) { ic := Options{IgnoreCase: true} pdf := &fake{name: "Scan001.PDF", raw: "Invoice from ACME LTD", size: 20 << 20, age: 40 * 24 * time.Hour} tests := []struct { src string want bool }{ {``, true}, {`(type pdf)`, true}, {`(type document)`, true}, {`(type jpg)`, false}, {`(type pdf) (size > 10M)`, true}, {`(type pdf) (size < 10M)`, false}, {`(or (type jpg) (content "acme ltd"))`, true}, {`(not (content "acme ltd"))`, false}, {`(age > 30d) (age <= 41d)`, true}, {`(name "^scan\d+")`, true}, {`(name "(?-i)^scan")`, false}, {`(matched)`, false}, {`(and (type pdf) (or (name "^x") (not (name "^y"))))`, true}, } for _, tt := range tests { if got := eval(t, tt.src, ic, pdf); got.Match != tt.want { t.Errorf("%s = %v, want %v (%+v)", tt.src, got.Match, tt.want, got) } } } func TestCaseAndFold(t *testing.T) { f := &fake{name: "SPOLKA-umowa.pdf", raw: "Umowa: spółka z o.o."} if !eval(t, `(name "spółka")`, Options{IgnoreCase: true, Fold: true}, f).Match { t.Error("folded, case-ignoring name did not match") } if eval(t, `(name "spółka")`, Options{IgnoreCase: true, Fold: false}, f).Match { t.Error("matched without folding") } if !eval(t, `(content "SPOLKA Z O.O.")`, Options{IgnoreCase: true, Fold: true}, f).Match { t.Error("folded content did not match") } img := &fake{name: "IMG_0001.jpg"} if eval(t, `(name "^img")`, Options{IgnoreCase: false}, img).Match { t.Error("strict case matched") } if !eval(t, `(name "(?i)^img")`, Options{IgnoreCase: false}, img).Match { t.Error("inline (?i) did not override strict case") } } func TestReasonsAndCaptures(t *testing.T) { f := &fake{name: "Screenshot_20260911.png", raw: "acme ltd"} r := eval(t, `(type image) (name "^Screenshot_(\d{4})(\d{2})") (not (name "^x(y)"))`, Options{IgnoreCase: true}, f) if !r.Match { t.Fatal("no match") } if want := []string{"Screenshot_202609", "2026", "09"}; !reflect.DeepEqual(r.Captures, want) { t.Errorf("captures = %q, want %q", r.Captures, want) } if want := []string{`type png`, `name "^Screenshot_(\d{4})(\d{2})"`, `not name "^x(y)"`}; !reflect.DeepEqual(r.Reasons, want) { t.Errorf("reasons = %q, want %q", r.Reasons, want) } r = eval(t, `(or (content "nope" "acme ltd") (type png))`, Options{IgnoreCase: true}, f) if want := []string{`type png`}; !reflect.DeepEqual(r.Reasons, want) { t.Errorf("or reasons = %q, want %q (cheapest true child)", r.Reasons, want) } r = eval(t, `(content "nope" "acme ltd")`, Options{IgnoreCase: true}, f) if want := []string{`content "acme ltd"`}; !reflect.DeepEqual(r.Reasons, want) { t.Errorf("content reasons = %q, want %q", r.Reasons, want) } if r := eval(t, ``, Options{}, f); !reflect.DeepEqual(r.Reasons, []string{"no condition"}) { t.Errorf("empty reasons = %q", r.Reasons) } d := &fake{name: "report (1).pdf", dupOrig: "report.pdf", dupOK: true} if r := eval(t, `(duplicate)`, Options{}, d); !r.Match || r.Reasons[0] != "duplicate of report.pdf" { t.Errorf("duplicate = %+v", r) } } func TestCheapFirstAvoidsContent(t *testing.T) { f := &fake{name: "a.txt", raw: "x"} eval(t, `(and (content "x") (type pdf))`, Options{}, f) if f.contentCalls != 0 { t.Errorf("content read although type was false (%d calls)", f.contentCalls) } g := &fake{name: "a.pdf", raw: "x"} eval(t, `(or (content "x") (type pdf))`, Options{}, g) if g.contentCalls != 0 { t.Errorf("content read although type was true (%d calls)", g.contentCalls) } } func TestContentErrorWarns(t *testing.T) { f := &fake{name: "a.pdf", rawErr: errors.New("needs pdftotext, not installed")} r := eval(t, `(or (content "acme") (content "other"))`, Options{}, f) if r.Match { t.Fatal("matched unreadable content") } if want := []string{"content unreadable: needs pdftotext, not installed"}; !reflect.DeepEqual(r.Warnings, want) { t.Errorf("warnings = %q, want %q (de-duplicated)", r.Warnings, want) } } func TestExplainFormat(t *testing.T) { f := &fake{name: "scan.pdf", rawErr: errors.New("needs pdftotext, not installed")} c, _ := Compile("d.conf", nodes(t, `(type pdf) (or (content "acme ltd") (name "\bacme\b"))`), Options{IgnoreCase: true}) var b strings.Builder c.Explain(f).Format(&b) want := "no and\n" + "yes type pdf\n" + "no or\n" + "no name \"\\bacme\\b\"\n" + "no content \"acme ltd\" (content unreadable: needs pdftotext, not installed)\n" if b.String() != want { t.Fatalf("got\n%s\nwant\n%s", b.String(), want) } } // TestReorderKeepsMeaning: random trees give the same answer with and without // cost reordering. func TestReorderKeepsMeaning(t *testing.T) { leaves := []string{`(type pdf)`, `(type txt)`, `(name "^a")`, `(name "b$")`, `(size > 10)`, `(size < 5)`, `(age > 1d)`, `(content "x")`, `(content "y")`, `(matched)`} rng := rand.New(rand.NewSource(1)) var gen func(depth int) string gen = func(depth int) string { if depth == 0 || rng.Intn(3) == 0 { return leaves[rng.Intn(len(leaves))] } switch rng.Intn(3) { case 0: return "(not " + gen(depth-1) + ")" case 1: return "(and " + gen(depth-1) + " " + gen(depth-1) + ")" default: return "(or " + gen(depth-1) + " " + gen(depth-1) + " " + gen(depth-1) + ")" } } names := []string{"a.pdf", "b.txt", "ab.pdf", "c.jpg"} for i := 0; i < 500; i++ { src := gen(4) f := &fake{name: names[rng.Intn(len(names))], raw: []string{"x", "y", "xy", ""}[rng.Intn(4)], size: int64(rng.Intn(20)), age: time.Duration(rng.Intn(72)) * time.Hour, matched: rng.Intn(2) == 0} a, _ := compile("d.conf", nodes(t, src), Options{}, true) b, _ := compile("d.conf", nodes(t, src), Options{}, false) if ra, rb := a.Eval(f), b.Eval(f); ra.Match != rb.Match { t.Fatalf("%s on %+v: reordered %v, original %v", src, f, ra.Match, rb.Match) } } } // TestNegatedCombinatorReason: E4. Negating a combinator must read as // something a person would write ("not (and ...)" / "not (or ...)"), not // the bare "not and" / "not or"; a negated leaf keeps its own label // unchanged ("not matched"). func TestNegatedCombinatorReason(t *testing.T) { f := &fake{name: "a.pdf"} if r := eval(t, `(not (and (type pdf) (matched)))`, Options{}, f); !r.Match || r.Reasons[0] != "not (and ...)" { t.Errorf("negated and = %+v, want reason %q", r, "not (and ...)") } if r := eval(t, `(not (or (matched) (name "^zzz")))`, Options{}, f); !r.Match || r.Reasons[0] != "not (or ...)" { t.Errorf("negated or = %+v, want reason %q", r, "not (or ...)") } if r := eval(t, `(not (matched))`, Options{}, f); !r.Match || r.Reasons[0] != "not matched" { t.Errorf("negated leaf = %+v, want its own label unchanged: %q", r, "not matched") } } // TestEvalReportsUnreadableContent: Result.Unreadable says a content test // was reached and could not read the file - what lets an exclude fail // closed (review M11) - and stays false when evaluation never reached the // content test. func TestEvalReportsUnreadableContent(t *testing.T) { f := &fake{name: "a.pdf", rawErr: errors.New("larger than max-read")} if r := eval(t, `(and (type pdf) (content "x"))`, Options{}, f); r.Match || !r.Unreadable { t.Errorf("reached: Match %v Unreadable %v; want false, true", r.Match, r.Unreadable) } if r := eval(t, `(or (name "^a") (content "x"))`, Options{}, f); !r.Match || r.Unreadable { t.Errorf("not reached: Match %v Unreadable %v; want true, false", r.Match, r.Unreadable) } }