summaryrefslogtreecommitdiff
path: root/internal/engine
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 14:08:36 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 14:08:36 +0200
commit1d3f2d1e4c59867024470d3444e12698b7ebb22e (patch)
tree4fa14f455c72f9b206a680dcc1b0f4c1f3708158 /internal/engine
parent07c24054cab965800983ef40f53a05c2db131ede (diff)
downloadkrino-1d3f2d1e4c59867024470d3444e12698b7ebb22e.tar.gz
krino-1d3f2d1e4c59867024470d3444e12698b7ebb22e.zip
0.0.4: max-size, exclude forms, --min-agev0.0.4
Diffstat (limited to 'internal/engine')
-rw-r--r--internal/engine/engine.go47
-rw-r--r--internal/engine/exclude_test.go209
-rw-r--r--internal/engine/match.go49
3 files changed, 295 insertions, 10 deletions
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index b5f2106..8cffbd1 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -46,6 +46,11 @@ type Dir struct {
// stay memoised, as before.
ContentVariants []cond.Options
+ // Excludes are the (exclude ...) forms that apply here, compiled with the
+ // directory's settings: krino.conf's first, then the directory's own. A
+ // file any of them matches is set aside before any rule runs.
+ Excludes []*Exclude
+
// DupScopes is every distinct directory list the duplicate tests of
// Rules use, in first-seen order, the plain (duplicate) as an empty
// list. Spec §5.5 rule 2 looks a file up under each of them before any
@@ -53,6 +58,12 @@ type Dir struct {
DupScopes [][]string
}
+// Exclude is one compiled (exclude ...) form.
+type Exclude struct {
+ Text string // the form as written, for check, explain and the plan
+ Cond *cond.Cond
+}
+
// Rule is one directory's rule, with its condition compiled.
type Rule struct {
Name string
@@ -71,6 +82,9 @@ func Load(mainFile string, names ...string) (*Engine, []*config.Diag) {
}
var dirs []*Dir
+ // A mistake inside a krino.conf exclude is compiled once per directory,
+ // but reported once.
+ reported := map[string]bool{}
for _, d := range cfg.Dirs {
dir := &Dir{
Name: d.Name,
@@ -103,7 +117,25 @@ func Load(mainFile string, names ...string) (*Engine, []*config.Diag) {
}
dir.Rules = append(dir.Rules, &Rule{Name: r.Name, Conf: r, Settings: rs, Cond: c})
}
- dir.ContentVariants = contentVariants(dir.Rules)
+ dirOpt := cond.Options{IgnoreCase: dir.Settings.Case == config.CaseIgnore, Fold: dir.Settings.Fold}
+ for _, src := range []struct {
+ file string
+ excludes []*config.Exclude
+ }{{cfg.Main.File, cfg.Main.Excludes}, {d.File, d.Excludes}} {
+ for _, x := range src.excludes {
+ c, cerrs := cond.Compile(src.file, x.When, dirOpt)
+ for _, ce := range cerrs {
+ if key := ce.Error(); !reported[key] {
+ reported[key] = true
+ errs = append(errs, ce)
+ }
+ }
+ if len(cerrs) == 0 {
+ dir.Excludes = append(dir.Excludes, &Exclude{Text: x.Text, Cond: c})
+ }
+ }
+ }
+ dir.ContentVariants = contentVariants(dir.Rules, dir.Excludes, dirOpt)
dir.DupScopes = dupScopes(dir.Rules)
dirs = append(dirs, dir)
}
@@ -184,14 +216,21 @@ func dedupeNames(names []string) []string {
return out
}
-// contentVariants returns the distinct (IgnoreCase, Fold) pairs any of
-// rules' content tests evaluate under, in first-seen order — B2's per-Dir
+// contentVariants returns the distinct (IgnoreCase, Fold) pairs any content
+// test evaluates under - each exclude's (under the directory's settings,
+// dirOpt) and each rule's - in first-seen order — B2's per-Dir
// ContentVariants. A rule whose condition has no content test at all
// (Cond.UsesContent false) never calls facts.Content, so its resolved
// case/fold settings contribute no variant here.
-func contentVariants(rules []*Rule) []cond.Options {
+func contentVariants(rules []*Rule, excludes []*Exclude, dirOpt cond.Options) []cond.Options {
var out []cond.Options
seen := map[cond.Options]bool{}
+ for _, x := range excludes {
+ if x.Cond.UsesContent && !seen[dirOpt] {
+ seen[dirOpt] = true
+ out = append(out, dirOpt)
+ }
+ }
for _, r := range rules {
if !r.Cond.UsesContent {
continue
diff --git a/internal/engine/exclude_test.go b/internal/engine/exclude_test.go
new file mode 100644
index 0000000..40b3548
--- /dev/null
+++ b/internal/engine/exclude_test.go
@@ -0,0 +1,209 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/plan"
+ "krino/internal/scan"
+)
+
+// excludeTree creates ~/dl with files (name -> content), all old enough to
+// be scanned, and returns home and dl.
+func excludeTree(t *testing.T, files map[string]string) (home, dl string) {
+ t.Helper()
+ home = sandbox(t)
+ t.Setenv("PATH", t.TempDir())
+ dl = filepath.Join(home, "dl")
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for name, body := range files {
+ p := filepath.Join(dl, name)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(p, old, old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return home, dl
+}
+
+// TestExcludeSetsFilesAside: an (exclude ...) in krino.conf applies to the
+// directory, and the directory's own excludes apply too - by type, by name
+// regex and by content - so those files get no actions from any rule and
+// are reported as excluded, with the form that matched.
+func TestExcludeSetsFilesAside(t *testing.T) {
+ h, _ := excludeTree(t, map[string]string{
+ "a.iso": "disk image",
+ "draft-1.pdf": "%PDF draft",
+ "secret.txt": "this is poufne material",
+ "keep.txt": "ordinary notes",
+ })
+ main := writeConfig(t, h, "(include \"dl\")\n(exclude (type iso))\n", map[string]string{"dl": `
+(path "~/dl")
+(exclude (name "^draft"))
+(exclude (type txt) (content "poufne"))
+(rule "all" (move "Out"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]string{
+ "a.iso": "(exclude (type iso))",
+ "draft-1.pdf": `(exclude (name "^draft"))`,
+ "secret.txt": `(exclude (type txt) (content "poufne"))`,
+ "keep.txt": "",
+ }
+ for _, fm := range dp.Result.Matched {
+ w, ok := want[fm.File.Rel]
+ if !ok {
+ t.Errorf("unexpected matched file %s", fm.File.Rel)
+ continue
+ }
+ if fm.Excluded != w {
+ t.Errorf("%s: Excluded = %q, want %q", fm.File.Rel, fm.Excluded, w)
+ }
+ if w != "" && len(fm.Rules) != 0 {
+ t.Errorf("%s: excluded but matched rules %v", fm.File.Rel, fm.Rules)
+ }
+ }
+ if len(dp.Result.Matched) != 4 || len(dp.Result.Unmatched) != 0 {
+ t.Errorf("matched %d, unmatched %d; want every file matched (3 excluded, 1 by the rule)", len(dp.Result.Matched), len(dp.Result.Unmatched))
+ }
+ for _, c := range dp.Chains {
+ if acting := len(c.Steps) > 0; acting != (c.File.Rel == "keep.txt") {
+ t.Errorf("%s: steps %+v", c.File.Rel, c.Steps)
+ }
+ }
+}
+
+// TestExcludeNeedsEveryCondition: within one form, every condition must
+// hold, as in when.
+func TestExcludeNeedsEveryCondition(t *testing.T) {
+ h, _ := excludeTree(t, map[string]string{"draft.txt": "x", "draft.pdf": "%PDF x"})
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(exclude (type pdf) (name "^draft"))
+(rule "all" (move "Out"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ r, err := e.Match(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, fm := range r.Matched {
+ if excluded := fm.Excluded != ""; excluded != (fm.File.Rel == "draft.pdf") {
+ t.Errorf("%s: Excluded = %q", fm.File.Rel, fm.Excluded)
+ }
+ }
+}
+
+// TestExcludeContentKeepsRuleContentVariants: an exclude reading content
+// under the directory's settings must not release the raw text a rule
+// with different case/fold settings still needs.
+func TestExcludeContentKeepsRuleContentVariants(t *testing.T) {
+ h, _ := excludeTree(t, map[string]string{"a.txt": "Invoice ACME"})
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(exclude (content "never present"))
+(rule "strict" (case strict) (when (content "ACME")) (move "Out"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ r, err := e.Match(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(r.Matched) != 1 || len(r.Matched[0].Rules) != 1 {
+ t.Fatalf("a.txt should match the strict content rule after the exclude read its text: %+v", r.Matched)
+ }
+}
+
+// TestLoadReportsBadExcludeOnce: a condition error inside a krino.conf
+// exclude is reported once, not once per directory.
+func TestLoadReportsBadExcludeOnce(t *testing.T) {
+ h := sandbox(t)
+ main := writeConfig(t, h, "(include \"a\" \"b\")\n(exclude (size big))\n", map[string]string{
+ "a": "(path \"/tmp\")", "b": "(path \"/tmp\")",
+ })
+ _, errs := Load(main)
+ if len(errs) != 1 || !strings.Contains(errs[0].Error(), "size") {
+ t.Errorf("errs = %v, want exactly one error about size", errs)
+ }
+}
+
+// TestMaxSizeSkipsTooBig: a directory's max-size skips larger files before
+// any rule, as too big.
+func TestMaxSizeSkipsTooBig(t *testing.T) {
+ h, _ := excludeTree(t, map[string]string{"small.txt": "x", "big.txt": strings.Repeat("x", 2048)})
+ main := writeConfig(t, h, "(include \"dl\")\n(defaults (max-size 1K))\n", map[string]string{"dl": `
+(path "~/dl")
+(rule "all" (move "Out"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ r, err := e.Match(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(r.Skipped) != 1 || r.Skipped[0].Rel != "big.txt" || r.Skipped[0].Reason != scan.TooBig {
+ t.Errorf("skipped = %+v, want big.txt too big", r.Skipped)
+ }
+ if len(r.Matched) != 1 || r.Matched[0].File.Rel != "small.txt" {
+ t.Errorf("matched = %+v, want small.txt", r.Matched)
+ }
+}
+
+// TestExplainReportsExclusionAndSize: explain names the exclude that sets a
+// file aside, traces every exclude, and says a file is too big.
+func TestExplainReportsExclusionAndSize(t *testing.T) {
+ h, dl := excludeTree(t, map[string]string{"a.iso": "disk", "big.txt": strings.Repeat("x", 2048)})
+ main := writeConfig(t, h, "(include \"dl\")\n(exclude (type iso))\n", map[string]string{"dl": `
+(path "~/dl")
+(max-size 1K)
+(exclude (name "^nothing"))
+(rule "all" (move "Out"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ x, err := e.Explain(context.Background(), filepath.Join(dl, "a.iso"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if x.Excluded != "(exclude (type iso))" {
+ t.Errorf("Excluded = %q", x.Excluded)
+ }
+ if len(x.Excludes) != 2 || !x.Excludes[0].Match || x.Excludes[1].Match || x.Excludes[0].Trace == nil {
+ t.Errorf("Excludes = %+v, want the global one matching, the directory's one not", x.Excludes)
+ }
+ big, err := e.Explain(context.Background(), filepath.Join(dl, "big.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if big.Skip != "too big" {
+ t.Errorf("Skip = %q, want too big", big.Skip)
+ }
+}
diff --git a/internal/engine/match.go b/internal/engine/match.go
index ffee7f0..8daea9d 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -34,6 +34,10 @@ type FileMatch struct {
Rules []RuleMatch // matching rules in order, ending at the first with (stop)
Warnings []string // "<rule>: <warning>", e.g. "acme: content unreadable: needs pdftotext, not installed"
+ // Excluded is the (exclude ...) form that set this file aside before any
+ // rule ran, as written; "" when none did. An excluded file has no Rules.
+ Excluded string
+
// NoDelete is non-empty when no delete step may run for this file (spec
// §5.5 rule 2), and says why: the file is a duplicate under a scope its
// directory's rules use, or that check failed. Only set for a file some
@@ -92,7 +96,7 @@ func (e *Engine) Match(ctx context.Context, d *Dir) (*Result, error) {
var matched, unmatched []FileMatch
for _, fm := range fileMatches {
- if len(fm.Rules) > 0 {
+ if len(fm.Rules) > 0 || fm.Excluded != "" {
matched = append(matched, fm)
} else {
unmatched = append(unmatched, fm)
@@ -119,6 +123,16 @@ func (e *Engine) Match(ctx context.Context, d *Dir) (*Result, error) {
func evalFile(run *matchRun, file scan.File) FileMatch {
f := newFacts(run, file)
fm := FileMatch{File: file}
+ for _, x := range run.d.Excludes {
+ res := x.Cond.Eval(f)
+ for _, w := range res.Warnings {
+ fm.Warnings = append(fm.Warnings, "exclude: "+w)
+ }
+ if res.Match {
+ fm.Excluded = x.Text
+ return fm
+ }
+ }
for _, r := range run.d.Rules {
res := r.Cond.Eval(f)
for _, w := range res.Warnings {
@@ -180,12 +194,21 @@ type RuleTrace struct {
Stopped string // "stopped by rule acme" when an earlier (stop) ended the search
}
+// ExcludeTrace is one (exclude ...) form's outcome in an Explain call.
+type ExcludeTrace struct {
+ Text string
+ Match bool
+ Trace *cond.Trace
+}
+
// Explanation is why (or why not) krino would act on one file.
type Explanation struct {
- Dir *Dir
- File scan.File
- Skip string // why krino would not look at this file at all; "" when it would
- Rules []RuleTrace
+ Dir *Dir
+ File scan.File
+ Skip string // why krino would not look at this file at all; "" when it would
+ Excludes []ExcludeTrace
+ Excluded string // the first exclude that matches, which sets the file aside; "" when none does
+ Rules []RuleTrace
}
// Explain reports, for one file, whether krino's ordinary scan would ever
@@ -235,6 +258,16 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
run := newMatchRun(e, d, ctx, now, e.filesForExplain(d, sf, excl, now))
f := newFacts(run, sf)
+ var excludes []ExcludeTrace
+ excluded := ""
+ for _, x := range d.Excludes {
+ trace := x.Cond.Explain(f)
+ excludes = append(excludes, ExcludeTrace{Text: x.Text, Match: trace.Value, Trace: trace})
+ if trace.Value && excluded == "" {
+ excluded = x.Text
+ }
+ }
+
var rules []RuleTrace
stoppedBy := ""
for _, r := range d.Rules {
@@ -253,7 +286,7 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
}
}
- return &Explanation{Dir: d, File: sf, Skip: skip, Rules: rules}, nil
+ return &Explanation{Dir: d, File: sf, Skip: skip, Excludes: excludes, Excluded: excluded, Rules: rules}, nil
}
// filesForExplain returns the file set Explain's duplicate checks run
@@ -285,6 +318,7 @@ func walkOptions(d *Dir, excl []string, now time.Time) scan.Options {
Exclude: excl,
Busy: d.Settings.Busy,
MinAge: d.Settings.MinAge,
+ MaxSize: d.Settings.MaxSize,
Now: now,
}
}
@@ -328,6 +362,9 @@ func explainSkip(d *Dir, sf scan.File, excl []string, now time.Time) string {
if now.Sub(sf.ModTime) < d.Settings.MinAge {
return "too new"
}
+ if d.Settings.MaxSize > 0 && sf.Size > d.Settings.MaxSize {
+ return "too big"
+ }
return ""
}