aboutsummaryrefslogtreecommitdiff
path: root/internal/cond/compile_test.go
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 /internal/cond/compile_test.go
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'internal/cond/compile_test.go')
-rw-r--r--internal/cond/compile_test.go150
1 files changed, 150 insertions, 0 deletions
diff --git a/internal/cond/compile_test.go b/internal/cond/compile_test.go
new file mode 100644
index 0000000..e68a4d8
--- /dev/null
+++ b/internal/cond/compile_test.go
@@ -0,0 +1,150 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package cond
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "krino/internal/sexp"
+)
+
+func nodes(t *testing.T, src string) []*sexp.Node {
+ t.Helper()
+ n, err := sexp.Parse("d.conf", []byte(src))
+ if err != nil {
+ t.Fatalf("parse %q: %v", src, err)
+ }
+ return n
+}
+
+func TestCompileGood(t *testing.T) {
+ for _, src := range []string{
+ ``,
+ `(type pdf)`,
+ `(type document image tar.gz)`,
+ `(and (type pdf) (or (content "acme ltd" "0000000000") (name "\bacme\b")) (not (name "^draft")))`,
+ `(path "^Work/") (size >= 10M) (age < 2w) (matched)`,
+ `(duplicate) (duplicate "Work" "~/docs")`,
+ `(not (not (type pdf)))`,
+ } {
+ if _, errs := Compile("d.conf", nodes(t, src), Options{IgnoreCase: true, Fold: true}); len(errs) > 0 {
+ t.Errorf("%s: %v", src, errs)
+ }
+ }
+}
+
+func TestCompileErrors(t *testing.T) {
+ tests := []struct{ src, want string }{
+ {`pdf`, `d.conf:1:1: a condition is a form like (type pdf), not pdf`},
+ {`("type" pdf)`, `d.conf:1:1: a condition is a form like (type pdf), not ("type" pdf)`},
+ {`(foo 1)`, `d.conf:1:1: unknown test (foo ...); tests are type, name, path, content, size, age, duplicate, matched, and, or, not`},
+ {`(and)`, `d.conf:1:1: and needs at least one condition`},
+ {`(or)`, `d.conf:1:1: or needs at least one condition`},
+ {`(not)`, `d.conf:1:1: not takes exactly one condition`},
+ {`(not (type pdf) (type doc))`, `d.conf:1:1: not takes exactly one condition`},
+ {`(type)`, `d.conf:1:1: type needs at least one extension or group, like (type pdf)`},
+ {`(type "pdf")`, `d.conf:1:7: type names are bare words: write (type pdf)`},
+ {`(name)`, `d.conf:1:1: name needs at least one regex in quotes`},
+ {`(path)`, `d.conf:1:1: path needs at least one regex in quotes`},
+ {`(name x)`, `d.conf:1:7: name takes regexes in quotes: write (name "x")`},
+ {`(name "(abc")`, `d.conf:1:7: name: bad regex "(abc": missing closing )`},
+ {`(path "[z-a]")`, `d.conf:1:7: path: bad regex "[z-a]": invalid character class range`},
+ {`(content)`, `d.conf:1:1: content needs at least one keyword in quotes`},
+ {`(content acme)`, `d.conf:1:10: content takes keywords in quotes: write (content "acme")`},
+ {`(content " ")`, `d.conf:1:10: content keyword is empty`},
+ {`(size 10M)`, `d.conf:1:1: size takes an operator and a size, like (size > 10M)`},
+ {`(age 30d)`, `d.conf:1:1: age takes an operator and a duration, like (age > 30d)`},
+ {`(size >> 10M)`, `d.conf:1:7: size operator is one of > >= < <= =, not >>`},
+ {`(size > 10Q)`, `d.conf:1:9: size: bad size "10Q": want a whole number with an optional K, M, G or T, like 50M`},
+ {`(age > 30)`, `d.conf:1:8: age: bad duration "30": want a whole number followed by s, m, h, d or w, like 30d`},
+ {`(duplicate Work)`, `d.conf:1:12: duplicate takes directories in quotes: write (duplicate "Work")`},
+ {`(matched x)`, `d.conf:1:1: matched takes nothing: write (matched)`},
+ }
+ for _, tt := range tests {
+ _, errs := Compile("d.conf", nodes(t, tt.src), Options{IgnoreCase: true})
+ if len(errs) != 1 || errs[0].Error() != tt.want {
+ t.Errorf("%s:\n got %v\n want %s", tt.src, errs, tt.want)
+ }
+ }
+}
+
+func TestCompileCollectsAllErrors(t *testing.T) {
+ _, errs := Compile("d.conf", nodes(t, `(type "a") (size 1) (matched x)`), Options{})
+ if len(errs) != 3 {
+ t.Fatalf("got %d errors: %v", len(errs), errs)
+ }
+}
+
+func TestDepthLimit(t *testing.T) {
+ deep := func(n int) string { return strings.Repeat("(not ", n) + "(type pdf)" + strings.Repeat(")", n) }
+ if _, errs := Compile("d.conf", nodes(t, deep(63)), Options{}); len(errs) != 0 {
+ t.Fatalf("64 levels rejected: %v", errs)
+ }
+ _, errs := Compile("d.conf", nodes(t, deep(64)), Options{})
+ if len(errs) != 1 || !strings.HasSuffix(errs[0].Error(), "conditions nest deeper than 64 levels") {
+ t.Fatalf("65 levels: %v", errs)
+ }
+}
+
+func TestFlagsAndDupDirs(t *testing.T) {
+ c, _ := Compile("d.conf", nodes(t, `(or (type pdf) (content "x")) (duplicate) (duplicate "Work" "~/docs")`), Options{})
+ if !c.UsesContent {
+ t.Error("UsesContent not set")
+ }
+ if want := [][]string{{}, {"Work", "~/docs"}}; !reflect.DeepEqual(c.DupDirs, want) {
+ t.Errorf("DupDirs = %#v, want %#v", c.DupDirs, want)
+ }
+ c, _ = Compile("d.conf", nodes(t, `(type pdf)`), Options{})
+ if c.UsesContent || len(c.DupDirs) != 0 {
+ t.Errorf("flags set without content/duplicate tests: %+v", c)
+ }
+}
+
+func TestCompileErrorReturnsNilCond(t *testing.T) {
+ c, errs := Compile("d.conf", nodes(t, `(type "pdf")`), Options{})
+ if len(errs) != 1 || c != nil {
+ t.Fatalf("got c=%v errs=%v, want c=nil and exactly one error", c, errs)
+ }
+}
+
+// TestEmptyChildrenGuard is a regression test for combine's empty-children
+// guard: an and/or all of whose children failed to compile must not become
+// a hollow node that evaluates vacuously (and -> true). Without the guard,
+// the inner (and (name "[")) would compile to an empty and, evaluate to
+// true, and the outer or would match on any file.
+func TestEmptyChildrenGuard(t *testing.T) {
+ c, errs := compile("d.conf", nodes(t, `(or (type pdf) (and (name "[")))`), Options{}, true)
+ if len(errs) != 1 {
+ t.Fatalf("got %d errors, want 1: %v", len(errs), errs)
+ }
+ if c.Eval(&fake{name: "x.txt"}).Match {
+ t.Error("hollow and inside or vacuously matched")
+ }
+}
+
+func TestGroupsMatchSpec(t *testing.T) {
+ want := map[string]string{
+ "image": "jpg jpeg png gif webp bmp tif tiff heic heif avif svg ico raw cr2 nef arw dng",
+ "video": "mp4 mkv webm mov avi m4v mpg mpeg wmv flv 3gp",
+ "audio": "mp3 flac ogg opus m4a aac wav wma aiff",
+ "archive": "zip tar gz tgz bz2 tbz2 xz txz zst 7z rar lz lzma cpio",
+ "document": "pdf doc docx odt rtf txt md tex",
+ "spreadsheet": "xls xlsx ods csv tsv",
+ "presentation": "ppt pptx odp",
+ "ebook": "epub mobi azw azw3 fb2 djvu",
+ "code": "go c h cpp hpp py sh js ts rs java rb pl lua html css json yaml yml toml xml sql",
+ "text": "txt md log csv tsv json yaml yml toml xml ini conf",
+ "package": "deb rpm apk appimage exe msi flatpak snap",
+ "font": "ttf otf woff woff2",
+ }
+ if len(groups) != len(want) {
+ t.Fatalf("%d groups, want %d", len(groups), len(want))
+ }
+ for g, exts := range want {
+ if got := strings.Join(groups[g], " "); got != exts {
+ t.Errorf("group %s = %q, want %q", g, got, exts)
+ }
+ }
+}