// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "os" "path/filepath" "reflect" "strings" "testing" "time" "krino/internal/cond" ) // sandbox gives a test its own HOME with no XDG overrides and returns it. func sandbox(t *testing.T) string { t.Helper() h := t.TempDir() t.Setenv("HOME", h) for _, v := range []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} { t.Setenv(v, "") } return h } // writeConfig writes krino.conf and dirs/.conf files under home/.config/krino. func writeConfig(t *testing.T, home, main string, dirs map[string]string) string { t.Helper() cdir := filepath.Join(home, ".config", "krino") if err := os.MkdirAll(filepath.Join(cdir, "dirs"), 0o755); err != nil { t.Fatal(err) } mainFile := filepath.Join(cdir, "krino.conf") if err := os.WriteFile(mainFile, []byte(main), 0o644); err != nil { t.Fatal(err) } for n, body := range dirs { if err := os.WriteFile(filepath.Join(cdir, "dirs", n+".conf"), []byte(body), 0o644); err != nil { t.Fatal(err) } } return mainFile } // fakeFacts is a minimal cond.Facts for checking compiled rules. type fakeFacts struct{ name string } func (f fakeFacts) Name() string { return f.name } func (f fakeFacts) Rel() string { return f.name } func (f fakeFacts) Size() int64 { return 1 } func (f fakeFacts) ModTime() time.Time { return time.Time{} } func (f fakeFacts) Now() time.Time { return time.Time{} } func (f fakeFacts) Content(bool, bool) (string, error) { return "", nil } func (f fakeFacts) Duplicate([]string) (string, bool, error) { return "", false, nil } func (f fakeFacts) Matched() bool { return false } var _ cond.Facts = fakeFacts{} func TestLoadCompilesWithRuleSettings(t *testing.T) { h := sandbox(t) os.Mkdir(filepath.Join(h, "dl"), 0o755) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (case strict) (ignore "*.part") (rule "strict" (when (name "^img")) (stop)) (rule "loose" (case ignore) (when (name "^img")) (stop)) `}) e, errs := Load(main, "dl", "dl") if len(errs) > 0 { t.Fatal(errs) } if len(e.Dirs) != 1 { t.Fatalf("got %d dirs, want 1 (duplicate name ignored)", len(e.Dirs)) } d := e.Dirs[0] if d.Root != filepath.Join(h, "dl") || d.Ignore == nil || !d.Ignore.Match("x.part", false) { t.Fatalf("dir = %+v", d) } img := fakeFacts{name: "IMG_1.jpg"} if d.Rules[0].Cond.Eval(img).Match { t.Error("rule under (case strict) matched IMG against ^img") } if !d.Rules[1].Cond.Eval(img).Match { t.Error("rule-level (case ignore) did not apply at compile time") } } func TestLoadReportsEveryProblem(t *testing.T) { h := sandbox(t) main := writeConfig(t, h, `(include "a" "b")`, map[string]string{ "a": `(path "/tmp") (rule "x" (when (type "pdf")) (stop))`, "b": `(path "/tmp") (ignore "[abc") (rule "y" (when (size 1)) (stop))`, }) e, errs := Load(main) if e != nil { t.Fatal("engine returned despite errors") } joined := "" for _, d := range errs { joined += d.Error() + "\n" } for _, want := range []string{ "a.conf:1:37: type names are bare words: write (type pdf)", `b.conf: bad ignore pattern "[abc": unterminated [`, "b.conf:1:47: size takes an operator and a size, like (size > 10M)", } { if !strings.Contains(joined, want) { t.Errorf("missing %q in:\n%s", want, joined) } } } func TestCheck(t *testing.T) { h := sandbox(t) bin := t.TempDir() os.WriteFile(filepath.Join(bin, "pdftotext"), []byte("#!/bin/sh\n"), 0o755) t.Setenv("PATH", bin) os.Mkdir(filepath.Join(h, "here"), 0o755) main := writeConfig(t, h, `(include "here" "gone")`, map[string]string{ "here": `(path "~/here") (rule "r" (stop))`, "gone": `(path "~/gone") (rule "r" (stop))`, }) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } r := e.Check() if r.MainFile != main || r.LogFile != filepath.Join(h, ".local", "state", "krino", "krino.log") { t.Errorf("report files = %q %q", r.MainFile, r.LogFile) } if len(r.Dirs) != 2 || r.Dirs[0].Missing || !r.Dirs[1].Missing { t.Errorf("dirs = %+v", r.Dirs) } if r.Tools[0].Name != "pdftotext" || r.Tools[0].Path != filepath.Join(bin, "pdftotext") || r.Tools[1].Path != "" { t.Errorf("tools = %+v", r.Tools) } } // TestLoadRejectsUnsuppliedCaptures: a rule using {N} must be able to get it // from its own name tests (spec 7.3) — adapted from the brief to this // package's actual sandbox/writeConfig/Load helpers (writeConfig takes a // main file body and a dirs map; there is no separate engineLoad, Load is // called directly). func TestLoadRejectsUnsuppliedCaptures(t *testing.T) { tests := []struct{ rule, want string }{ {`(rule "a" (when (type pdf)) (move "Work/{1}"))`, `rule "a": {1} needs a name test to capture from`}, {`(rule "a" (when (name "inv-(\d+)")) (move "Work/{2}"))`, `rule "a": {2} but a name test has only 1 capture group`}, {`(rule "a" (when (or (name "x-(\d+)-(\d+)") (name "y-(\d+)"))) (rename "{2}"))`, `rule "a": {2} but a name test has only 1 capture group`}, } for _, tt := range tests { h := sandbox(t) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `(path "/tmp") ` + tt.rule}) _, errs := Load(main, "dl") joined := "" for _, d := range errs { joined += d.Error() + "\n" } if len(errs) != 1 || !strings.Contains(joined, tt.want) { t.Errorf("rule %s: errs %v, want %q", tt.rule, errs, tt.want) } } } // TestLoadAcceptsSuppliedCaptures: a rule whose name test has enough groups // for every {N} it uses loads clean. func TestLoadAcceptsSuppliedCaptures(t *testing.T) { for _, rule := range []string{ `(rule "a" (when (name "inv-(\d+)-(\d+)")) (move "Work/{2}/{1}"))`, // B1a: a name test reachable only under a (not ...) must not count // toward this rule's own captures (B1), but it must also not make // the rule itself invalid - the outer (name ...) alone already // supplies the two groups {2} needs. `(rule "a" (when (and (name "inv-(\d+)-(\d+)") (not (name "draft")))) (move "Work/{2}"))`, } { h := sandbox(t) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `(path "/tmp") ` + rule}) if _, errs := Load(main, "dl"); len(errs) != 0 { t.Errorf("rule %s: unexpected diagnostics: %v", rule, errs) } } } // TestContentVariantsComputedAtLoad: B2 plumbing. Load computes each // directory's distinct (ignoreCase, fold) content-test variants from its // rules' resolved settings: a rule with no content test contributes // nothing; two rules sharing a variant fold into one; a rule-level (case // ignore) override adds a second. func TestContentVariantsComputedAtLoad(t *testing.T) { h := sandbox(t) os.Mkdir(filepath.Join(h, "dl"), 0o755) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (case strict) (rule "no-content" (when (type pdf)) (stop)) (rule "strict-content" (when (content "acme")) (stop)) (rule "also-strict-content" (when (content "other")) (stop)) (rule "loose-content" (case ignore) (when (content "acme")) (stop)) `}) e, errs := Load(main, "dl") if len(errs) > 0 { t.Fatal(errs) } got := e.Dirs[0].ContentVariants want := []cond.Options{{IgnoreCase: false, Fold: true}, {IgnoreCase: true, Fold: true}} if !reflect.DeepEqual(got, want) { t.Fatalf("ContentVariants = %+v, want %+v", got, want) } } // TestLoadRefusesDuplicateWithDelete is spec §4.5: a (duplicate) test // anywhere in a rule's condition, and a delete action in the same rule, is // a load error, however the test is nested. func TestLoadRefusesDuplicateWithDelete(t *testing.T) { tests := []struct{ rule, want string }{ {`(rule "a" (when (duplicate)) (delete))`, `rule "a": (duplicate) cannot be combined with (delete): duplicates are never deleted, move them aside instead`}, {`(rule "a" (when (duplicate "Archive")) (delete permanent))`, `rule "a": (duplicate) cannot be combined with (delete permanent)`}, {`(rule "a" (when (or (type pdf) (duplicate))) (move "Keep") (delete))`, `rule "a": (duplicate) cannot be combined with (delete)`}, {`(rule "a" (when (not (duplicate))) (delete))`, `rule "a": (duplicate) cannot be combined with (delete)`}, } for _, tt := range tests { h := sandbox(t) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `(path "/tmp") ` + tt.rule}) _, errs := Load(main, "dl") joined := "" for _, d := range errs { joined += d.Error() + "\n" } if len(errs) != 1 || !strings.Contains(joined, tt.want) { t.Errorf("rule %s: errs %v, want %q", tt.rule, errs, tt.want) } } } // TestLoadAcceptsDuplicateWithMove: the way §5.5 recommends dealing with // duplicates loads clean, and so does a delete rule with no duplicate test. func TestLoadAcceptsDuplicateWithMove(t *testing.T) { for _, rule := range []string{ `(rule "dupes" (when (duplicate "Archive")) (move "~/.dupes/") (stop))`, `(rule "old" (when (type iso) (age > 90d)) (delete))`, } { h := sandbox(t) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": "(path \"/tmp\")\n" + rule}) if _, errs := Load(main, "dl"); len(errs) != 0 { t.Errorf("rule %s: errs %v, want none", rule, errs) } } }