1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
|
// 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/<name>.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 }
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)
}
}
}
// 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 (plan 12).
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"},
} {
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)
}
}
|