// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "os" "path/filepath" "reflect" "strings" "testing" "time" "git.labunix.xyz/krino/internal/cond" "git.labunix.xyz/krino/internal/config" "git.labunix.xyz/krino/internal/norm" ) // 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) ContentContains(cond.Options, []string) (int, error) { return -1, nil } func (f fakeFacts) Duplicate([]string) (string, bool, error) { return "", false, nil } func (f fakeFacts) Matched() (bool, bool) { return false, false } func (f fakeFacts) Folded(subj string) norm.Folded { return norm.FoldMapped(subj) } 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). This uses the 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) } } } // TestContentKeywordsComputedAtLoad: Load collects every content keyword // of a directory's rules, once per (options, normalised keyword), sorted by // key: a rule-level (case ignore) makes the same word a second keyword. func TestContentKeywordsComputedAtLoad(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].ContentKeywords strict, loose := cond.Options{IgnoreCase: false, Fold: true}, cond.Options{IgnoreCase: true, Fold: true} want := []cond.Keyword{{Opt: loose, Norm: "acme"}, {Opt: strict, Norm: "acme"}, {Opt: strict, Norm: "other"}} if !reflect.DeepEqual(got, want) { t.Fatalf("ContentKeywords = %+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) } } } // TestLoadRefusesBadPlaceholders: a copy or move destination or a rename // name whose placeholders could never expand is a config error at load, so // krino check reports it at the action's position instead of a run skipping // the step. func TestLoadRefusesBadPlaceholders(t *testing.T) { for _, c := range []struct { action, want string }{ {`(move "Out/{foo}")`, "unknown placeholder {foo}"}, {`(move "Out/{mtime}")`, "unknown placeholder {mtime}"}, {`(move "Out/{now}")`, "unknown placeholder {now}"}, {`(move "Out/{mtime:%B}")`, "%B"}, {`(rename "{0}-x")`, "numbered from 1"}, {`(copy "Out/{10}")`, "unknown placeholder {10}"}, {`(move "Out/{name")`, "unclosed placeholder"}, {`(move "Out/{mtime:}")`, "needs a format"}, {`(move "Out/{now:}")`, "needs a format"}, } { h := sandbox(t) os.MkdirAll(filepath.Join(h, "dl"), 0o755) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (when (name \"^(a)\")) " + c.action + ")\n"}) _, errs := Load(main) found := false for _, e := range errs { if strings.Contains(e.Error(), c.want) && strings.Contains(e.Error(), "dl.conf:2:") { found = true } } if !found { t.Errorf("%s: errors %v; want %q at line 2", c.action, errs, c.want) } } h := sandbox(t) os.MkdirAll(filepath.Join(h, "dl"), 0o755) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (when (name \"^(a)\")) (rename \"{1}-{stem}{ext}\") (move \"Out/{mtime:%Y/%m}/{{x}}/{now:%j}\"))\n"}) if _, errs := Load(main); len(errs) > 0 { t.Errorf("valid placeholders refused: %v", errs) } } // TestEngineLoadWith: the engine compiles overridden text too, so unsaved // rules are checked exactly as a run would read them (GUI design §1.3). func TestEngineLoadWith(t *testing.T) { h := sandbox(t) os.MkdirAll(filepath.Join(h, "dl"), 0o755) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": "(path \"~/dl\")\n"}) over := map[string][]byte{config.DirFile(main, "dl"): []byte("(path \"~/dl\")\n(rule \"r\" (when (bogus)) (move \"Out\"))\n")} if _, errs := LoadWith(main, over); len(errs) == 0 { t.Error("a mistake in the overridden directory file was not reported") } good := map[string][]byte{config.DirFile(main, "dl"): []byte("(path \"~/dl\")\n(rule \"r\" (move \"Out\"))\n")} e, errs := LoadWith(main, good) if len(errs) > 0 || len(e.Dirs) != 1 || len(e.Dirs[0].Rules) != 1 { t.Errorf("overridden rules not compiled: %v", errs) } }