aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md14
-rw-r--r--README.md4
-rw-r--r--cmd/krino/check.go3
-rw-r--r--cmd/krino/common.go26
-rw-r--r--cmd/krino/exclude_test.go136
-rw-r--r--cmd/krino/explain.go18
-rw-r--r--cmd/krino/main.go3
-rw-r--r--cmd/krino/render.go37
-rw-r--r--cmd/krino/sort.go9
-rw-r--r--docs/design.md32
-rw-r--r--internal/config/dir.go38
-rw-r--r--internal/config/dir_test.go36
-rw-r--r--internal/config/main.go7
-rw-r--r--internal/config/main_test.go18
-rw-r--r--internal/config/settings.go15
-rw-r--r--internal/config/settings_test.go16
-rw-r--r--internal/config/skel/krino.conf7
-rw-r--r--internal/config/skel/template.conf6
-rw-r--r--internal/engine/engine.go47
-rw-r--r--internal/engine/exclude_test.go209
-rw-r--r--internal/engine/match.go49
-rw-r--r--internal/scan/scan.go8
-rw-r--r--internal/scan/scan_test.go32
-rw-r--r--man/krino.120
-rw-r--r--man/krino.conf.560
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
diff --git a/README.md b/README.md
index 77733f5..b777ff9 100644
--- a/README.md
+++ b/README.md
@@ -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...)