aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 15:16:55 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 15:16:55 +0200
commit0b5d0eb92c5be2f0ddb2fa73990f31e5654e57fe (patch)
tree358e331945b8206ed4a72703e3aedfe8ea7cdcdb
parent1d3f2d1e4c59867024470d3444e12698b7ebb22e (diff)
downloadkrino-0b5d0eb92c5be2f0ddb2fa73990f31e5654e57fe.tar.gz
krino-0b5d0eb92c5be2f0ddb2fa73990f31e5654e57fe.zip
krino: 0.0.5 — keyword cache, t and d in reviewv0.0.5
-rw-r--r--CHANGELOG.md14
-rw-r--r--README.md6
-rw-r--r--cmd/krino/cache_test.go47
-rw-r--r--cmd/krino/check.go1
-rw-r--r--cmd/krino/common.go6
-rw-r--r--cmd/krino/explain.go1
-rw-r--r--cmd/krino/history_test.go16
-rw-r--r--cmd/krino/review.go99
-rw-r--r--cmd/krino/review_test.go90
-rw-r--r--cmd/krino/sort.go7
-rw-r--r--cmd/krino/undo.go12
-rw-r--r--docs/design.md53
-rw-r--r--internal/cond/compile.go1
-rw-r--r--internal/cond/compile_test.go21
-rw-r--r--internal/cond/eval.go17
-rw-r--r--internal/cond/eval_test.go12
-rw-r--r--internal/cond/types.go24
-rw-r--r--internal/engine/cache_test.go224
-rw-r--r--internal/engine/engine.go58
-rw-r--r--internal/engine/engine_test.go33
-rw-r--r--internal/engine/exclude_test.go8
-rw-r--r--internal/engine/facts.go111
-rw-r--r--internal/engine/facts_test.go69
-rw-r--r--internal/engine/match.go45
-rw-r--r--internal/extract/extract.go26
-rw-r--r--internal/extract/tools_test.go24
-rw-r--r--internal/kwcache/kwcache.go231
-rw-r--r--internal/kwcache/kwcache_test.go180
-rw-r--r--internal/scan/fileid_other.go10
-rw-r--r--internal/scan/fileid_unix.go19
-rw-r--r--internal/scan/scan.go29
-rw-r--r--internal/scan/scan_test.go26
-rw-r--r--internal/xdg/xdg.go3
-rw-r--r--man/krino.134
-rw-r--r--man/krino.conf.522
35 files changed, 1353 insertions, 226 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43ab3e5..da39c06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
## Unreleased
+## 0.0.5 — 2026-09-14
+
+- Keyword cache: for each file whose text it extracts, krino records which
+ content keywords the text contains, in `~/.cache/krino/NAME.cache`, and
+ answers content tests from it while the file is unchanged. A rerun over
+ the same files extracts nothing: on a real downloads folder a dry run
+ went from 12.8 s to 0.13 s. No text and no file names are stored. A
+ changed file, a new keyword, or a changed extraction tool means reading
+ again; failures are never cached. `krino check` shows where the cache is.
+- Choosing per file: `t` sends the file to the Trash and `d` deletes it
+ permanently, after a `y` to confirm, instead of what its rules planned;
+ both are logged under the rule `(review)`. "Done, apply chosen so far"
+ moves from `d` to `w`, in undo's review too.
+
## 0.0.4 — 2026-09-14
- `max-size` setting: files larger than it are skipped as "too big", in
diff --git a/README.md b/README.md
index b777ff9..2b42628 100644
--- a/README.md
+++ b/README.md
@@ -127,10 +127,12 @@ key at a time, no Enter needed:
move → Filed/Acme/
rule acme
because name "acme"
- [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing
+ [y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing
```
-Approval is per file: a file's whole chain runs, or none of it. A README
+Approval is per file: a file's whole chain runs, or none of it. `t` sends
+the file to the Trash and `d` deletes it permanently (after a `y`) instead
+of what the rules planned; `w` applies what you chose so far. A README
can't paste a session it didn't actually run in a terminal, so here we
apply directly with `-y`, which shows the same plan and applies it without
asking:
diff --git a/cmd/krino/cache_test.go b/cmd/krino/cache_test.go
new file mode 100644
index 0000000..65526b3
--- /dev/null
+++ b/cmd/krino/cache_test.go
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestRunWritesKeywordCache: a dry run over a directory with a content rule
+// leaves a private cache under ~/.cache/krino, and check says where it is.
+func TestRunWritesKeywordCache(t *testing.T) {
+ h := home(t)
+ dl := filepath.Join(h, "dl")
+ os.MkdirAll(dl, 0o755)
+ p := filepath.Join(dl, "a.txt")
+ os.WriteFile(p, []byte("invoice from acme ltd"), 0o644)
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ os.Chtimes(p, old, old)
+ if code, _, errOut := runCLI(t, "init"); code != 0 {
+ t.Fatal(errOut)
+ }
+ if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 {
+ t.Fatal(errOut)
+ }
+ rules := "(path \"~/dl\")\n(rule \"acme\" (when (content \"acme ltd\")) (move \"Acme\"))\n"
+ os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644)
+
+ for i := 0; i < 2; i++ {
+ if code, out, errOut := runCLI(t, "-n"); code != 0 || !strings.Contains(out, "a.txt") {
+ t.Fatalf("run %d: exit %d\n%s\n%s", i, code, out, errOut)
+ }
+ }
+ fi, err := os.Stat(filepath.Join(h, ".cache", "krino", "dl.cache"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o600 {
+ t.Errorf("cache mode %o, want 600", fi.Mode().Perm())
+ }
+ if _, out, _ := runCLI(t, "check"); !strings.Contains(out, "cache: ~/.cache/krino\n") {
+ t.Errorf("check does not show the cache directory:\n%s", out)
+ }
+}
diff --git a/cmd/krino/check.go b/cmd/krino/check.go
index dfe3a1a..4463c73 100644
--- a/cmd/krino/check.go
+++ b/cmd/krino/check.go
@@ -28,6 +28,7 @@ func cmdCheck(g *globals, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "config: %s\n", xdg.Abbrev(r.MainFile))
fmt.Fprintf(stdout, "log: %s\n", xdg.Abbrev(r.LogFile))
+ fmt.Fprintf(stdout, "cache: %s\n", xdg.Abbrev(cacheDir()))
if len(r.Dirs) == 0 {
fmt.Fprintln(stdout, "no directories included; add one with: krino new NAME PATH")
}
diff --git a/cmd/krino/common.go b/cmd/krino/common.go
index 4552441..11e73c5 100644
--- a/cmd/krino/common.go
+++ b/cmd/krino/common.go
@@ -5,6 +5,7 @@ package main
import (
"fmt"
"io"
+ "path/filepath"
"strings"
"time"
@@ -30,6 +31,11 @@ func minAgeOverride(g *globals) (d time.Duration, set bool, err error) {
return d, true, nil
}
+// cacheDir is where every directory's keyword cache lives (spec §6.1).
+func cacheDir() string {
+ return filepath.Join(xdg.CacheHome(), "krino")
+}
+
// 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 {
diff --git a/cmd/krino/explain.go b/cmd/krino/explain.go
index 6f8fa26..8fade24 100644
--- a/cmd/krino/explain.go
+++ b/cmd/krino/explain.go
@@ -39,6 +39,7 @@ func cmdExplain(g *globals, args []string, stdout, stderr io.Writer) int {
if setMinAge {
applyMinAge(e, minAge)
}
+ e.CacheDir = cacheDir()
x, err := e.Explain(context.Background(), xdg.Expand(fs.Arg(0)))
if err != nil {
diff --git a/cmd/krino/history_test.go b/cmd/krino/history_test.go
index f76ac66..5d86b6f 100644
--- a/cmd/krino/history_test.go
+++ b/cmd/krino/history_test.go
@@ -182,6 +182,22 @@ func TestReviewUndoChoosePerFile(t *testing.T) {
}
}
+// TestReviewUndoWriteStopsAsking: [w] applies what was chosen so far; undo
+// offers no [t] or [d], so those are rejected keys there.
+func TestReviewUndoWriteStopsAsking(t *testing.T) {
+ out := new(strings.Builder)
+ approved, action, err := reviewUndoFiles(strings.NewReader("ctdyw"), out, undoFiles("a", "b", "c"), palette{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if action != 'c' || !approved[0] || approved[1] || approved[2] {
+ t.Errorf("approved = %v action = %q; want only index 0", approved, action)
+ }
+ if !strings.Contains(out.String(), "'t' is not y, n, a, w or q") || strings.Contains(out.String(), "[t]") {
+ t.Errorf("undo review should reject t and not offer it:\n%s", out)
+ }
+}
+
// TestReviewUndoRefusedFileNotPrompted is spec §10: a file PlanUndo already
// refused is shown (with its reason - covered end to end by
// TestUndoRefusesChangedDestination-style flows through cmdUndo) but never
diff --git a/cmd/krino/review.go b/cmd/krino/review.go
index 66e17b9..29b3915 100644
--- a/cmd/krino/review.go
+++ b/cmd/krino/review.go
@@ -21,7 +21,7 @@ import (
// directory-level table does (root-relative inside root, ~-abbreviated
// outside it) instead of always falling back to the abbreviated form. p
// styles the prompts and the per-file steps (spec §8.2).
-func reviewDir(out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, rune, error) {
+func reviewDir(out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, map[string]plan.Kind, rune, error) {
return reviewChains(keyReader{stdin}, out, chains, root, p)
}
@@ -47,17 +47,18 @@ func (k keyReader) Read(p []byte) (int, error) {
//
// menu, and, for [c], the per-file
//
-// [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing
+// [y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing
//
// prompt. action is always one of 'a', 'c', 's' or 'q': a [c] session's own
// [q] ("quit, apply nothing") folds into the same 'q' the caller already
// handles for the top-level menu, and approved is emptied to match - even a
// file already marked yes in that session is discarded, per spec §8.3's
-// wording ("apply nothing"), unlike [d] ("apply chosen so far"), which
-// keeps it. root is the directory being reviewed - passed only to
+// wording ("apply nothing"), unlike [w] ("apply chosen so far"), which
+// keeps it. replaced holds the files [t] or [d] chose to trash or delete
+// instead of what the rules planned; replaceChains applies it. root is the directory being reviewed - passed only to
// reviewPerFile's destination rendering (review finding 1, fix round
// 2026-09-12); nothing here uses it directly.
-func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, rune, error) {
+func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (map[string]bool, map[string]plan.Kind, rune, error) {
fmt.Fprintln(out)
for _, l := range wrapped("", "[a] apply all [c] choose per file [s] skip this directory [q] quit", 0, widthPolicy(out), p.keys) {
fmt.Fprintln(out, l)
@@ -65,24 +66,24 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string,
for {
key, err := readKey(in)
if err != nil {
- return nil, 0, err
+ return nil, nil, 0, err
}
switch key {
case 'a':
- return approveAll(chains), 'a', nil
+ return approveAll(chains), nil, 'a', nil
case 's':
- return map[string]bool{}, 's', nil
+ return map[string]bool{}, nil, 's', nil
case 'q':
- return map[string]bool{}, 'q', nil
+ return map[string]bool{}, nil, 'q', nil
case 'c':
- approved, quit, err := reviewPerFile(in, out, chains, root, p)
+ approved, replaced, quit, err := reviewPerFile(in, out, chains, root, p)
if err != nil {
- return nil, 0, err
+ return nil, nil, 0, err
}
if quit {
- return map[string]bool{}, 'q', nil
+ return map[string]bool{}, nil, 'q', nil
}
- return approved, 'c', nil
+ return approved, replaced, 'c', nil
default:
fmt.Fprintf(out, "%q is not a, c, s or q\n", key)
}
@@ -94,14 +95,18 @@ func reviewChains(in io.Reader, out io.Writer, chains []plan.Chain, root string,
// Each file shows the same block body the plan shows (stepLines): every
// step, its rule and its reason, wrapped to the terminal. [y]/[n] decide
// just that file; [a] approves it and every remaining file without asking
-// again; [d] stops asking and applies whatever was already chosen,
+// again; [t] and [d] approve it with its chain replaced by one trash or
+// permanent delete step, [d] only after a y to its own confirmation (any
+// other key asks about the same file again); [w] stops asking and applies
+// whatever was already chosen,
// declining the rest; [q] aborts the review entirely, discarding even files
// already marked yes - reported back to reviewChains via quit=true. root is
// passed down so a destination inside root renders root-relative and one
// outside it renders ~-abbreviated, exactly as in the plan (review finding
// 1, fix round 2026-09-12).
-func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (approved map[string]bool, quit bool, err error) {
+func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string, p palette) (approved map[string]bool, replaced map[string]plan.Kind, quit bool, err error) {
approved = map[string]bool{}
+ replaced = map[string]plan.Kind{}
yesRest := false
for i, c := range chains {
if yesRest {
@@ -113,14 +118,17 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string
for _, l := range stepLines(c, 7, root, p, widthPolicy(out)) {
fmt.Fprintln(out, l)
}
- for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) {
- fmt.Fprintln(out, l)
- }
-
+ showKeys := true
for {
+ if showKeys {
+ for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) {
+ fmt.Fprintln(out, l)
+ }
+ showKeys = false
+ }
key, kerr := readKey(in)
if kerr != nil {
- return nil, false, kerr
+ return nil, nil, false, kerr
}
switch key {
case 'y':
@@ -130,23 +138,60 @@ func reviewPerFile(in io.Reader, out io.Writer, chains []plan.Chain, root string
case 'a':
approved[c.File.Rel] = true
yesRest = true
+ case 't':
+ approved[c.File.Rel] = true
+ replaced[c.File.Rel] = plan.Trash
case 'd':
- return approved, false, nil
+ fmt.Fprintf(out, " delete %s permanently? [y/N] ", c.File.Rel)
+ confirm, kerr := readKey(in)
+ if kerr != nil {
+ return nil, nil, false, kerr
+ }
+ fmt.Fprintln(out)
+ if confirm != 'y' {
+ fmt.Fprintln(out, " not deleted")
+ showKeys = true
+ continue
+ }
+ approved[c.File.Rel] = true
+ replaced[c.File.Rel] = plan.DeletePermanent
+ case 'w':
+ return approved, replaced, false, nil
case 'q':
- return nil, true, nil
+ return nil, nil, true, nil
default:
- fmt.Fprintf(out, "%q is not y, n, a, d or q\n", key)
+ fmt.Fprintf(out, "%q is not y, n, a, t, d, w or q\n", key)
continue
}
break
}
}
- return approved, false, nil
+ return approved, replaced, false, nil
}
-// perFileKeys is the per-file prompt of spec §8.3, shared by review and
-// undo, and wrapped to the terminal like every other long line.
-const perFileKeys = "[y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing"
+// perFileKeys is the per-file prompt of spec §8.3, wrapped to the terminal
+// like every other long line. Undo's has no [t] or [d] (undoPerFileKeys).
+const perFileKeys = "[y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing"
+
+// reviewRule is the rule name a step chosen in review is planned and logged
+// under, in parentheses so no configured rule can be mistaken for it.
+const reviewRule = "(review)"
+
+// replaceChains returns chains with the chain of every file in replaced
+// swapped for one step of the chosen kind on the file itself, under
+// reviewRule: [t] and [d] set aside what the rules planned. chains itself
+// is not modified.
+func replaceChains(chains []plan.Chain, replaced map[string]plan.Kind) []plan.Chain {
+ out := make([]plan.Chain, len(chains))
+ for i, c := range chains {
+ if k, ok := replaced[c.File.Rel]; ok {
+ c.Steps = []plan.Step{{Kind: k, Rule: reviewRule, Src: c.File.Path, Reason: "chosen in review"}}
+ c.Warnings = nil
+ }
+ out[i] = c
+ }
+ return out
+}
// readKey reads the single byte reviewChains treats as one keypress. Over
// the real terminal that byte already came from tui.ReadKey (via
diff --git a/cmd/krino/review_test.go b/cmd/krino/review_test.go
index a387022..19a1d01 100644
--- a/cmd/krino/review_test.go
+++ b/cmd/krino/review_test.go
@@ -22,7 +22,7 @@ func chains(rels ...string) []plan.Chain {
func TestChoosePerFile(t *testing.T) {
// c enters per-file mode, then y n y for three files.
- approved, action, err := reviewChains(strings.NewReader("cyny"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
+ approved, _, action, err := reviewChains(strings.NewReader("cyny"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
if err != nil {
t.Fatal(err)
}
@@ -35,31 +35,31 @@ func TestChoosePerFile(t *testing.T) {
}
func TestApplyAllAndSkip(t *testing.T) {
- approved, action, _ := reviewChains(strings.NewReader("a"), new(strings.Builder), chains("a", "b"), "", palette{})
+ approved, _, action, _ := reviewChains(strings.NewReader("a"), new(strings.Builder), chains("a", "b"), "", palette{})
if action != 'a' || len(approved) != 2 {
t.Errorf("[a] = %q %v; want every file approved", action, approved)
}
- approved, action, _ = reviewChains(strings.NewReader("s"), new(strings.Builder), chains("a", "b"), "", palette{})
+ approved, _, action, _ = reviewChains(strings.NewReader("s"), new(strings.Builder), chains("a", "b"), "", palette{})
if action != 's' || len(approved) != 0 {
t.Errorf("[s] = %q %v; want nothing approved", action, approved)
}
}
-func TestPerFileDoneStopsAsking(t *testing.T) {
- // c, y for the first, then d: apply what was chosen so far.
- approved, _, _ := reviewChains(strings.NewReader("cyd"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
+func TestPerFileWriteStopsAsking(t *testing.T) {
+ // c, y for the first, then w: apply what was chosen so far.
+ approved, _, _, _ := reviewChains(strings.NewReader("cyw"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
if !approved["a"] || approved["b"] || approved["c"] {
t.Errorf("approved = %v; want only a", approved)
}
}
// TestPerFileQuitAppliesNothing: spec §8.3's [q] on the per-file prompt is
-// "quit, apply nothing" - stronger than [d], which keeps what was already
+// "quit, apply nothing" - stronger than [w], which keeps what was already
// chosen. It folds into the same top-level 'q' the caller already handles
// for the directory-level menu (spec §8.2's [q]), and discards even a file
// already marked yes.
func TestPerFileQuitAppliesNothing(t *testing.T) {
- approved, action, err := reviewChains(strings.NewReader("cyq"), new(strings.Builder), chains("a", "b"), "", palette{})
+ approved, _, action, err := reviewChains(strings.NewReader("cyq"), new(strings.Builder), chains("a", "b"), "", palette{})
if err != nil {
t.Fatal(err)
}
@@ -74,7 +74,7 @@ func TestPerFileQuitAppliesNothing(t *testing.T) {
// TestPerFileYesToAllRemaining: spec §8.3's [a] mid-review approves the
// current file and every remaining one without asking again.
func TestPerFileYesToAllRemaining(t *testing.T) {
- approved, action, err := reviewChains(strings.NewReader("ca"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
+ approved, _, action, err := reviewChains(strings.NewReader("ca"), new(strings.Builder), chains("a", "b", "c"), "", palette{})
if err != nil {
t.Fatal(err)
}
@@ -88,7 +88,7 @@ func TestPerFileYesToAllRemaining(t *testing.T) {
// same prompt is read again.
func TestInvalidKeyReprompts(t *testing.T) {
out := new(strings.Builder)
- approved, action, err := reviewChains(strings.NewReader("zs"), out, chains("a"), "", palette{})
+ approved, _, action, err := reviewChains(strings.NewReader("zs"), out, chains("a"), "", palette{})
if err != nil {
t.Fatal(err)
}
@@ -115,7 +115,7 @@ func TestPerFileDestinationIsRootRelative(t *testing.T) {
{File: scan.File{Rel: "outside.txt"}, Steps: []plan.Step{{Kind: plan.Move, Dst: "/home/x/backup/outside.txt"}}},
}
out := new(strings.Builder)
- if _, _, err := reviewChains(strings.NewReader("cyy"), out, cs, root, palette{}); err != nil {
+ if _, _, _, err := reviewChains(strings.NewReader("cyy"), out, cs, root, palette{}); err != nil {
t.Fatal(err)
}
text := out.String()
@@ -134,7 +134,7 @@ func TestPerFileShowsTheWholeBlock(t *testing.T) {
{Kind: plan.Move, Rule: "acme", Dst: "/w/a.pdf", Reason: `content "acme ltd"`},
}}}
out := new(strings.Builder)
- if _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil {
+ if _, _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil {
t.Fatal(err)
}
want := "\n[1/1] a.pdf\n move → /w/\n rule acme\n because content \"acme ltd\"\n"
@@ -154,7 +154,7 @@ func TestPerFileWrapsToTheTerminal(t *testing.T) {
{Kind: plan.Move, Rule: "acme", Dst: "/w/some/deeply/nested/destination/directory/for/invoices/a.pdf", Reason: `content "acme ltd"`},
}}}
out := new(strings.Builder)
- if _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil {
+ if _, _, _, err := reviewChains(strings.NewReader("cy"), out, cs, "", palette{}); err != nil {
t.Fatal(err)
}
var prompt string
@@ -170,3 +170,67 @@ func TestPerFileWrapsToTheTerminal(t *testing.T) {
t.Errorf("wrapped prompt reads %q, want %q\n%s", prompt, perFileKeys, out)
}
}
+
+// TestPerFileTrashAndDelete: [t] and [d] (confirmed with y) replace what the
+// rules planned for that file with one step, and approve it.
+func TestPerFileTrashAndDelete(t *testing.T) {
+ out := new(strings.Builder)
+ approved, replaced, action, err := reviewChains(strings.NewReader("ctdyn"), out, chains("a", "b", "c"), "", palette{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if action != 'c' || !approved["a"] || !approved["b"] || approved["c"] {
+ t.Errorf("approved = %v action = %q; want a and b", approved, action)
+ }
+ if len(replaced) != 2 || replaced["a"] != plan.Trash || replaced["b"] != plan.DeletePermanent {
+ t.Errorf("replaced = %v; want a trash, b deleted permanently", replaced)
+ }
+ if !strings.Contains(out.String(), "delete b permanently? [y/N]") {
+ t.Errorf("no confirmation asked:\n%s", out)
+ }
+}
+
+// TestPerFileDeleteNeedsConfirmation: any key but y after [d] deletes
+// nothing and asks about the same file again.
+func TestPerFileDeleteNeedsConfirmation(t *testing.T) {
+ out := new(strings.Builder)
+ approved, replaced, _, err := reviewChains(strings.NewReader("cdny"), out, chains("a"), "", palette{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(replaced) != 0 || !approved["a"] {
+ t.Errorf("approved = %v replaced = %v; want a approved as planned, nothing replaced", approved, replaced)
+ }
+ if strings.Count(out.String(), perFileKeys) != 2 {
+ t.Errorf("the prompt should be shown again after a cancelled delete:\n%s", out)
+ }
+}
+
+// TestPerFileQuitDiscardsReplacements: [q] after [t] applies nothing, the
+// trash included.
+func TestPerFileQuitDiscardsReplacements(t *testing.T) {
+ approved, replaced, action, _ := reviewChains(strings.NewReader("ctq"), new(strings.Builder), chains("a", "b"), "", palette{})
+ if action != 'q' || len(approved) != 0 || len(replaced) != 0 {
+ t.Errorf("approved = %v replaced = %v action = %q; want nothing", approved, replaced, action)
+ }
+}
+
+// TestReplaceChains: a replaced file's chain becomes one step of the chosen
+// kind on the file itself, under the rule name (review); other chains keep
+// their steps.
+func TestReplaceChains(t *testing.T) {
+ cs := chains("a", "b")
+ cs[0].File.Path = "/dl/a"
+ cs[0].Steps = append(cs[0].Steps, plan.Step{Kind: plan.Copy, Dst: "/w/copy"})
+ got := replaceChains(cs, map[string]plan.Kind{"a": plan.DeletePermanent})
+ want := plan.Step{Kind: plan.DeletePermanent, Rule: "(review)", Src: "/dl/a", Reason: "chosen in review"}
+ if len(got[0].Steps) != 1 || got[0].Steps[0] != want {
+ t.Errorf("a's steps = %+v; want only %+v", got[0].Steps, want)
+ }
+ if len(got[1].Steps) != 1 || got[1].Steps[0].Kind != plan.Move {
+ t.Errorf("b's steps changed: %+v", got[1].Steps)
+ }
+ if len(cs[0].Steps) != 2 {
+ t.Errorf("replaceChains changed its input: %+v", cs[0].Steps)
+ }
+}
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index 9e714ba..3e5d951 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -69,6 +69,7 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
if setMinAge {
applyMinAge(e, minAge)
}
+ e.CacheDir = cacheDir()
p := palette{on: colourOn(g, stdout)}
// Spec §8.4: with neither -y nor -n, krino asks; asking a non-terminal
@@ -213,12 +214,13 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
}
var approved map[string]bool
+ var replaced map[string]plan.Kind
var action rune
if g.yes {
approved, action = approveAll(actionable), 'a'
} else {
var rerr error
- approved, action, rerr = reviewDir(stdout, actionable, d.Root, p)
+ approved, replaced, action, rerr = reviewDir(stdout, actionable, d.Root, p)
if rerr != nil {
fmt.Fprintf(stderr, "krino: %s: %v\n", d.Name, rerr)
exit = 1
@@ -242,6 +244,9 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
return true
}
+ // [t] and [d] in review: the file gets the one step chosen
+ // there instead of the chain its rules planned.
+ dp.Chains = replaceChains(dp.Chains, replaced)
res, aerr := e.Apply(ctx, dp, approved, j, run)
if aerr != nil {
if errors.Is(aerr, context.Canceled) || errors.Is(aerr, context.DeadlineExceeded) {
diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go
index d31a5b3..036e3c0 100644
--- a/cmd/krino/undo.go
+++ b/cmd/krino/undo.go
@@ -325,7 +325,7 @@ func reviewUndoDir(out io.Writer, files []engine.UndoFile, p palette) (map[int]b
//
// menu, and, for [c], the per-file
//
-// [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing
+// [y] yes [n] no [a] yes to this and all remaining [w] write, apply chosen so far [q] quit, apply nothing
//
// prompt - the same shape as review.go's reviewChains/reviewPerFile, over a
// different plan shape (approved is keyed by index into files, not by
@@ -386,7 +386,7 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p
continue
}
- for _, l := range wrapped(" ", perFileKeys, 2, widthPolicy(out), p.keys) {
+ for _, l := range wrapped(" ", undoPerFileKeys, 2, widthPolicy(out), p.keys) {
fmt.Fprintln(out, l)
}
for {
@@ -402,12 +402,12 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p
case 'a':
approved[i] = true
yesRest = true
- case 'd':
+ case 'w':
return approved, false, nil
case 'q':
return nil, true, nil
default:
- fmt.Fprintf(out, "%q is not y, n, a, d or q\n", key)
+ fmt.Fprintf(out, "%q is not y, n, a, w or q\n", key)
continue
}
break
@@ -416,6 +416,10 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p
return approved, false, nil
}
+// undoPerFileKeys is undo's per-file prompt: review's without [t] and [d],
+// which make no sense for a reversal.
+const undoPerFileKeys = "[y] yes [n] no [a] yes to this and all remaining [w] write, apply chosen so far [q] quit, apply nothing"
+
// approveAllUndo approves every reversible file in files by index - [a]
// apply all, at either the top level or mid per-file review. A refused file
// is never marked true: nothing would happen to it anyway (ApplyUndo skips
diff --git a/docs/design.md b/docs/design.md
index 3130870..271e2a6 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -5,7 +5,9 @@ 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);
amended again 2026-09-14 for 0.0.4: `max-size` (§4.4), `(exclude ...)`
-(§4.2, §4.3, §4.6) and `--min-age` (§11).
+(§4.2, §4.3, §4.6) and `--min-age` (§11); amended again 2026-09-14 for
+0.0.5: the keyword cache (§3, §6.1, §13), and `t`, `d` and `w` in review
+(§8.3, §10).
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,
@@ -59,6 +61,8 @@ $XDG_CONFIG_HOME/krino/ default ~/.config/krino
$XDG_STATE_HOME/krino/ default ~/.local/state/krino
krino.log the log (§9)
<name>.lock per-directory lock while a run is active
+$XDG_CACHE_HOME/krino/ default ~/.cache/krino
+ <name>.cache keyword cache (§6.1), 0600, safe to delete
```
`krino init` creates the config directory with a commented `krino.conf` and
@@ -323,6 +327,35 @@ so a keyword split across lines in a PDF still matches. Matching is
substring: `"acme"` matches `"acmeco"`. Words hyphenated across lines in a
PDF are not rejoined.
+### 6.1 Keyword cache
+
+Extraction is nearly all of a run's time, so krino remembers what it found.
+For each file it extracted, `<name>.cache` records which of the
+directory's content keywords the text contains, against the full list of
+keywords it was checked against. It stores no text and no file names; a
+file is known by device, inode, size and modification time, which krino's
+own moves and renames keep. The keywords themselves are stored, as they
+appear in the config.
+
+- When a file is extracted, every content keyword of its directory is
+ answered at once, so one extraction serves every rule and exclude.
+- A content test is answered from the cache when the file's entry covers
+ all of the test's keywords. Otherwise the file is extracted and its entry
+ replaced: a changed file, or a new keyword, costs one extraction.
+- Files above `max-read` are refused before the cache is consulted.
+ Failures (unreadable, tool missing, timeout) are never cached.
+- The cache is discarded whole when the extractor fingerprint changes: a
+ tool installed, removed or replaced, or krino's extraction code changing
+ (`extract.Version`).
+- Planning a run (`-n` included) reads the cache and writes it back holding
+ only files still in the directory, under the directory's lock, via a
+ temporary file renamed into place; the directory is 0700, the file 0600.
+ `explain` reads it and never writes. A directory with no content tests
+ has no cache.
+- Known gap: a file edited in place with its size and modification time
+ deliberately preserved keeps its old answers. Deleting
+ `~/.cache/krino` resets everything.
+
## 7. Actions
### 7.1 Chains
@@ -525,10 +558,17 @@ list:
move → Work/Acme/2026/
rule acme
because name \bacme\b
- [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing
+ [y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing
```
-Approval is per file: the whole chain or none of it.
+Approval is per file: the whole chain or none of it. `t` and `d` approve
+the file with its chain replaced by a single step on the file itself: to the
+Trash, or deleted permanently. They are the user's own decision, logged
+under the rule name `(review)`; `(review)` steps are not subject to the
+duplicate protection of §5.5, which governs rules. `d` asks
+`delete NAME permanently? [y/N]`, and any key but `y` deletes nothing and
+asks about the file again. `w` stops asking and applies what was chosen so
+far. `q` applies nothing in this directory, choices included.
### 8.4 Modes
@@ -569,7 +609,7 @@ not logged; declined files are.
is undone by naming it: `krino undo RUN`. Undo runs cannot themselves be
undone.
- Undo builds a plan like any other, shown and approved the same way
- (`-y` and `-n` apply).
+ (`-y` and `-n` apply). Its per-file prompt has no `t` or `d`.
Reversals, last step first within each file:
@@ -675,6 +715,11 @@ Rules for the GUI to come:
about krino, and would fail or pass for reasons outside its control.
Report the measurement; promise nothing. `make bench` runs the Go
benchmarks on generated trees.
+- Keyword cache (§6.1, 0.0.5): measured on the same kind of folder, 170
+ files, a dry run took 12.78 s with an empty cache and 0.13 s with a warm
+ one, printing the identical plan. Without the cache, 59 `pdftotext` runs
+ took 21.5 s of wall time between them, one PDF 14.7 s alone; with
+ extraction answered instantly, the run took 0.25 s.
## 14. Build, dependencies, release
diff --git a/internal/cond/compile.go b/internal/cond/compile.go
index 590d61a..94118ac 100644
--- a/internal/cond/compile.go
+++ b/internal/cond/compile.go
@@ -266,6 +266,7 @@ func (c *compiler) compileContent(n *sexp.Node) *node {
continue
}
keywords = append(keywords, keyword{norm: normed, src: a.Text})
+ c.cond.Keywords = append(c.cond.Keywords, Keyword{Opt: c.opt, Norm: normed})
}
if bad {
return nil
diff --git a/internal/cond/compile_test.go b/internal/cond/compile_test.go
index 2a07ad6..8432365 100644
--- a/internal/cond/compile_test.go
+++ b/internal/cond/compile_test.go
@@ -165,3 +165,24 @@ func TestGroupsMatchSpec(t *testing.T) {
}
}
}
+
+// TestCompileCollectsKeywords: every content keyword is recorded as
+// compared, normalised under the compile options, and its key names those
+// options.
+func TestCompileCollectsKeywords(t *testing.T) {
+ opt := Options{IgnoreCase: true, Fold: true}
+ c, errs := Compile("d.conf", nodes(t, `(or (content "Spółka" "x") (not (content "NIP")))`), opt)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ want := []Keyword{{Opt: opt, Norm: "spolka"}, {Opt: opt, Norm: "x"}, {Opt: opt, Norm: "nip"}}
+ if !reflect.DeepEqual(c.Keywords, want) {
+ t.Errorf("Keywords = %+v, want %+v", c.Keywords, want)
+ }
+ if got := want[0].Key(); got != "if:spolka" {
+ t.Errorf("Key = %q", got)
+ }
+ if got := KeywordKey(Options{}, "NIP"); got != "sn:NIP" {
+ t.Errorf("strict, unfolded key = %q", got)
+ }
+}
diff --git a/internal/cond/eval.go b/internal/cond/eval.go
index e3093af..3cec110 100644
--- a/internal/cond/eval.go
+++ b/internal/cond/eval.go
@@ -19,7 +19,10 @@ type Facts interface {
Size() int64
ModTime() time.Time
Now() time.Time
- Content(ignoreCase, fold bool) (string, error) // normalised with norm.Text
+ // ContentContains reports the index of the first of keywords, each
+ // normalised under opt with norm.Text, that the file's text contains, or
+ // -1 when it contains none.
+ ContentContains(opt Options, keywords []string) (int, error)
Duplicate(dirs []string) (original string, ok bool, err error)
Matched() bool // an earlier rule matched this file
}
@@ -167,14 +170,16 @@ func (c *Cond) evalLeaf(n *node, f Facts) (ok bool, reason, warn string, caps []
return false, "", "", nil
case kContent:
- text, err := f.Content(c.opt.IgnoreCase, c.opt.Fold)
+ norms := make([]string, len(n.keywords))
+ for i, kw := range n.keywords {
+ norms[i] = kw.norm
+ }
+ i, err := f.ContentContains(c.opt, norms)
if err != nil {
return false, "", "content unreadable: " + err.Error(), nil
}
- for _, kw := range n.keywords {
- if strings.Contains(text, kw.norm) {
- return true, `content "` + kw.src + `"`, "", nil
- }
+ if i >= 0 {
+ return true, `content "` + n.keywords[i].src + `"`, "", nil
}
return false, "", "", nil
diff --git a/internal/cond/eval_test.go b/internal/cond/eval_test.go
index 3607978..8891de7 100644
--- a/internal/cond/eval_test.go
+++ b/internal/cond/eval_test.go
@@ -37,12 +37,18 @@ func (f *fake) Size() int64 { return f.size }
func (f *fake) ModTime() time.Time { return now.Add(-f.age) }
func (f *fake) Now() time.Time { return now }
func (f *fake) Matched() bool { return f.matched }
-func (f *fake) Content(ic, fold bool) (string, error) {
+func (f *fake) ContentContains(opt Options, keywords []string) (int, error) {
f.contentCalls++
if f.rawErr != nil {
- return "", f.rawErr
+ return -1, f.rawErr
}
- return norm.Text(f.raw, ic, fold), nil
+ text := norm.Text(f.raw, opt.IgnoreCase, opt.Fold)
+ for i, kw := range keywords {
+ if strings.Contains(text, kw) {
+ return i, nil
+ }
+ }
+ return -1, nil
}
func (f *fake) Duplicate(dirs []string) (string, bool, error) { return f.dupOrig, f.dupOK, nil }
diff --git a/internal/cond/types.go b/internal/cond/types.go
index 9455f63..28a4fc9 100644
--- a/internal/cond/types.go
+++ b/internal/cond/types.go
@@ -24,6 +24,30 @@ type Cond struct {
opt Options // the case/fold settings conditions were compiled with; Task 8 needs them again at eval time
UsesContent bool // some content test exists
DupDirs [][]string // the raw directory arguments of each duplicate test, in order
+ Keywords []Keyword // every content keyword, as compiled, in order
+}
+
+// Keyword is one content keyword as a content test compares it: normalised
+// under the options it was compiled with.
+type Keyword struct {
+ Opt Options
+ Norm string
+}
+
+// Key is the keyword's identity across runs, for the keyword cache: its
+// options and its normalised text.
+func (k Keyword) Key() string { return KeywordKey(k.Opt, k.Norm) }
+
+// KeywordKey is Keyword.Key for opt and an already normalised keyword.
+func KeywordKey(opt Options, norm string) string {
+ b := []byte("sn:")
+ if opt.IgnoreCase {
+ b[0] = 'i'
+ }
+ if opt.Fold {
+ b[1] = 'f'
+ }
+ return string(b) + norm
}
// kind is what a compiled node tests, or how it combines its children.
diff --git a/internal/engine/cache_test.go b/internal/engine/cache_test.go
new file mode 100644
index 0000000..eee2860
--- /dev/null
+++ b/internal/engine/cache_test.go
@@ -0,0 +1,224 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// countingPDFTree makes ~/dl holding the given .pdf files, and a fake
+// pdftotext first on PATH that prints the file's own bytes as its text and
+// counts its runs in a file; a file whose name contains "fail" makes it
+// exit 1.
+func countingPDFTree(t *testing.T, files map[string]string) (home, dl string, runs func() int) {
+ t.Helper()
+ home, dl = excludeTree(t, files)
+ bin := t.TempDir()
+ count := filepath.Join(t.TempDir(), "count")
+ script := "#!/bin/sh\necho x >> '" + count + "'\ncase \"$4\" in *fail*) exit 1;; esac\nexec /bin/cat \"$4\"\n"
+ if err := os.WriteFile(filepath.Join(bin, "pdftotext"), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", bin)
+ runs = func() int {
+ b, _ := os.ReadFile(count)
+ return strings.Count(string(b), "x")
+ }
+ return home, dl, runs
+}
+
+// cachedMatch loads the configuration afresh, as a new krino process
+// would, and matches dl with the cache under ~/.cache/krino.
+func cachedMatch(t *testing.T, home, main string) *Result {
+ t.Helper()
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ e.CacheDir = filepath.Join(home, ".cache", "krino")
+ r, err := e.Match(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
+func matchedNames(r *Result) string {
+ var names []string
+ for _, fm := range r.Matched {
+ names = append(names, fm.File.Rel)
+ }
+ return strings.Join(names, " ")
+}
+
+const acmeRules = `
+(path "~/dl")
+(rule "acme" (when (content "acme")) (move "Acme"))
+`
+
+// TestCacheSkipsExtractionOnRerun: the second run over unchanged files
+// extracts nothing and matches the same files.
+func TestCacheSkipsExtractionOnRerun(t *testing.T) {
+ home, _, runs := countingPDFTree(t, map[string]string{"a.pdf": "Invoice ACME", "b.pdf": "nothing here"})
+ main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules})
+
+ first := cachedMatch(t, home, main)
+ if runs() != 2 || matchedNames(first) != "a.pdf" {
+ t.Fatalf("first run: %d extractions, matched %q", runs(), matchedNames(first))
+ }
+ second := cachedMatch(t, home, main)
+ if runs() != 2 {
+ t.Errorf("second run extracted again: %d runs in all", runs())
+ }
+ if matchedNames(second) != "a.pdf" {
+ t.Errorf("second run matched %q, want a.pdf", matchedNames(second))
+ }
+}
+
+// TestCacheRereadsChangedFile: a file whose modification time changed is
+// extracted again; the other is not.
+func TestCacheRereadsChangedFile(t *testing.T) {
+ home, dl, runs := countingPDFTree(t, map[string]string{"a.pdf": "Invoice ACME", "b.pdf": "nothing here"})
+ main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules})
+ cachedMatch(t, home, main)
+
+ later := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
+ if err := os.WriteFile(filepath.Join(dl, "b.pdf"), []byte("now ACME too"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ os.Chtimes(filepath.Join(dl, "b.pdf"), later, later)
+ r := cachedMatch(t, home, main)
+ if runs() != 3 {
+ t.Errorf("%d extractions in all, want 3: only b.pdf read again", runs())
+ }
+ if matchedNames(r) != "a.pdf b.pdf" {
+ t.Errorf("matched %q, want both", matchedNames(r))
+ }
+}
+
+// TestCacheRereadsForNewKeyword: a keyword no entry was checked against
+// makes the files that reach it be read again, once.
+func TestCacheRereadsForNewKeyword(t *testing.T) {
+ home, _, runs := countingPDFTree(t, map[string]string{"a.pdf": "Invoice ACME", "b.pdf": "nothing here"})
+ main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules})
+ cachedMatch(t, home, main)
+
+ writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules + `(rule "nothing" (when (content "nothing")) (move "Nothing"))` + "\n"})
+ r := cachedMatch(t, home, main)
+ if runs() != 4 {
+ t.Errorf("%d extractions in all, want 4: both read again for the new keyword", runs())
+ }
+ if matchedNames(r) != "a.pdf b.pdf" {
+ t.Errorf("matched %q, want both", matchedNames(r))
+ }
+ cachedMatch(t, home, main)
+ if runs() != 4 {
+ t.Errorf("third run extracted again: %d runs in all", runs())
+ }
+}
+
+// TestCacheOffWithoutCacheDir: an engine with no CacheDir reads every time
+// and writes no cache.
+func TestCacheOffWithoutCacheDir(t *testing.T) {
+ home, _, runs := countingPDFTree(t, map[string]string{"a.pdf": "Invoice ACME"})
+ main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules})
+ for i := 0; i < 2; i++ {
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ if _, err := e.Match(context.Background(), e.Dirs[0]); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if runs() != 2 {
+ t.Errorf("%d extractions, want one per run", runs())
+ }
+ if _, err := os.Stat(filepath.Join(home, ".cache")); !os.IsNotExist(err) {
+ t.Errorf("a cache was written with no CacheDir: %v", err)
+ }
+}
+
+// TestCacheDoesNotStoreFailures: a file the tool fails on is tried again
+// on every run, and warned about every time.
+func TestCacheDoesNotStoreFailures(t *testing.T) {
+ home, _, runs := countingPDFTree(t, map[string]string{"fail.pdf": "Invoice ACME"})
+ main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules})
+ for i := 1; i <= 2; i++ {
+ r := cachedMatch(t, home, main)
+ if runs() != i {
+ t.Errorf("run %d: %d extractions in all, want %d", i, runs(), i)
+ }
+ if len(r.Unmatched) != 1 || len(r.Unmatched[0].Warnings) == 0 {
+ t.Errorf("run %d: want fail.pdf unmatched with a warning: %+v", i, r.Unmatched)
+ }
+ }
+}
+
+// TestCacheRespectsMaxRead: a file over max-read is not read, even when an
+// earlier run cached its answers.
+func TestCacheRespectsMaxRead(t *testing.T) {
+ home, _, _ := countingPDFTree(t, map[string]string{"a.pdf": "Invoice ACME"})
+ main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules})
+ cachedMatch(t, home, main)
+
+ writeConfig(t, home, `(include "dl")`, map[string]string{"dl": "(max-read 1)\n" + acmeRules})
+ r := cachedMatch(t, home, main)
+ if len(r.Matched) != 0 || len(r.Unmatched) != 1 || !strings.Contains(strings.Join(r.Unmatched[0].Warnings, " "), "larger than max-read") {
+ t.Errorf("want a.pdf unmatched as larger than max-read: matched %+v unmatched %+v", r.Matched, r.Unmatched)
+ }
+}
+
+// TestCacheHoldsNoText: the cache file holds the keywords and answers, not
+// the extracted text or the file's name.
+func TestCacheHoldsNoText(t *testing.T) {
+ home, _, _ := countingPDFTree(t, map[string]string{"secret-name.pdf": "Invoice ACME confidential"})
+ main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules})
+ cachedMatch(t, home, main)
+ b, err := os.ReadFile(filepath.Join(home, ".cache", "krino", "dl.cache"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, leak := range []string{"Invoice", "invoice", "confidential", "secret-name"} {
+ if strings.Contains(string(b), leak) {
+ t.Errorf("cache holds %q:\n%s", leak, b)
+ }
+ }
+}
+
+// TestExplainUsesCacheWithoutWriting: explain answers from the cache a run
+// wrote, and never writes one itself.
+func TestExplainUsesCacheWithoutWriting(t *testing.T) {
+ home, dl, runs := countingPDFTree(t, map[string]string{"a.pdf": "Invoice ACME"})
+ main := writeConfig(t, home, `(include "dl")`, map[string]string{"dl": acmeRules})
+ explain := func() {
+ t.Helper()
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ e.CacheDir = filepath.Join(home, ".cache", "krino")
+ x, err := e.Explain(context.Background(), filepath.Join(dl, "a.pdf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(x.Rules) != 1 || !x.Rules[0].Match {
+ t.Errorf("explain: %+v", x.Rules)
+ }
+ }
+ explain()
+ if _, err := os.Stat(filepath.Join(home, ".cache")); !os.IsNotExist(err) {
+ t.Errorf("explain wrote a cache: %v", err)
+ }
+ cachedMatch(t, home, main)
+ before := runs()
+ explain()
+ if runs() != before {
+ t.Errorf("explain extracted although the run cached a.pdf")
+ }
+}
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index 8cffbd1..b50728e 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -8,6 +8,7 @@ package engine
import (
"fmt"
"os"
+ "sort"
"strings"
"time"
@@ -26,6 +27,11 @@ type Engine struct {
Extract *extract.Extractor
Now func() time.Time // time.Now; tests replace it
MainFile string
+
+ // CacheDir holds each directory's keyword cache (spec §6.1), as
+ // NAME.cache; "" means no cache is read or written. Load leaves it
+ // empty: the command line sets it.
+ CacheDir string
}
// Dir is one configured directory, with its ignore matcher and rules
@@ -38,13 +44,11 @@ type Dir struct {
Ignore *ignore.Matcher
Rules []*Rule
- // ContentVariants is the distinct (IgnoreCase, Fold) pairs any of
- // Rules' content tests evaluate under, in first-seen order. B2: when
- // this holds exactly one variant, facts.Content releases a file's raw
- // extracted text once that variant's normalised copy exists, since no
- // other variant will ever be asked for; with more than one, both must
- // stay memoised, as before.
- ContentVariants []cond.Options
+ // ContentKeywords is every content keyword the directory's excludes
+ // and rules test, once each, sorted by Key. When a file's text is
+ // extracted, every one of them is answered at once, so one extraction
+ // serves every content test and fills the keyword cache.
+ ContentKeywords []cond.Keyword
// Excludes are the (exclude ...) forms that apply here, compiled with the
// directory's settings: krino.conf's first, then the directory's own. A
@@ -135,7 +139,7 @@ func Load(mainFile string, names ...string) (*Engine, []*config.Diag) {
}
}
}
- dir.ContentVariants = contentVariants(dir.Rules, dir.Excludes, dirOpt)
+ dir.ContentKeywords = contentKeywords(dir.Rules, dir.Excludes)
dir.DupScopes = dupScopes(dir.Rules)
dirs = append(dirs, dir)
}
@@ -216,32 +220,26 @@ func dedupeNames(names []string) []string {
return out
}
-// 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, 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)
+// contentKeywords returns every content keyword excludes and rules test,
+// each once, sorted by Key: Dir.ContentKeywords.
+func contentKeywords(rules []*Rule, excludes []*Exclude) []cond.Keyword {
+ seen := map[string]bool{}
+ var out []cond.Keyword
+ add := func(c *cond.Cond) {
+ for _, k := range c.Keywords {
+ if !seen[k.Key()] {
+ seen[k.Key()] = true
+ out = append(out, k)
+ }
}
}
+ for _, x := range excludes {
+ add(x.Cond)
+ }
for _, r := range rules {
- if !r.Cond.UsesContent {
- continue
- }
- opt := cond.Options{IgnoreCase: r.Settings.Case == config.CaseIgnore, Fold: r.Settings.Fold}
- if seen[opt] {
- continue
- }
- seen[opt] = true
- out = append(out, opt)
+ add(r.Cond)
}
+ sort.Slice(out, func(i, j int) bool { return out[i].Key() < out[j].Key() })
return out
}
diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go
index 18b35e7..c38c1fc 100644
--- a/internal/engine/engine_test.go
+++ b/internal/engine/engine_test.go
@@ -46,14 +46,14 @@ func writeConfig(t *testing.T, home, main string, dirs map[string]string) string
// fakeFacts is a minimal cond.Facts for checking compiled rules.
type fakeFacts struct{ name string }
-func (f fakeFacts) Name() string { return f.name }
-func (f fakeFacts) Rel() string { return f.name }
-func (f fakeFacts) Size() int64 { return 1 }
-func (f fakeFacts) ModTime() time.Time { return time.Time{} }
-func (f fakeFacts) Now() time.Time { return time.Time{} }
-func (f fakeFacts) Content(bool, bool) (string, error) { return "", nil }
-func (f fakeFacts) Duplicate([]string) (string, bool, error) { return "", false, nil }
-func (f fakeFacts) Matched() bool { return false }
+func (f fakeFacts) Name() string { return f.name }
+func (f fakeFacts) Rel() string { return f.name }
+func (f fakeFacts) Size() int64 { return 1 }
+func (f fakeFacts) ModTime() time.Time { return time.Time{} }
+func (f fakeFacts) Now() time.Time { return time.Time{} }
+func (f fakeFacts) ContentContains(cond.Options, []string) (int, error) { return -1, nil }
+func (f fakeFacts) Duplicate([]string) (string, bool, error) { return "", false, nil }
+func (f fakeFacts) Matched() bool { return false }
var _ cond.Facts = fakeFacts{}
@@ -187,12 +187,10 @@ func TestLoadAcceptsSuppliedCaptures(t *testing.T) {
}
}
-// TestContentVariantsComputedAtLoad: B2 plumbing. Load computes each
-// directory's distinct (ignoreCase, fold) content-test variants from its
-// rules' resolved settings: a rule with no content test contributes
-// nothing; two rules sharing a variant fold into one; a rule-level (case
-// ignore) override adds a second.
-func TestContentVariantsComputedAtLoad(t *testing.T) {
+// TestContentKeywordsComputedAtLoad: Load collects every content keyword
+// of a directory's rules, once per (options, normalised keyword), sorted by
+// key: a rule-level (case ignore) makes the same word a second keyword.
+func TestContentKeywordsComputedAtLoad(t *testing.T) {
h := sandbox(t)
os.Mkdir(filepath.Join(h, "dl"), 0o755)
main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
@@ -207,10 +205,11 @@ func TestContentVariantsComputedAtLoad(t *testing.T) {
if len(errs) > 0 {
t.Fatal(errs)
}
- got := e.Dirs[0].ContentVariants
- want := []cond.Options{{IgnoreCase: false, Fold: true}, {IgnoreCase: true, Fold: true}}
+ got := e.Dirs[0].ContentKeywords
+ strict, loose := cond.Options{IgnoreCase: false, Fold: true}, cond.Options{IgnoreCase: true, Fold: true}
+ want := []cond.Keyword{{Opt: loose, Norm: "acme"}, {Opt: strict, Norm: "acme"}, {Opt: strict, Norm: "other"}}
if !reflect.DeepEqual(got, want) {
- t.Fatalf("ContentVariants = %+v, want %+v", got, want)
+ t.Fatalf("ContentKeywords = %+v, want %+v", got, want)
}
}
diff --git a/internal/engine/exclude_test.go b/internal/engine/exclude_test.go
index 40b3548..8b26450 100644
--- a/internal/engine/exclude_test.go
+++ b/internal/engine/exclude_test.go
@@ -115,10 +115,10 @@ func TestExcludeNeedsEveryCondition(t *testing.T) {
}
}
-// 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) {
+// TestExcludeContentAndRuleContentKeepTheirOwnSettings: an exclude reading
+// content under the directory's settings first must not change how a rule
+// with different case/fold settings sees the same text.
+func TestExcludeContentAndRuleContentKeepTheirOwnSettings(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")
diff --git a/internal/engine/facts.go b/internal/engine/facts.go
index 5790e95..9035245 100644
--- a/internal/engine/facts.go
+++ b/internal/engine/facts.go
@@ -12,6 +12,8 @@ import (
"krino/internal/cond"
"krino/internal/dup"
+ "krino/internal/extract"
+ "krino/internal/kwcache"
"krino/internal/norm"
"krino/internal/plan"
"krino/internal/scan"
@@ -30,6 +32,7 @@ type matchRun struct {
ctx context.Context
now time.Time
files []scan.File
+ cache *kwcache.Cache // nil: no keyword cache
mu sync.Mutex
dupOnce map[string]*sync.Once
@@ -103,26 +106,25 @@ func (run *matchRun) dupIndex(key string, dirs []string) *dup.Index {
}
// facts is one file's cond.Facts. It is used by exactly one goroutine, so
-// its own memoised state (content, its normalised variants, and whether an
-// earlier rule matched) needs no locking of its own; only the matchRun it
-// points at is shared.
+// its own memoised state (the keyword answers, and whether an earlier rule
+// matched) needs no locking of its own; only the matchRun it points at is
+// shared.
type facts struct {
run *matchRun
file scan.File
matched bool
- contentDone bool
- content string
- contentErr error
- normCache map[[2]bool]string
+ contentDone bool // extraction was attempted
+ contentErr error // why it failed
+ answers map[string]bool // by cond.KeywordKey, once extracted
}
var _ cond.Facts = (*facts)(nil)
// newFacts builds the Facts for one scanned file.
func newFacts(run *matchRun, file scan.File) *facts {
- return &facts{run: run, file: file, normCache: make(map[[2]bool]string)}
+ return &facts{run: run, file: file}
}
func (f *facts) Name() string { return f.file.Name }
@@ -132,32 +134,85 @@ func (f *facts) ModTime() time.Time { return f.file.ModTime }
func (f *facts) Now() time.Time { return f.run.now }
func (f *facts) Matched() bool { return f.matched }
-// Content extracts the file's text once, then normalises it per
-// (ignoreCase, fold) variant, memoising each. B2: when the directory's
-// rules use exactly one variant (Dir.ContentVariants), the raw text is
-// released as soon as that variant's normalised copy exists — no other
-// variant will ever be asked for, so there is no reason to keep both the
-// raw text and its normalised copy in memory at once. A directory using
-// more than one variant keeps the raw text for as long as f lives, exactly
-// as before.
-func (f *facts) Content(ignoreCase, fold bool) (string, error) {
+// ContentContains answers a content test (spec §6.1). A file above
+// max-read is never read, cached or not. Before the file has been
+// extracted this run, the keyword cache answers when it knows every one of
+// keywords for this file as it is now; otherwise the text is extracted
+// once, every keyword of the directory (and of this test) is answered from
+// it and stored in the cache, and the text itself is dropped. A failed
+// extraction is not cached: the next run tries again.
+func (f *facts) ContentContains(opt cond.Options, keywords []string) (int, error) {
+ if max := f.run.d.Settings.MaxRead; max > 0 && f.file.Size > max {
+ return -1, extract.ErrTooLarge
+ }
+ keys := make([]string, len(keywords))
+ for i, kw := range keywords {
+ keys[i] = cond.KeywordKey(opt, kw)
+ }
if !f.contentDone {
- f.content, f.contentErr = f.run.e.Extract.Text(f.run.ctx, f.file.Path, f.file.Size, f.run.d.Settings.MaxRead)
- f.contentDone = true
+ if id, ok := f.cacheID(); ok {
+ if hits, ok := f.run.cache.Lookup(id, keys); ok {
+ for i, hit := range hits {
+ if hit {
+ return i, nil
+ }
+ }
+ return -1, nil
+ }
+ }
+ f.extract(opt, keywords)
}
if f.contentErr != nil {
- return "", f.contentErr
+ return -1, f.contentErr
+ }
+ for i, k := range keys {
+ if f.answers[k] {
+ return i, nil
+ }
+ }
+ return -1, nil
+}
+
+// extract reads the file's text and answers every keyword of the directory,
+// plus the asking test's (opt, keywords), from it.
+func (f *facts) extract(opt cond.Options, keywords []string) {
+ f.contentDone = true
+ text, err := f.run.e.Extract.Text(f.run.ctx, f.file.Path, f.file.Size, f.run.d.Settings.MaxRead)
+ if err != nil {
+ f.contentErr = err
+ return
+ }
+ all := append([]cond.Keyword(nil), f.run.d.ContentKeywords...)
+ for _, kw := range keywords {
+ all = append(all, cond.Keyword{Opt: opt, Norm: kw})
+ }
+ normed := map[cond.Options]string{}
+ f.answers = make(map[string]bool, len(all))
+ for _, k := range all {
+ t, ok := normed[k.Opt]
+ if !ok {
+ t = norm.Text(text, k.Opt.IgnoreCase, k.Opt.Fold)
+ normed[k.Opt] = t
+ }
+ f.answers[k.Key()] = strings.Contains(t, k.Norm)
}
- key := [2]bool{ignoreCase, fold}
- if v, ok := f.normCache[key]; ok {
- return v, nil
+ if id, ok := f.cacheID(); ok {
+ f.run.cache.Store(id, f.answers)
}
- v := norm.Text(f.content, ignoreCase, fold)
- f.normCache[key] = v
- if len(f.run.d.ContentVariants) == 1 {
- f.content = ""
+}
+
+// cacheID is the file's keyword cache identity; ok is false when there is
+// no cache, or the platform gave the file no inode.
+func (f *facts) cacheID() (kwcache.ID, bool) {
+ if f.run.cache == nil || f.file.Ino == 0 {
+ return kwcache.ID{}, false
}
- return v, nil
+ return fileCacheID(f.file), true
+}
+
+// fileCacheID is file's kwcache.ID.
+func fileCacheID(file scan.File) kwcache.ID {
+ return kwcache.ID{Dev: file.Dev, Ino: file.Ino, Size: file.Size, MTime: file.ModTime.UnixNano()}
}
// Duplicate resolves dirs against the directory's root, builds (or reuses)
diff --git a/internal/engine/facts_test.go b/internal/engine/facts_test.go
index 7d844a0..9739fa6 100644
--- a/internal/engine/facts_test.go
+++ b/internal/engine/facts_test.go
@@ -3,79 +3,10 @@
package engine
import (
- "context"
- "os"
"path/filepath"
"testing"
- "time"
-
- "krino/internal/cond"
- "krino/internal/extract"
- "krino/internal/scan"
)
-// contentFacts builds a *facts for a real text file, under a Dir whose
-// ContentVariants is variants, for exercising B2's raw-release directly.
-func contentFacts(t *testing.T, body string, variants []cond.Options) *facts {
- t.Helper()
- p := filepath.Join(t.TempDir(), "a.txt")
- if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
- fi, err := os.Stat(p)
- if err != nil {
- t.Fatal(err)
- }
- e := &Engine{Extract: extract.New(), Now: time.Now}
- d := &Dir{Name: "d", ContentVariants: variants}
- file := scan.File{Path: p, Rel: "a.txt", Name: "a.txt", Size: fi.Size(), ModTime: fi.ModTime()}
- run := newMatchRun(e, d, context.Background(), time.Now(), []scan.File{file})
- return newFacts(run, file)
-}
-
-// TestContentReleasesRawWithOneVariant: B2. A directory whose rules use
-// exactly one (ignoreCase, fold) variant releases the raw extracted text
-// once that variant's normalised copy exists.
-func TestContentReleasesRawWithOneVariant(t *testing.T) {
- f := contentFacts(t, "Hello World", []cond.Options{{IgnoreCase: true, Fold: false}})
- const want = "hello world"
- got, err := f.Content(true, false)
- if err != nil {
- t.Fatal(err)
- }
- if got != want {
- t.Fatalf("got %q, want %q", got, want)
- }
- if f.content != "" {
- t.Errorf("raw text not released with a single content variant: %q", f.content)
- }
- // A second call for the same (already cached) variant must still work
- // from normCache, without needing the released raw text.
- if got, err := f.Content(true, false); err != nil || got != want {
- t.Errorf("second call for the cached variant: got %q, %v, want %q", got, err, want)
- }
-}
-
-// TestContentKeepsRawWithTwoVariants: B2. A directory whose rules use two
-// distinct variants must not release the raw text after the first: the
-// second variant still needs it, and normalising it correctly (not from an
-// emptied string) is the proof the raw text was kept.
-func TestContentKeepsRawWithTwoVariants(t *testing.T) {
- f := contentFacts(t, "Hello World", []cond.Options{
- {IgnoreCase: true, Fold: false},
- {IgnoreCase: false, Fold: false},
- })
- if got, err := f.Content(true, false); err != nil || got != "hello world" {
- t.Fatalf("first variant: got %q, %v", got, err)
- }
- if f.content == "" {
- t.Fatal("raw text released after only the first of two variants")
- }
- if got, err := f.Content(false, false); err != nil || got != "Hello World" {
- t.Fatalf("second variant: got %q, %v, want the unfolded original (raw text must still be available)", got, err)
- }
-}
-
// TestDisplayOriginalAbbreviatesHome: a duplicate's original is shown
// root-relative inside the root, and home-abbreviated outside it, the way
// every other user-visible path is; a path outside $HOME stays absolute.
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 8daea9d..1a8077a 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -16,6 +16,7 @@ import (
"krino/internal/cond"
"krino/internal/config"
+ "krino/internal/kwcache"
"krino/internal/plan"
"krino/internal/scan"
"krino/internal/xdg"
@@ -70,6 +71,7 @@ func (e *Engine) Match(ctx context.Context, d *Dir) (*Result, error) {
}
run := newMatchRun(e, d, ctx, now, wres.Files)
+ cacheWarn := e.openCache(run)
fileMatches := make([]FileMatch, len(wres.Files))
workers := runtime.GOMAXPROCS(0)
@@ -103,7 +105,18 @@ func (e *Engine) Match(ctx context.Context, d *Dir) (*Result, error) {
}
}
- warnings := run.warnings()
+ warnings := append(run.warnings(), cacheWarn...)
+ if run.cache != nil {
+ ids := make([]kwcache.ID, 0, len(wres.Files))
+ for _, f := range wres.Files {
+ if f.Ino != 0 {
+ ids = append(ids, fileCacheID(f))
+ }
+ }
+ if err := run.cache.Save(e.cacheFile(d), ids); err != nil {
+ warnings = append(warnings, "cache: "+err.Error())
+ }
+ }
sort.Strings(warnings)
return &Result{
@@ -242,20 +255,14 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
}
rel = filepath.ToSlash(rel)
- sf := scan.File{
- Path: abs,
- Rel: rel,
- Name: filepath.Base(abs),
- Size: info.Size(),
- ModTime: info.ModTime(),
- Mode: info.Mode(),
- }
+ sf := scan.NewFile(abs, rel, info)
now := e.Now()
excl := e.excludeDirs(d)
skip := explainSkip(d, sf, excl, now)
run := newMatchRun(e, d, ctx, now, e.filesForExplain(d, sf, excl, now))
+ e.openCache(run) // read only: Explain never writes the cache
f := newFacts(run, sf)
var excludes []ExcludeTrace
@@ -289,6 +296,26 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
return &Explanation{Dir: d, File: sf, Skip: skip, Excludes: excludes, Excluded: excluded, Rules: rules}, nil
}
+// cacheFile is d's keyword cache file.
+func (e *Engine) cacheFile(d *Dir) string {
+ return filepath.Join(e.CacheDir, d.Name+".cache")
+}
+
+// openCache loads run's directory keyword cache into run.cache, when the
+// engine has a CacheDir and the directory has content tests at all. A cache
+// that cannot be read is replaced by an empty one, reported as a warning.
+func (e *Engine) openCache(run *matchRun) []string {
+ if e.CacheDir == "" || len(run.d.ContentKeywords) == 0 {
+ return nil
+ }
+ c, err := kwcache.Load(e.cacheFile(run.d), e.Extract.Fingerprint())
+ run.cache = c
+ if err != nil {
+ return []string{"cache: " + err.Error() + " (starting a new one)"}
+ }
+ return nil
+}
+
// filesForExplain returns the file set Explain's duplicate checks run
// against: the directory's ordinary scan, plus the explained file itself
// when that scan would not have reached it (it is busy, ignored, too new,
diff --git a/internal/extract/extract.go b/internal/extract/extract.go
index aa3ae93..9768db1 100644
--- a/internal/extract/extract.go
+++ b/internal/extract/extract.go
@@ -7,12 +7,18 @@ package extract
import (
"context"
"errors"
+ "fmt"
"os"
"path/filepath"
"strings"
"time"
)
+// Version is the version of the text this package extracts. Bump it
+// whenever a change could make any format's text differ, so every keyword
+// cache built from the old text is discarded (Fingerprint).
+const Version = 1
+
var (
// ErrUnsupported is returned when the format carries no text krino
// knows how to extract.
@@ -111,6 +117,26 @@ func newWithPath(path string) *Extractor {
return &Extractor{tools: tools, Timeout: 30 * time.Second}
}
+// Fingerprint identifies what text this Extractor would produce: Version,
+// and each external tool found with its path, size and modification time.
+// A keyword cache built under another fingerprint is discarded, so
+// installing, removing or upgrading a tool invalidates it.
+func (e *Extractor) Fingerprint() string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "v%d", Version)
+ for _, name := range toolNames {
+ p := e.tools[name]
+ if p == "" {
+ continue
+ }
+ fmt.Fprintf(&b, " %s=%s", name, p)
+ if fi, err := os.Stat(p); err == nil {
+ fmt.Fprintf(&b, ":%d:%d", fi.Size(), fi.ModTime().UnixNano())
+ }
+ }
+ return b.String()
+}
+
// Tools reports every external tool Extractor knows about, in a fixed
// order, with the path it was found at or "" if it was not found.
func (e *Extractor) Tools() []Tool {
diff --git a/internal/extract/tools_test.go b/internal/extract/tools_test.go
index 4a03f1b..b481e8a 100644
--- a/internal/extract/tools_test.go
+++ b/internal/extract/tools_test.go
@@ -237,3 +237,27 @@ func TestMaxReadCapsToolOutput(t *testing.T) {
t.Fatalf("got %v, want ErrTooLarge (max-read 1024 should have capped a 200000-byte tool output)", err)
}
}
+
+// TestFingerprintFollowsTools: the fingerprint changes when a tool is
+// installed or replaced, and not otherwise.
+func TestFingerprintFollowsTools(t *testing.T) {
+ bin := t.TempDir()
+ none := newWithPath(bin).Fingerprint()
+ if none != newWithPath(bin).Fingerprint() {
+ t.Fatal("fingerprint not stable")
+ }
+ tool := filepath.Join(bin, "pdftotext")
+ if err := os.WriteFile(tool, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ installed := newWithPath(bin).Fingerprint()
+ if installed == none {
+ t.Error("installing pdftotext did not change the fingerprint")
+ }
+ if err := os.WriteFile(tool, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if newWithPath(bin).Fingerprint() == installed {
+ t.Error("replacing pdftotext did not change the fingerprint")
+ }
+}
diff --git a/internal/kwcache/kwcache.go b/internal/kwcache/kwcache.go
new file mode 100644
index 0000000..b61cfb1
--- /dev/null
+++ b/internal/kwcache/kwcache.go
@@ -0,0 +1,231 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package kwcache remembers which content keywords a file's extracted text
+// contains, so a file that has not changed is not extracted again (spec
+// §6.1). It stores answers only, never the text, and knows a file by its
+// ID, never by its name.
+package kwcache
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+)
+
+// version is the on-disk format; a file of any other version loads empty.
+const version = 1
+
+// ID is how a file is recognised: the same inode with the same size and
+// modification time is taken to hold the same content. A move or rename
+// within one filesystem keeps it.
+type ID struct {
+ Dev, Ino uint64
+ Size int64
+ MTime int64 // Unix nanoseconds
+}
+
+// entry is what is known about one file: every keyword it was checked
+// against, and those its text contains.
+type entry struct {
+ checked []string // sorted
+ hits map[string]bool
+}
+
+// Cache holds one directory's answers: those loaded from disk and those
+// stored during this run. It is safe for concurrent use.
+type Cache struct {
+ fingerprint string
+ existed bool // Load found a file, so Save must rewrite it even when empty
+
+ mu sync.Mutex
+ old map[ID]entry
+ cur map[ID]entry
+}
+
+// New returns an empty cache for extractor fingerprint.
+func New(fingerprint string) *Cache {
+ return &Cache{fingerprint: fingerprint, old: map[ID]entry{}, cur: map[ID]entry{}}
+}
+
+type diskFile struct {
+ Dev uint64 `json:"dev"`
+ Ino uint64 `json:"ino"`
+ Size int64 `json:"size"`
+ MTime int64 `json:"mtime"`
+ Set int `json:"keywords"` // index into diskCache.Keywords
+ Hits []int `json:"hits"` // indices into that keyword list
+}
+
+type diskCache struct {
+ Version int `json:"version"`
+ Fingerprint string `json:"fingerprint"`
+ Keywords [][]string `json:"keywords"`
+ Files []diskFile `json:"files"`
+}
+
+// Load reads the cache at path. A missing file, another format version or
+// another fingerprint is an empty cache and no error; an unreadable file
+// is an empty cache and an error. The cache returned is never nil.
+func Load(path, fingerprint string) (*Cache, error) {
+ c := New(fingerprint)
+ data, err := os.ReadFile(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return c, nil
+ }
+ c.existed = err == nil
+ if err != nil {
+ return c, err
+ }
+ var d diskCache
+ if err := json.Unmarshal(data, &d); err != nil {
+ return c, fmt.Errorf("%s: %v", path, err)
+ }
+ if d.Version != version || d.Fingerprint != fingerprint {
+ return c, nil
+ }
+ for i, set := range d.Keywords {
+ if !sort.StringsAreSorted(set) {
+ return New(fingerprint), fmt.Errorf("%s: keyword list %d is not sorted", path, i)
+ }
+ }
+ for _, f := range d.Files {
+ if f.Set < 0 || f.Set >= len(d.Keywords) {
+ return New(fingerprint), fmt.Errorf("%s: bad keyword list %d", path, f.Set)
+ }
+ set := d.Keywords[f.Set]
+ hits := make(map[string]bool, len(f.Hits))
+ for _, h := range f.Hits {
+ if h < 0 || h >= len(set) {
+ return New(fingerprint), fmt.Errorf("%s: bad keyword %d", path, h)
+ }
+ hits[set[h]] = true
+ }
+ c.old[ID{Dev: f.Dev, Ino: f.Ino, Size: f.Size, MTime: f.MTime}] = entry{checked: set, hits: hits}
+ }
+ c.existed = true
+ return c, nil
+}
+
+// Lookup reports, for each of keys, whether the text of the file id
+// contains it. ok is false unless an entry for id exists and was checked
+// against every one of keys.
+func (c *Cache) Lookup(id ID, keys []string) (hits []bool, ok bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ e, found := c.cur[id]
+ if !found {
+ e, found = c.old[id]
+ }
+ if !found {
+ return nil, false
+ }
+ hits = make([]bool, len(keys))
+ for i, k := range keys {
+ j := sort.SearchStrings(e.checked, k)
+ if j == len(e.checked) || e.checked[j] != k {
+ return nil, false
+ }
+ hits[i] = e.hits[k]
+ }
+ return hits, true
+}
+
+// Store records answers, every keyword the file id was checked against
+// and whether its text contains it, replacing anything known about id.
+func (c *Cache) Store(id ID, answers map[string]bool) {
+ e := entry{checked: make([]string, 0, len(answers)), hits: map[string]bool{}}
+ for k, hit := range answers {
+ e.checked = append(e.checked, k)
+ if hit {
+ e.hits[k] = true
+ }
+ }
+ sort.Strings(e.checked)
+ c.mu.Lock()
+ c.cur[id] = e
+ c.mu.Unlock()
+}
+
+// Save writes the entries of the files in present, from this run or loaded,
+// to path, and drops every other: the cache only ever describes files
+// still in the directory. The directory is created 0700 and the file
+// written 0600 under a temporary name, then renamed into place. A cache
+// with nothing to write and no file on disk writes nothing.
+func (c *Cache) Save(path string, present []ID) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ d := diskCache{Version: version, Fingerprint: c.fingerprint, Keywords: [][]string{}, Files: []diskFile{}}
+ sets := map[string]int{}
+ seen := map[ID]bool{}
+ for _, id := range present {
+ if seen[id] {
+ continue
+ }
+ seen[id] = true
+ e, ok := c.cur[id]
+ if !ok {
+ e, ok = c.old[id]
+ }
+ if !ok {
+ continue
+ }
+ key := strings.Join(e.checked, "\x00")
+ set, ok := sets[key]
+ if !ok {
+ set = len(d.Keywords)
+ sets[key] = set
+ d.Keywords = append(d.Keywords, e.checked)
+ }
+ f := diskFile{Dev: id.Dev, Ino: id.Ino, Size: id.Size, MTime: id.MTime, Set: set, Hits: []int{}}
+ for i, k := range e.checked {
+ if e.hits[k] {
+ f.Hits = append(f.Hits, i)
+ }
+ }
+ d.Files = append(d.Files, f)
+ }
+ if len(d.Files) == 0 && !c.existed {
+ return nil
+ }
+ sort.Slice(d.Files, func(i, j int) bool {
+ if d.Files[i].Dev != d.Files[j].Dev {
+ return d.Files[i].Dev < d.Files[j].Dev
+ }
+ return d.Files[i].Ino < d.Files[j].Ino
+ })
+
+ data, err := json.Marshal(d)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(dir, ".kwcache-*")
+ if err != nil {
+ return err
+ }
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ os.Remove(tmp.Name())
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ os.Remove(tmp.Name())
+ return err
+ }
+ if err := os.Rename(tmp.Name(), path); err != nil {
+ os.Remove(tmp.Name())
+ return err
+ }
+ c.existed = true
+ return nil
+}
diff --git a/internal/kwcache/kwcache_test.go b/internal/kwcache/kwcache_test.go
new file mode 100644
index 0000000..8395b2d
--- /dev/null
+++ b/internal/kwcache/kwcache_test.go
@@ -0,0 +1,180 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package kwcache
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+var (
+ a = ID{Dev: 1, Ino: 10, Size: 100, MTime: 1000}
+ b = ID{Dev: 1, Ino: 11, Size: 200, MTime: 2000}
+)
+
+func saved(t *testing.T, c *Cache, present ...ID) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "sub", "dl.cache")
+ if err := c.Save(path, present); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+// TestLookupAfterReload: answers stored and saved come back after a Load
+// with the same fingerprint, for any keywords the entry covers.
+func TestLookupAfterReload(t *testing.T) {
+ c := New("fp")
+ c.Store(a, map[string]bool{"k:acme": true, "k:faktura": false, "k:nip": true})
+ path := saved(t, c, a)
+
+ got, err := Load(path, "fp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ hits, ok := got.Lookup(a, []string{"k:faktura", "k:nip"})
+ if !ok || !reflect.DeepEqual(hits, []bool{false, true}) {
+ t.Errorf("Lookup = %v, %v; want [false true], true", hits, ok)
+ }
+ if _, ok := got.Lookup(a, []string{"k:acme", "k:new"}); ok {
+ t.Error("a keyword the entry was never checked against must be a miss")
+ }
+ if _, ok := got.Lookup(b, []string{"k:acme"}); ok {
+ t.Error("a file never stored must be a miss")
+ }
+}
+
+// TestLookupBeforeSave: what was stored in this run answers at once.
+func TestLookupBeforeSave(t *testing.T) {
+ c := New("fp")
+ c.Store(a, map[string]bool{"k:acme": true})
+ if hits, ok := c.Lookup(a, []string{"k:acme"}); !ok || !hits[0] {
+ t.Errorf("Lookup = %v, %v", hits, ok)
+ }
+}
+
+// TestChangedFileMisses: a different size or modification time is a
+// different ID, so nothing stored for the old one answers.
+func TestChangedFileMisses(t *testing.T) {
+ c := New("fp")
+ c.Store(a, map[string]bool{"k:acme": true})
+ for _, id := range []ID{{Dev: 1, Ino: 10, Size: 101, MTime: 1000}, {Dev: 1, Ino: 10, Size: 100, MTime: 1001}} {
+ if _, ok := c.Lookup(id, []string{"k:acme"}); ok {
+ t.Errorf("%+v answered from %+v's entry", id, a)
+ }
+ }
+}
+
+// TestFingerprintMismatchDiscardsAll: a cache written under a different
+// extractor fingerprint loads empty, without error.
+func TestFingerprintMismatchDiscardsAll(t *testing.T) {
+ c := New("old")
+ c.Store(a, map[string]bool{"k:acme": true})
+ path := saved(t, c, a)
+ got, err := Load(path, "new")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := got.Lookup(a, []string{"k:acme"}); ok {
+ t.Error("entry survived a fingerprint change")
+ }
+}
+
+// TestSaveKeepsOnlyPresentFiles: entries for files not passed to Save, from
+// this run or an earlier one, are dropped.
+func TestSaveKeepsOnlyPresentFiles(t *testing.T) {
+ c := New("fp")
+ c.Store(a, map[string]bool{"k:acme": true})
+ c.Store(b, map[string]bool{"k:acme": false})
+ path := saved(t, c, a, b)
+
+ next, err := Load(path, "fp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := next.Save(path, []ID{b}); err != nil {
+ t.Fatal(err)
+ }
+ last, err := Load(path, "fp")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := last.Lookup(a, []string{"k:acme"}); ok {
+ t.Error("a is gone from the directory but its entry was kept")
+ }
+ if hits, ok := last.Lookup(b, []string{"k:acme"}); !ok || hits[0] {
+ t.Errorf("b's entry lost across a reload: %v, %v", hits, ok)
+ }
+}
+
+// TestStoreReplacesLoadedEntry: a file read again this run replaces what
+// an earlier run stored for it.
+func TestStoreReplacesLoadedEntry(t *testing.T) {
+ c := New("fp")
+ c.Store(a, map[string]bool{"k:acme": true})
+ path := saved(t, c, a)
+ next, _ := Load(path, "fp")
+ next.Store(a, map[string]bool{"k:acme": false, "k:new": true})
+ if hits, ok := next.Lookup(a, []string{"k:acme", "k:new"}); !ok || hits[0] || !hits[1] {
+ t.Errorf("Lookup = %v, %v; want the new entry", hits, ok)
+ }
+}
+
+// TestSavePermissions: the directory is private and the file readable by
+// its owner only, with no temporary file left behind.
+func TestSavePermissions(t *testing.T) {
+ c := New("fp")
+ c.Store(a, map[string]bool{"k:acme": true})
+ path := saved(t, c, a)
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if perm := fi.Mode().Perm(); perm != 0o600 {
+ t.Errorf("file mode %o, want 600", perm)
+ }
+ di, err := os.Stat(filepath.Dir(path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if perm := di.Mode().Perm(); perm != 0o700 {
+ t.Errorf("directory mode %o, want 700", perm)
+ }
+ entries, _ := os.ReadDir(filepath.Dir(path))
+ if len(entries) != 1 {
+ t.Errorf("directory holds %d entries, want only the cache", len(entries))
+ }
+}
+
+// TestLoadMissingAndCorrupt: no file is an empty cache and no error; an
+// unreadable one is an empty cache and an error saying so.
+func TestLoadMissingAndCorrupt(t *testing.T) {
+ dir := t.TempDir()
+ c, err := Load(filepath.Join(dir, "none.cache"), "fp")
+ if err != nil || c == nil {
+ t.Fatalf("missing: %v, %v", c, err)
+ }
+ bad := filepath.Join(dir, "bad.cache")
+ os.WriteFile(bad, []byte("{not json"), 0o600)
+ c, err = Load(bad, "fp")
+ if err == nil || c == nil {
+ t.Fatalf("corrupt: want an empty cache and an error, got %v, %v", c, err)
+ }
+ if _, ok := c.Lookup(a, []string{"k:acme"}); ok {
+ t.Error("corrupt cache answered")
+ }
+}
+
+// TestSaveWithNothingWritesNothing: a cache that never held an entry, and
+// has no file yet, creates none.
+func TestSaveWithNothingWritesNothing(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "sub", "dl.cache")
+ if err := New("fp").Save(path, []ID{a}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
+ t.Errorf("Save created %s for an empty cache", filepath.Dir(path))
+ }
+}
diff --git a/internal/scan/fileid_other.go b/internal/scan/fileid_other.go
new file mode 100644
index 0000000..503a9b4
--- /dev/null
+++ b/internal/scan/fileid_other.go
@@ -0,0 +1,10 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+//go:build !unix
+
+package scan
+
+import "io/fs"
+
+// fileID reports no identity where the platform has no inodes.
+func fileID(fs.FileInfo) (dev, ino uint64) { return 0, 0 }
diff --git a/internal/scan/fileid_unix.go b/internal/scan/fileid_unix.go
new file mode 100644
index 0000000..e29066a
--- /dev/null
+++ b/internal/scan/fileid_unix.go
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+//go:build unix
+
+package scan
+
+import (
+ "io/fs"
+ "syscall"
+)
+
+// fileID returns the device and inode info was read from.
+func fileID(info fs.FileInfo) (dev, ino uint64) {
+ st, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return 0, 0
+ }
+ return uint64(st.Dev), uint64(st.Ino)
+}
diff --git a/internal/scan/scan.go b/internal/scan/scan.go
index 7fe9e92..77cca66 100644
--- a/internal/scan/scan.go
+++ b/internal/scan/scan.go
@@ -24,6 +24,26 @@ type File struct {
Size int64
ModTime time.Time
Mode fs.FileMode
+
+ // Dev and Ino identify the file on its filesystem; both are 0 where the
+ // platform reports neither.
+ Dev, Ino uint64
+}
+
+// NewFile builds the File for path, at rel under the root, from its Lstat
+// info.
+func NewFile(path, rel string, info fs.FileInfo) File {
+ dev, ino := fileID(info)
+ return File{
+ Path: path,
+ Rel: rel,
+ Name: filepath.Base(path),
+ Size: info.Size(),
+ ModTime: info.ModTime(),
+ Mode: info.Mode(),
+ Dev: dev,
+ Ino: ino,
+ }
}
// Reason is why an entry was not returned as a File.
@@ -217,14 +237,7 @@ func (w *walker) walk(dir, relDir string, depth int, entries []os.DirEntry) erro
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,
- Name: name,
- Size: info.Size(),
- ModTime: info.ModTime(),
- Mode: info.Mode(),
- })
+ w.result.Files = append(w.result.Files, NewFile(path, rel, info))
}
return nil
}
diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go
index a245893..e9aeb73 100644
--- a/internal/scan/scan_test.go
+++ b/internal/scan/scan_test.go
@@ -293,3 +293,29 @@ func TestTooBig(t *testing.T) {
t.Errorf("MaxSize 0 skipped files: %v", skipped(r))
}
}
+
+// TestFilesCarryInode: a walked file carries its device and inode, which a
+// rename keeps.
+func TestFilesCarryInode(t *testing.T) {
+ root := tree(t)
+ p := filepath.Join(root, "a.txt")
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ old := now.Add(-time.Hour)
+ os.Chtimes(p, old, old)
+ first, err := Walk(root, Options{Now: now})
+ if err != nil || len(first.Files) != 1 {
+ t.Fatalf("walk: %v %+v", err, first)
+ }
+ if first.Files[0].Ino == 0 {
+ t.Fatal("no inode on a unix filesystem")
+ }
+ if err := os.Rename(p, filepath.Join(root, "b.txt")); err != nil {
+ t.Fatal(err)
+ }
+ second, _ := Walk(root, Options{Now: now})
+ if len(second.Files) != 1 || second.Files[0].Ino != first.Files[0].Ino || second.Files[0].Dev != first.Files[0].Dev {
+ t.Errorf("rename changed the identity: %+v then %+v", first.Files[0], second.Files)
+ }
+}
diff --git a/internal/xdg/xdg.go b/internal/xdg/xdg.go
index ed34838..be13650 100644
--- a/internal/xdg/xdg.go
+++ b/internal/xdg/xdg.go
@@ -18,6 +18,9 @@ func StateHome() string { return base("XDG_STATE_HOME", filepath.Join(".local",
// DataHome is $XDG_DATA_HOME, or ~/.local/share.
func DataHome() string { return base("XDG_DATA_HOME", filepath.Join(".local", "share")) }
+// CacheHome is $XDG_CACHE_HOME, or ~/.cache.
+func CacheHome() string { return base("XDG_CACHE_HOME", ".cache") }
+
// base follows the XDG rule that a relative value is invalid and ignored.
func base(env, fallback string) string {
if v := os.Getenv(env); filepath.IsAbs(v) {
diff --git a/man/krino.1 b/man/krino.1
index 458bd56..cc4c2e1 100644
--- a/man/krino.1
+++ b/man/krino.1
@@ -244,11 +244,25 @@ each step, then the rule and the reason it matched:
copy \(-> ~/backup/invoices/2026/
rule backup
because content "invoice"
- [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing
+ [y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing
.Ed
.Pp
Approval is per file: a file's whole chain runs, or none of it.
+.Ic t
+and
+.Ic d
+set aside what the rules planned for the file and approve one step on the
+file itself instead: to the Trash, or deleted permanently, logged under the
+rule name
+.Sy (review) .
.Ic d
+first asks
+.Dq delete Ar name No permanently? [y/N] ;
+any key but
+.Ic y
+deletes nothing and asks about the file again.
+A permanent delete cannot be undone.
+.Ic w
stops asking and applies whatever was already chosen, declining the rest.
.Ic q
here aborts the review for this directory entirely, discarding even a file
@@ -272,6 +286,13 @@ both apply nothing:
[a] apply all [c] choose per file [s] skip [q] quit
.Ed
.Pp
+Its per-file prompt is the one in
+.Sx REVIEW
+without
+.Ic t
+and
+.Ic d .
+.Pp
An undo run cannot itself be undone: naming it to
.Ic undo
is refused.
@@ -320,6 +341,11 @@ step uses
.Pq Pa $XDG_DATA_HOME/Trash ;
default
.Pa ~/.local/share .
+.It Ev XDG_CACHE_HOME
+Base of the keyword cache
+.Pq Pa $XDG_CACHE_HOME/krino ;
+default
+.Pa ~/.cache .
.It Ev PAGER
Used to show a plan taller than the terminal; default
.Dq less -FRX .
@@ -355,6 +381,12 @@ against the same directory waits, or fails immediately with
The freedesktop.org trash a
.Sy (delete)
step moves files into.
+.It Pa $XDG_CACHE_HOME/krino/ Ns Ar name Ns Pa .cache
+Which content keywords each extracted file of directory
+.Ar name
+contains, so an unchanged file is not extracted again; see
+.Xr krino.conf 5 , Sx CONTENT EXTRACTION .
+Safe to delete at any time.
.El
.Sh EXIT STATUS
.Bl -tag -width Ds
diff --git a/man/krino.conf.5 b/man/krino.conf.5
index 769bd51..c89fbd9 100644
--- a/man/krino.conf.5
+++ b/man/krino.conf.5
@@ -521,6 +521,28 @@ Matching is substring:
matches
.Ql \&"acmeco\&" .
Words hyphenated across lines in a PDF are not rejoined.
+.Ss Keyword cache
+For each file it extracts,
+.Nm krino
+records in
+.Pa $XDG_CACHE_HOME/krino/ Ns Ar name Ns Pa .cache
+which of the directory's content keywords the text contains, and answers
+later content tests from it while the file is unchanged.
+No text and no file names are stored; a file is known by device, inode,
+size and modification time, which a move or rename within one filesystem
+keeps.
+The keywords are stored as written in the configuration.
+.Pp
+A file is extracted again when it changes, or when a test asks about a
+keyword its entry was never checked against.
+Failures are never cached, and a file above
+.Ic max-read
+is refused before the cache is read.
+The whole cache is discarded when an extraction tool is installed, removed
+or replaced.
+Each run keeps entries only for files still in the directory.
+A file edited in place with its size and modification time preserved keeps
+its old answers; deleting the cache resets everything.
.Sh ACTIONS
.Ss Chains
A file's chain is the actions of every matching rule, in rule order, and