diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
| commit | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch) | |
| tree | ecbb277ff916b719f2ee45fba017792b85d5faf9 /internal/cond/compile_test.go | |
| parent | 42b02c47be9b285099203e44a2570636d4ca6f03 (diff) | |
| download | krino-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.go | 150 |
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) + } + } +} |
