diff options
| -rw-r--r-- | CHANGELOG.md | 14 | ||||
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | cmd/krino/check.go | 3 | ||||
| -rw-r--r-- | cmd/krino/common.go | 26 | ||||
| -rw-r--r-- | cmd/krino/exclude_test.go | 136 | ||||
| -rw-r--r-- | cmd/krino/explain.go | 18 | ||||
| -rw-r--r-- | cmd/krino/main.go | 3 | ||||
| -rw-r--r-- | cmd/krino/render.go | 37 | ||||
| -rw-r--r-- | cmd/krino/sort.go | 9 | ||||
| -rw-r--r-- | docs/design.md | 32 | ||||
| -rw-r--r-- | internal/config/dir.go | 38 | ||||
| -rw-r--r-- | internal/config/dir_test.go | 36 | ||||
| -rw-r--r-- | internal/config/main.go | 7 | ||||
| -rw-r--r-- | internal/config/main_test.go | 18 | ||||
| -rw-r--r-- | internal/config/settings.go | 15 | ||||
| -rw-r--r-- | internal/config/settings_test.go | 16 | ||||
| -rw-r--r-- | internal/config/skel/krino.conf | 7 | ||||
| -rw-r--r-- | internal/config/skel/template.conf | 6 | ||||
| -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 | ||||
| -rw-r--r-- | internal/scan/scan.go | 8 | ||||
| -rw-r--r-- | internal/scan/scan_test.go | 32 | ||||
| -rw-r--r-- | man/krino.1 | 20 | ||||
| -rw-r--r-- | man/krino.conf.5 | 60 |
25 files changed, 825 insertions, 25 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index acb32c1..43ab3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +## 0.0.4 — 2026-09-14 + +- `max-size` setting: files larger than it are skipped as "too big", in + `(defaults ...)` or a directory file. +- `(exclude COND...)` sets files aside before any rule sees them, by type or + extension, name regex, content, or any other condition; all conditions in + one form must hold. In `krino.conf` it applies to every directory, in a + directory file to that directory. Excluded files are counted in the plan + and listed with the form that matched under `-v`; `explain` traces every + exclusion and `check` lists them. +- `--min-age DURATION` overrides every directory's `min-age` for one run, + e.g. `krino -n --min-age 0` to include files written a moment ago. +- `krino init` and `krino new` write commented examples of all three. + ## 0.0.3 — 2026-09-14 - The plan is shown as one block per file instead of a table: the file's @@ -75,9 +75,9 @@ edit its rules, then check them with: krino check demo `krino new` copied the template to `dirs/demo.conf` and filled in the path. Replace its body with a small rule. The built-in `min-age` is 2 minutes -(files skip as "busy" while they might still be downloading), which the +(files skip as "too new" while they might still be downloading), which the demo files we just created are younger than, so this also turns it down to -0 for this directory: +0 for this directory (`--min-age 0` would do the same for a single run): ``` cat > "$XDG_CONFIG_HOME/krino/dirs/demo.conf" <<'EOF' diff --git a/cmd/krino/check.go b/cmd/krino/check.go index 1820bbe..dfe3a1a 100644 --- a/cmd/krino/check.go +++ b/cmd/krino/check.go @@ -36,6 +36,9 @@ func cmdCheck(g *globals, args []string, stdout, stderr io.Writer) int { if dr.Missing { fmt.Fprintf(stdout, " warning: %s is not a directory right now; it will be skipped\n", xdg.Abbrev(dr.Dir.Root)) } + for _, x := range dr.Dir.Excludes { + fmt.Fprintf(stdout, " %s\n", x.Text) + } if len(dr.Dir.Rules) == 0 { fmt.Fprintln(stdout, " no rules yet") } diff --git a/cmd/krino/common.go b/cmd/krino/common.go index ea1e83d..4552441 100644 --- a/cmd/krino/common.go +++ b/cmd/krino/common.go @@ -6,11 +6,37 @@ import ( "fmt" "io" "strings" + "time" "krino/internal/config" + "krino/internal/engine" "krino/internal/xdg" ) +// minAgeOverride parses --min-age: a duration in krino's own units (30s, +// 2m, 1h, 1d, 1w), or a bare 0. set is false when the flag was not given. +// Checked before any config is read, as every flag is. +func minAgeOverride(g *globals) (d time.Duration, set bool, err error) { + switch g.minAge { + case "": + return 0, false, nil + case "0": + return 0, true, nil + } + d, err = config.ParseDuration(g.minAge) + if err != nil { + return 0, false, fmt.Errorf("--min-age: %v", err) + } + return d, true, nil +} + +// applyMinAge sets every loaded directory's min-age to d, for this run only. +func applyMinAge(e *engine.Engine, d time.Duration) { + for _, dir := range e.Dirs { + dir.Settings.MinAge = d + } +} + // mainFile is -c FILE, or the default krino.conf. func mainFile(g *globals) string { if g.conf != "" { diff --git a/cmd/krino/exclude_test.go b/cmd/krino/exclude_test.go new file mode 100644 index 0000000..2e9ff2a --- /dev/null +++ b/cmd/krino/exclude_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "krino/internal/engine" + "krino/internal/scan" +) + +// excludeFixture makes ~/dl with an old a.iso, an old keep.txt and a fresh +// new.txt, a krino.conf excluding iso files everywhere, and dl's own rules: +// exclude names starting with "draft", move everything else to Out. +func excludeFixture(t *testing.T) string { + t.Helper() + h := home(t) + dl := filepath.Join(h, "dl") + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for name, mt := range map[string]time.Time{"a.iso": old, "keep.txt": old, "draft.txt": old, "new.txt": time.Now()} { + p := filepath.Join(dl, name) + if err := os.MkdirAll(dl, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + cfg := filepath.Join(h, ".config", "krino") + mainConf, err := os.ReadFile(filepath.Join(cfg, "krino.conf")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfg, "krino.conf"), append(mainConf, []byte("\n(exclude (type iso))\n")...), 0o644); err != nil { + t.Fatal(err) + } + rules := "(path \"~/dl\")\n(exclude (name \"^draft\"))\n(rule \"all\" (move \"Out\"))\n" + if err := os.WriteFile(filepath.Join(cfg, "dirs", "dl.conf"), []byte(rules), 0o644); err != nil { + t.Fatal(err) + } + return h +} + +// TestMinAgeFlagOverridesTheSetting: --min-age changes "too new" for one +// run, in either position, and a bad value is a usage error before anything +// is read. +func TestMinAgeFlagOverridesTheSetting(t *testing.T) { + excludeFixture(t) + _, out, _ := runCLI(t, "-n") + if strings.Contains(out, "new.txt") || !strings.Contains(out, "1 too new") { + t.Errorf("without the flag new.txt should be too new:\n%s", out) + } + for _, args := range [][]string{{"--min-age", "0", "-n"}, {"-n", "--min-age", "0s"}} { + code, out, errOut := runCLI(t, args...) + if code != 0 || !strings.Contains(out, " new.txt\n") || strings.Contains(out, "too new") { + t.Errorf("%v: exit %d, new.txt should be planned:\n%s\n%s", args, code, out, errOut) + } + } + code, _, errOut := runCLI(t, "--min-age", "soon", "-n") + if code != 2 || !strings.Contains(errOut, "--min-age") { + t.Errorf("--min-age soon: exit %d, stderr %q; want 2 naming --min-age", code, errOut) + } +} + +// TestVerboseListsExcludedFiles: with -v, the plan lists each excluded file +// with the exclude that set it aside; the counts line counts them. +func TestVerboseListsExcludedFiles(t *testing.T) { + excludeFixture(t) + code, out, errOut := runCLI(t, "-n", "-v") + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + for _, want := range []string{ + "2 excluded", + "\nexcluded\n a.iso (exclude (type iso))\n draft.txt (exclude (name \"^draft\"))\n", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } +} + +// TestCheckListsExclusions: check shows each directory's exclusions, the +// krino.conf ones first, before its rules. +func TestCheckListsExclusions(t *testing.T) { + excludeFixture(t) + code, out, errOut := runCLI(t, "check") + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + want := " (exclude (type iso))\n (exclude (name \"^draft\"))\n 1 all" + if !strings.Contains(out, want) { + t.Errorf("check output lacks %q:\n%s", want, out) + } +} + +// TestExplainShowsExclusion: explain says which exclude sets the file +// aside and traces every exclude. +func TestExplainShowsExclusion(t *testing.T) { + h := excludeFixture(t) + code, out, errOut := runCLI(t, "explain", filepath.Join(h, "dl", "a.iso")) + if code != 0 { + t.Fatalf("exit %d: %s", code, errOut) + } + for _, want := range []string{ + "krino would set this file aside: (exclude (type iso))\n", + "(exclude (type iso)): MATCH\n", + "(exclude (name \"^draft\")): no\n", + } { + if !strings.Contains(out, want) { + t.Errorf("explain output lacks %q:\n%s", want, out) + } + } +} + +// TestSkipSummaryCountsTooBig: a file skipped for max-size is counted as +// too big in the "not acted on" line. +func TestSkipSummaryCountsTooBig(t *testing.T) { + r := &engine.Result{Skipped: []scan.Skipped{{Rel: "big.iso", Reason: scan.TooBig}}} + if got := skipSummaryLine(r, nil, false); !strings.Contains(got, "1 too big") { + t.Errorf("line = %q, want 1 too big", got) + } +} diff --git a/cmd/krino/explain.go b/cmd/krino/explain.go index a4d0253..6f8fa26 100644 --- a/cmd/krino/explain.go +++ b/cmd/krino/explain.go @@ -26,12 +26,19 @@ func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int { if fs.NArg() != 1 { return usageError(stderr, "usage: krino explain FILE") } + minAge, setMinAge, err := minAgeOverride(g) + if err != nil { + return usageError(stderr, err.Error()) + } e, errs := engine.Load(mainFile(g)) if len(errs) > 0 { printDiags(stderr, errs) return 2 } + if setMinAge { + applyMinAge(e, minAge) + } x, err := e.Explain(context.Background(), xdg.Expand(fs.Arg(0))) if err != nil { @@ -43,7 +50,18 @@ func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int { if x.Skip != "" { fmt.Fprintf(stdout, "krino would not look at this file: %s\n", x.Skip) } + if x.Excluded != "" { + fmt.Fprintf(stdout, "krino would set this file aside: %s\n", x.Excluded) + } fmt.Fprintln(stdout) + for _, xt := range x.Excludes { + status := "no" + if xt.Match { + status = "MATCH" + } + fmt.Fprintf(stdout, "%s: %s\n", xt.Text, status) + printTrace(stdout, xt.Trace) + } for _, rt := range x.Rules { if rt.Stopped != "" { fmt.Fprintf(stdout, "rule %s: not evaluated, %s\n", rt.Rule.Name, rt.Stopped) diff --git a/cmd/krino/main.go b/cmd/krino/main.go index 6d1ef96..a8fab2d 100644 --- a/cmd/krino/main.go +++ b/cmd/krino/main.go @@ -52,6 +52,7 @@ Sort the files in the directories listed in krino.conf by their rules. -c FILE use FILE instead of ~/.config/krino/krino.conf --no-color never colour the output, as when NO_COLOR is set -P, --no-pager print the plan straight out, never through the pager + --min-age D for this run, skip files modified less than D ago (0, 30m, 1d) -h, --help show this help --version print the version ` @@ -60,6 +61,7 @@ Sort the files in the directories listed in krino.conf by their rules. type globals struct { yes, dry, verbose, json bool noColor, noPager bool + minAge string // --min-age as given; "" when not conf string } @@ -122,6 +124,7 @@ func flagSet(name string, g *globals) *flag.FlagSet { fs.BoolVar(&g.noColor, "no-color", g.noColor, "") fs.BoolVar(&g.noPager, "no-pager", g.noPager, "") fs.BoolVar(&g.noPager, "P", g.noPager, "") + fs.StringVar(&g.minAge, "min-age", g.minAge, "") return fs } diff --git a/cmd/krino/render.go b/cmd/krino/render.go index 8031767..f94b15a 100644 --- a/cmd/krino/render.go +++ b/cmd/krino/render.go @@ -67,6 +67,13 @@ func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette, width i } if verbose { + if lines := excludedLines(r, dp.Chains); len(lines) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "excluded") + for _, l := range lines { + fmt.Fprintln(w, l) + } + } if len(r.Unmatched) > 0 { fmt.Fprintln(w) fmt.Fprintln(w, "not matched") @@ -82,6 +89,36 @@ func printPlan(w io.Writer, dp *engine.DirPlan, verbose bool, p palette, width i } } +// excludedLines lists, for -v, every file that was set aside: each chain +// with no steps, with what set it aside - the (exclude ...) form, or the +// action-less rule it matched. Names are padded to the widest shown +// (capped at 40), as in the warnings section. +func excludedLines(r *engine.Result, chains []plan.Chain) []string { + byRel := make(map[string]engine.FileMatch, len(r.Matched)) + for _, fm := range r.Matched { + byRel[fm.File.Rel] = fm + } + var rels, why []string + for _, c := range chains { + if len(c.Steps) > 0 { + continue + } + fm := byRel[c.File.Rel] + reason := fm.Excluded + if reason == "" && len(fm.Rules) > 0 { + reason = "rule " + fm.Rules[len(fm.Rules)-1].Rule.Name + } + rels = append(rels, c.File.Rel) + why = append(why, reason) + } + width := relWidth(rels) + out := make([]string, len(rels)) + for i := range rels { + out[i] = " " + padCell(rels[i], width) + " " + why[i] + } + return out +} + // chainActing reports whether c has at least one step that will actually // run - the single definition of "actionable" that countActing, // actionableChains (sort.go) and chainOutcomes (sort.go) all share (fix diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go index 6d11238..9e714ba 100644 --- a/cmd/krino/sort.go +++ b/cmd/krino/sort.go @@ -56,12 +56,19 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int { if g.json && !g.dry { return usageError(stderr, "--json is only valid with -n") } + minAge, setMinAge, err := minAgeOverride(g) + if err != nil { + return usageError(stderr, err.Error()) + } e, errs := engine.Load(mainFile(g), names...) if len(errs) > 0 { printDiags(stderr, errs) return 2 } + if setMinAge { + applyMinAge(e, minAge) + } p := palette{on: colourOn(g, stdout)} // Spec §8.4: with neither -y nor -n, krino asks; asking a non-terminal @@ -461,7 +468,7 @@ func printSkipped(w io.Writer, skipped []scan.Skipped) { // skipReasonOrder is plan 2's reviewed order for the skip reasons the last // line reports, before "unmatched". -var skipReasonOrder = []scan.Reason{scan.Ignored, scan.Busy, scan.TooNew, scan.Symlink, scan.NotRegular, scan.Unreadable} +var skipReasonOrder = []scan.Reason{scan.Ignored, scan.Busy, scan.TooNew, scan.TooBig, scan.Symlink, scan.NotRegular, scan.Unreadable} // skipSummaryLine builds the "not acted on: N ignored · N busy · ... · N // unmatched" line per spec §8.2's item format ("<count> <label>", not diff --git a/docs/design.md b/docs/design.md index 437b602..3130870 100644 --- a/docs/design.md +++ b/docs/design.md @@ -3,7 +3,9 @@ Status: describes krino 0.0.1, 2026-09-13; amended 2026-09-14 for 0.0.2: duplicates are never deleted (§5.5), and coloured output with `--no-color` (§8.2, §11); amended again 2026-09-14 for 0.0.3: the plan -shown as one block per file, wrapped to the terminal, and `-P` (§8.2, §8.3, §11). +shown as one block per file, wrapped to the terminal, and `-P` (§8.2, §8.3, §11); +amended again 2026-09-14 for 0.0.4: `max-size` (§4.4), `(exclude ...)` +(§4.2, §4.3, §4.6) and `--min-age` (§11). krino (from Greek κρίνω, "to separate, to judge, to decide") sorts files in chosen directories by rules. A rule tests a file's type, name, path, size, @@ -100,6 +102,7 @@ type names, operators, sizes and durations are symbols. (log "~/.local/state/krino/krino.log") ; optional (defaults ; optional; any setting from 4.4 (min-age 5m)) +(exclude (type iso)) ; optional, may repeat; every directory (4.6) ``` ### 4.3 `dirs/<name>.conf` @@ -108,6 +111,7 @@ type names, operators, sizes and durations are symbols. (path "~/downloads") ; required (recursive no) ; any setting from 4.4 (ignore "*.part" "*.aria2" ".*") ; may repeat; patterns accumulate in order +(exclude (name "^keep-")) ; may repeat; this directory only (4.6) (rule "acme" (when (type document) @@ -135,6 +139,7 @@ directory, then defaults, then built-in. | `max-depth` | integer, 1 = root only | unlimited | | | `min-age` | duration. Skip files modified more recently | `2m` | | | `max-read` | size. No content extraction above this file size | `50M` | | +| `max-size` | size. Skip files larger than this, as "too big" | unlimited | | | `busy` | suffixes. Skip `f` when `f<suffix>` exists beside it | `".part" ".aria2" ".crdownload"` | | | `on-conflict` | `suffix` \| `skip` \| `overwrite` (§7.4) | `suffix` | yes | @@ -171,6 +176,27 @@ A rule whose condition contains `(duplicate)` anywhere, including inside absolute paths are allowed. It is created if missing. `DEST` and `NAME` take placeholders (§7.3). +### 4.6 Exclusions + +```lisp +(exclude (type iso img)) ; by extension +(exclude (name "^keep-")) ; by name +(exclude (type pdf) (content "confidential")) ; by content +``` + +`(exclude COND...)` sets files aside before any rule sees them. Its +conditions are the tests of §5.2, and all of them must hold, as in `when`; +a file matching any `exclude` form is excluded. Forms in `krino.conf` apply +to every directory and are tested first, then the directory's own. They are +compiled with the directory's `case` and `fold`. An excluded file is counted +as "excluded" in the plan, listed with the form that matched under `-v`, +and traced by `explain`; `check` lists every directory's exclusions. Since +no rule has run yet, `(matched)` is never true inside an exclude. + +Unlike `ignore`, which never looks inside a file and never descends into an +ignored directory, an exclude can test content and size, and is evaluated +per file after the walk. + ## 5. Conditions ### 5.1 Operators @@ -399,7 +425,8 @@ name has to change, the log records the actual name. excluded before the walk, and krino would re-examine its own output; plan 3 closes this by treating a file already at its computed destination as a no-op. -- Skipped as busy: files newer than `min-age`, or with a `busy` sibling. +- Skipped: files newer than `min-age` ("too new"), larger than `max-size` + ("too big"), or with a `busy` sibling ("busy"). ### 8.2 Display @@ -580,6 +607,7 @@ krino undo [RUN] reverse a run (default: the last one) -c FILE use FILE instead of ~/.config/krino/krino.conf --no-color never colour the output, as when NO_COLOR is set -P, --no-pager print the plan straight out, never through the pager +--min-age D for this run, every directory's min-age is D (0, 30s, 1d) -h, --help help --version print "krino 0.0.1" ``` diff --git a/internal/config/dir.go b/internal/config/dir.go index 74c538a..461d912 100644 --- a/internal/config/dir.go +++ b/internal/config/dir.go @@ -19,9 +19,41 @@ type Dir struct { PathText string // as written in the file Settings Settings Ignore []string // gitignore patterns, in order + Excludes []*Exclude Rules []*Rule } +// Exclude is one (exclude COND...) form: a file for which every condition +// holds is set aside before any rule runs. Forms may repeat, so a file is +// excluded when any one form matches it. +type Exclude struct { + Pos sexp.Pos + Text string // the form as written, whitespace collapsed, for check and explain + When []*sexp.Node // the conditions, all of which must hold +} + +// parseExclude reads an (exclude COND...) form from src; nil when it has no +// usable condition. +func parseExclude(n *sexp.Node, src []byte, d *diags) *Exclude { + conds := n.Args() + if len(conds) == 0 { + d.at(n, "(exclude) needs a condition, like (exclude (type iso))") + return nil + } + bad := false + for _, c := range conds { + if c.Kind != sexp.List { + d.at(c, "exclude: a condition is a form like (type pdf), not %s", c) + bad = true + } + } + if bad { + return nil + } + text := strings.Join(strings.Fields(string(src[n.Pos.Offset:n.End.Offset])), " ") + return &Exclude{Pos: n.Pos, Text: text, When: conds} +} + // Rule is a named condition with the actions it performs. type Rule struct { Name string @@ -97,6 +129,10 @@ func ParseDir(name, file string, src []byte) (*Dir, []*Diag) { } dir.Ignore = append(dir.Ignore, a.Text) } + case head == "exclude": + if x := parseExclude(n, src, d); x != nil { + dir.Excludes = append(dir.Excludes, x) + } case head == "rule": r := parseRule(n, d) if r == nil { @@ -111,7 +147,7 @@ func ParseDir(name, file string, src []byte) (*Dir, []*Diag) { case isSetting(head): dir.Settings.parse(n, d, seen) default: - d.at(n, "unknown form (%s ...); a directory file has path, ignore, rule and settings like (recursive yes)", head) + d.at(n, "unknown form (%s ...); a directory file has path, ignore, exclude, rule and settings like (recursive yes)", head) } } if pathNode == nil { diff --git a/internal/config/dir_test.go b/internal/config/dir_test.go index 7ddfa2a..080905c 100644 --- a/internal/config/dir_test.go +++ b/internal/config/dir_test.go @@ -4,6 +4,7 @@ package config import ( "reflect" + "strings" "testing" ) @@ -111,7 +112,7 @@ func TestParseDirErrors(t *testing.T) { {`(path "/a") (rule "x" (delete) (move "y"))`, `d.conf:1:32: rule "x": (move "y") after delete would never run`}, {`(path "/a") (rule "x" (stop now))`, `d.conf:1:23: rule "x": stop takes nothing: write (stop)`}, {`(path "/a") (rule "x" (fly "y"))`, `d.conf:1:23: rule "x": unknown form (fly ...); a rule has when, copy, move, rename, delete, stop, case, fold and on-conflict`}, - {`(path "/a") (sort "x")`, `d.conf:1:13: unknown form (sort ...); a directory file has path, ignore, rule and settings like (recursive yes)`}, + {`(path "/a") (sort "x")`, `d.conf:1:13: unknown form (sort ...); a directory file has path, ignore, exclude, rule and settings like (recursive yes)`}, } for _, tt := range tests { _, errs := ParseDir("d", "d.conf", []byte(tt.src)) @@ -120,3 +121,36 @@ func TestParseDirErrors(t *testing.T) { } } } + +// TestParseExclude: (exclude COND...) may repeat; each form keeps its +// conditions (all must hold, as in when) and its text as written, with the +// whitespace collapsed, for check and explain to show. +func TestParseExclude(t *testing.T) { + src := `(path "/tmp") +(exclude (type iso img)) +(exclude (name "^draft") + (content "poufne")) +` + dir, errs := ParseDir("dl", "dl.conf", []byte(src)) + if len(errs) != 0 { + t.Fatal(errs) + } + if len(dir.Excludes) != 2 || len(dir.Excludes[0].When) != 1 || len(dir.Excludes[1].When) != 2 { + t.Fatalf("Excludes = %+v", dir.Excludes) + } + if got, want := dir.Excludes[1].Text, `(exclude (name "^draft") (content "poufne"))`; got != want { + t.Errorf("Text = %q, want %q", got, want) + } + if dir.Excludes[1].Pos.Line != 3 { + t.Errorf("Pos = %+v, want line 3", dir.Excludes[1].Pos) + } + for _, tt := range []struct{ src, want string }{ + {"(path \"/tmp\")\n(exclude)\n", "(exclude) needs a condition"}, + {"(path \"/tmp\")\n(exclude iso)\n", "exclude: a condition is a form like (type pdf), not iso"}, + } { + _, errs := ParseDir("dl", "dl.conf", []byte(tt.src)) + if len(errs) != 1 || !strings.Contains(errs[0].Msg, tt.want) { + t.Errorf("%q: errs %v, want one containing %q", tt.src, errs, tt.want) + } + } +} diff --git a/internal/config/main.go b/internal/config/main.go index ee6c3df..2764c8a 100644 --- a/internal/config/main.go +++ b/internal/config/main.go @@ -21,6 +21,7 @@ type Main struct { IncludeNode *sexp.Node Log string // absolute; empty means the default Defaults Settings + Excludes []*Exclude // apply to every directory, before its own } // nameRE is what a directory name may look like: it becomes a file name. @@ -87,8 +88,12 @@ func ParseMain(file string, src []byte) (*Main, []*Diag) { } m.Defaults.parse(a, d, dseen) } + case "exclude": + if x := parseExclude(n, src, d); x != nil { + m.Excludes = append(m.Excludes, x) + } default: - d.at(n, "unknown form (%s ...); krino.conf has include, log and defaults", head) + d.at(n, "unknown form (%s ...); krino.conf has include, log, defaults and exclude", head) } } return m, d.list diff --git a/internal/config/main_test.go b/internal/config/main_test.go index a82dfcd..be39faf 100644 --- a/internal/config/main_test.go +++ b/internal/config/main_test.go @@ -4,6 +4,7 @@ package config import ( "reflect" + "strings" "testing" "time" ) @@ -54,7 +55,7 @@ func TestParseMainErrors(t *testing.T) { {`(log "rel/x")`, `k:1:6: log path must be absolute or start with ~`}, {`(defaults (recursive maybe))`, `k:1:22: recursive is yes or no, not maybe`}, {`(defaults (rule "x"))`, `k:1:11: defaults holds settings like (min-age 2m); got (rule "x")`}, - {`(inlcude "a")`, `k:1:1: unknown form (inlcude ...); krino.conf has include, log and defaults`}, + {`(inlcude "a")`, `k:1:1: unknown form (inlcude ...); krino.conf has include, log, defaults and exclude`}, {`include`, `k:1:1: expected a form like (include ...), got include`}, {`(include "a"`, `k:1:1: "(" never closed: (include "a")`}, } @@ -65,3 +66,18 @@ func TestParseMainErrors(t *testing.T) { } } } + +// TestParseMainExclude: krino.conf may hold (exclude ...) forms, which +// apply to every directory. +func TestParseMainExclude(t *testing.T) { + m, errs := ParseMain("krino.conf", []byte("(include \"a\")\n(exclude (type iso))\n(exclude (name \"[.]asc$\"))\n")) + if len(errs) != 0 { + t.Fatal(errs) + } + if len(m.Excludes) != 2 || m.Excludes[0].Text != "(exclude (type iso))" { + t.Fatalf("Excludes = %+v", m.Excludes) + } + if _, errs := ParseMain("krino.conf", []byte("(exclude)")); len(errs) != 1 || !strings.Contains(errs[0].Msg, "(exclude) needs a condition") { + t.Errorf("(exclude): errs %v", errs) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index 97355a5..efc935b 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -34,6 +34,7 @@ type Settings struct { MaxDepth *int MinAge *time.Duration MaxRead *int64 + MaxSize *int64 Busy *[]string OnConflict *Conflict } @@ -46,6 +47,7 @@ type Resolved struct { MaxDepth int // 0 means unlimited MinAge time.Duration MaxRead int64 + MaxSize int64 // 0 means unlimited Busy []string OnConflict Conflict } @@ -83,6 +85,9 @@ func (s Settings) Over(base Resolved) Resolved { if s.MaxRead != nil { r.MaxRead = *s.MaxRead } + if s.MaxSize != nil { + r.MaxSize = *s.MaxSize + } if s.Busy != nil { r.Busy = *s.Busy } @@ -92,7 +97,7 @@ func (s Settings) Over(base Resolved) Resolved { return r } -var settingNames = []string{"case", "fold", "recursive", "max-depth", "min-age", "max-read", "busy", "on-conflict"} +var settingNames = []string{"case", "fold", "recursive", "max-depth", "min-age", "max-read", "max-size", "busy", "on-conflict"} // ruleSettings are the settings a rule may override. var ruleSettings = map[string]bool{"case": true, "fold": true, "on-conflict": true} @@ -106,6 +111,7 @@ var settingHint = map[string]string{ "max-depth": "a number, like (max-depth 3)", "min-age": "a duration, like (min-age 2m)", "max-read": "a size, like (max-read 50M)", + "max-size": "a size, like (max-size 1G)", "on-conflict": "(on-conflict suffix), skip or overwrite", } @@ -183,6 +189,13 @@ func (s *Settings) parse(n *sexp.Node, d *diags, seen map[string]*sexp.Node) { return } s.MaxRead = &size + case "max-size": + size, err := ParseSize(v) + if err != nil { + d.at(at, "max-size: %v", err) + return + } + s.MaxSize = &size case "on-conflict": c, ok := map[string]Conflict{"suffix": ConflictSuffix, "skip": ConflictSkip, "overwrite": ConflictOverwrite}[v] if !ok { diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index e46c880..341c0f3 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -4,6 +4,7 @@ package config import ( "reflect" + "strings" "testing" "time" @@ -27,12 +28,12 @@ func parseSettings(t *testing.T, src string) (Settings, []*Diag) { func TestSettingsResolve(t *testing.T) { s, errs := parseSettings(t, `(case strict) (fold no) (recursive yes) (max-depth 3) - (min-age 5m) (max-read 1G) (busy ".tmp") (on-conflict skip)`) + (min-age 5m) (max-read 1G) (max-size 2G) (busy ".tmp") (on-conflict skip)`) if len(errs) > 0 { t.Fatal(errs) } want := Resolved{Case: CaseStrict, Fold: false, Recursive: true, MaxDepth: 3, - MinAge: 5 * time.Minute, MaxRead: 1 << 30, Busy: []string{".tmp"}, OnConflict: ConflictSkip} + MinAge: 5 * time.Minute, MaxRead: 1 << 30, MaxSize: 2 << 30, Busy: []string{".tmp"}, OnConflict: ConflictSkip} if got := s.Over(Builtin()); !reflect.DeepEqual(got, want) { t.Fatalf("got %+v\nwant %+v", got, want) } @@ -83,3 +84,14 @@ func TestSettingErrors(t *testing.T) { } } } + +// TestMaxSizeErrors: max-size takes a size like max-read does, and is not a +// rule-level setting. +func TestMaxSizeErrors(t *testing.T) { + if _, errs := parseSettings(t, "(max-size big)"); len(errs) != 1 || !strings.Contains(errs[0].Msg, "max-size") { + t.Errorf("(max-size big): errs %v, want one max-size error", errs) + } + if _, errs := parseSettings(t, "(max-size 10M)"); len(errs) != 0 { + t.Errorf("(max-size 10M): errs %v, want none", errs) + } +} diff --git a/internal/config/skel/krino.conf b/internal/config/skel/krino.conf index dbd883b..7008dbc 100644 --- a/internal/config/skel/krino.conf +++ b/internal/config/skel/krino.conf @@ -19,5 +19,12 @@ ;; (recursive no) ;; (min-age 2m) ; skip files modified in the last 2 minutes ;; (max-read 50M) ; no content extraction above this size +;; (max-size 2G) ; skip files larger than this entirely ;; (busy ".part" ".aria2" ".crdownload") ;; (on-conflict suffix)) ; suffix | skip | overwrite + +;; Files no rule in any directory may touch. The conditions in one form +;; must all hold; a file matching any form is set aside. Some examples: +;; (exclude (type iso)) ; by type or extension +;; (exclude (name "^keep-")) ; by name, a regex +;; (exclude (type pdf) (content "confidential")) ; by content diff --git a/internal/config/skel/template.conf b/internal/config/skel/template.conf index c3af09d..d366716 100644 --- a/internal/config/skel/template.conf +++ b/internal/config/skel/template.conf @@ -15,10 +15,16 @@ ;; (fold yes) ; yes: "spolka" matches "spółka" ;; (min-age 2m) ; skip files modified in the last 2 minutes ;; (max-read 50M) ; no content extraction above this size +;; (max-size 2G) ; skip files larger than this entirely ;; (on-conflict suffix) ; suffix | skip | overwrite ;; Files and directories to leave alone, in .gitignore syntax. (ignore "*.part" "*.crdownload" "*.aria2" ".*") +;; Files no rule here may touch, tested before any rule. The conditions in +;; one form must all hold; a file matching any form is set aside. +;; (exclude (type iso img)) ; by extension +;; (exclude (name "^keep-")) ; by name, a regex +;; (exclude (type pdf) (content "confidential")) ; by content ;; Rules run top to bottom. Every rule that matches a file adds its actions ;; to that file; (stop) ends the search for it. Some examples: 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 "" } diff --git a/internal/scan/scan.go b/internal/scan/scan.go index e87b741..7fe9e92 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -36,6 +36,7 @@ const ( Symlink // a symbolic link (never followed) NotRegular // fifo, socket, device Unreadable // a directory that could not be read + TooBig // larger than MaxSize ) // String names a Reason the way it should read in a report. @@ -53,6 +54,8 @@ func (r Reason) String() string { return "not a regular file" case Unreadable: return "unreadable" + case TooBig: + return "too big" default: return fmt.Sprintf("Reason(%d)", int(r)) } @@ -72,6 +75,7 @@ type Options struct { Exclude []string // absolute directories never entered Busy []string // suffixes, e.g. ".part" MinAge time.Duration + MaxSize int64 // bytes; 0: no limit Now time.Time } @@ -209,6 +213,10 @@ func (w *walker) walk(dir, relDir string, depth int, entries []os.DirEntry) erro w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: TooNew}) continue } + if w.opt.MaxSize > 0 && info.Size() > w.opt.MaxSize { + w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: TooBig}) + continue + } w.result.Files = append(w.result.Files, File{ Path: path, Rel: rel, diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go index 94f922b..a245893 100644 --- a/internal/scan/scan_test.go +++ b/internal/scan/scan_test.go @@ -261,3 +261,35 @@ func TestIgnoredDirectoryReportedOnce(t *testing.T) { t.Fatalf("skipped = %v, want %v (the directory once, not its three files)", skipped(r), want) } } + +// TestTooBig: with MaxSize set, a file larger than it is skipped as too +// big; a file of exactly MaxSize is kept; MaxSize 0 means no limit. +func TestTooBig(t *testing.T) { + root := tree(t) + for name, size := range map[string]int{"small.bin": 10, "exact.bin": 100, "large.bin": 101} { + p := filepath.Join(root, name) + if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil { + t.Fatal(err) + } + old := now.Add(-time.Hour) + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + r, err := Walk(root, Options{MaxSize: 100, Now: now}) + if err != nil { + t.Fatal(err) + } + if want := []string{"exact.bin", "small.bin"}; !reflect.DeepEqual(rels(r), want) { + t.Fatalf("files = %v, want %v", rels(r), want) + } + if got := skipped(r); !reflect.DeepEqual(got, map[string]Reason{"large.bin": TooBig}) { + t.Fatalf("skipped = %v", got) + } + if TooBig.String() != "too big" { + t.Errorf("TooBig reads %q", TooBig.String()) + } + if r, _ := Walk(root, Options{Now: now}); len(rels(r)) != 3 { + t.Errorf("MaxSize 0 skipped files: %v", skipped(r)) + } +} diff --git a/man/krino.1 b/man/krino.1 index a4ce2f9..458bd56 100644 --- a/man/krino.1 +++ b/man/krino.1 @@ -13,6 +13,7 @@ .Op Fl c Ar file .Op Fl P .Op Fl -no-color +.Op Fl -min-age Ar duration .Op Ar name ... .Pp .Nm @@ -129,6 +130,21 @@ faint. Print the plan straight out, never through .Ev PAGER . Without it, a plan taller than the terminal is shown through the pager. +.It Fl -min-age Ar duration +For this run only, skip files modified less than +.Ar duration +ago in every directory, in place of each directory's +.Ic min-age +.Pq Xr krino.conf 5 . +.Ar duration +is +.Sy 0 , +or an integer with one of +.Ql s m h d w ; +.Fl -min-age Cm 0 +considers even a file written a moment ago. +Also honoured by +.Ic explain . .It Fl h , Fl -help Print usage and exit. .It Fl -version @@ -163,7 +179,7 @@ in .Pa krino.conf , keeping its comments. .It Ic check Op Ar name ... -Validate the configuration, list each included directory's rules, and list +Validate the configuration, list each included directory's exclusions and rules, and list which content-extraction tools .Pq Xr krino.conf 5 , Sx CONTENT EXTRACTION are available. @@ -171,7 +187,7 @@ With no .Ar name , checks every included directory. .It Ic explain Ar file -Evaluate every rule of +Evaluate every exclusion and rule of .Ar file Ns 's directory against it and show each test's result, so a rule that should match but does not diff --git a/man/krino.conf.5 b/man/krino.conf.5 index a412e1d..769bd51 100644 --- a/man/krino.conf.5 +++ b/man/krino.conf.5 @@ -67,6 +67,7 @@ The main file, read first: (log "~/.local/state/krino/krino.log") ; optional (defaults ; optional; any setting below (min-age 5m)) +(exclude (type iso)) ; optional, may repeat .Ed .Bl -tag -width Ds .It Ic (include Ar name No ...) @@ -84,6 +85,10 @@ Defaults for every directory; a directory's own file, and a rule inside it, can override them. See .Sx SETTINGS . +.It Ic (exclude Ar condition No ...) +May repeat. +Sets matching files aside in every directory; see +.Sx EXCLUSIONS . .El .Ss dirs/name.conf One file per configured directory: @@ -91,6 +96,7 @@ One file per configured directory: (path "~/downloads") ; required (recursive no) ; any setting from SETTINGS (ignore "*.part" "*.aria2" ".*") ; may repeat; patterns accumulate in order +(exclude (name "^keep-")) ; may repeat (rule "acme" (when (type document) @@ -126,6 +132,10 @@ re-includes; a pattern with no .Ql / matches at any depth; the last matching pattern wins. An ignored directory is not descended into. +.It Ic (exclude Ar condition No ...) +May repeat. +Sets matching files aside in this directory; see +.Sx EXCLUSIONS . .It Ic (rule Ar name item No ...) See .Sx RULES . @@ -183,7 +193,10 @@ means the root only. Built-in: unlimited. .It Ic min-age A duration. -Files modified more recently are skipped as busy. +Files modified more recently are skipped as too new. +.Xr krino 1 Ns 's +.Fl -min-age +overrides it for one run. Built-in: .Sy 2m . .It Ic max-read @@ -191,6 +204,10 @@ A size. No content is extracted from a file above this size. Built-in: .Sy 50M . +.It Ic max-size +A size. +Files above it are skipped as too big: no rule sees them. +Built-in: unlimited. .It Ic busy One or more suffixes. A file is skipped when its own name with one of these suffixes appended @@ -210,6 +227,47 @@ see Built-in: .Sy suffix . .El +.Sh EXCLUSIONS +.Bd -literal -offset indent +(exclude (type iso img)) ; by extension +(exclude (name "^keep-")) ; by name +(exclude (type pdf) (content "confidential")) ; by content +.Ed +.Pp +An +.Ic exclude +form sets files aside before any rule sees them. +Its conditions are those of +.Sx CONDITIONS , +and all of them must hold, as in +.Ic when ; +a file matching any +.Ic exclude +form is excluded. +Forms in +.Pa krino.conf +apply to every directory and are tested first, then the directory's own. +They use the directory's +.Ic case +and +.Ic fold . +.Pp +An excluded file is counted as excluded in the plan and listed, with the +form that matched, under +.Fl v ; +.Ic krino explain +traces every form, and +.Ic krino check +lists them. +No rule has run yet, so +.Ic (matched) +is never true inside an +.Ic exclude . +Unlike +.Ic ignore , +which never opens a file, an +.Ic exclude +can test size and content. .Sh RULES .Bd -literal -offset indent (rule NAME ITEM...) |
