diff options
Diffstat (limited to 'internal/engine')
| -rw-r--r-- | internal/engine/engine.go | 47 | ||||
| -rw-r--r-- | internal/engine/exclude_test.go | 209 | ||||
| -rw-r--r-- | internal/engine/match.go | 49 |
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 "" } |
