aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-13 02:31:32 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-13 02:31:32 +0200
commit26c94eb3db62ec6eebbf8d22c11afe691d9520c4 (patch)
tree165e5bf69234b4f96c9b74deb4898d7143ddf120
parenta6e442a645902011b2081c216daaec052cdc6ce6 (diff)
downloadkrino-26c94eb3db62ec6eebbf8d22c11afe691d9520c4.tar.gz
krino-26c94eb3db62ec6eebbf8d22c11afe691d9520c4.zip
krino: release 0.0.1 — man pages, install, examples, cross and release, README, changelogv0.0.1
Also: undo removes the directories its run created; a hardlink is never a duplicate of its own other name; a flag written before "undo" is honoured; --version prints no leading v. Duplicate conditions with different scopes not sharing an original is documented as a known limitation.
-rw-r--r--CHANGELOG.md32
-rw-r--r--Makefile103
-rw-r--r--README.md214
-rw-r--r--cmd/krino/history_test.go63
-rw-r--r--cmd/krino/main.go22
-rw-r--r--cmd/krino/main_test.go21
-rw-r--r--cmd/krino/sort_test.go63
-rw-r--r--cmd/krino/undo.go6
-rw-r--r--docs/design.md51
-rw-r--r--examples/by-type.conf55
-rw-r--r--examples/invoices.conf33
-rw-r--r--examples/old-installers.conf26
-rw-r--r--examples/screenshots.conf27
-rw-r--r--internal/apply/apply.go2
-rw-r--r--internal/apply/apply_test.go42
-rw-r--r--internal/apply/fs.go35
-rw-r--r--internal/config/skel/krino.conf5
-rw-r--r--internal/config/skel/template.conf5
-rw-r--r--internal/dup/dup.go50
-rw-r--r--internal/dup/dup_test.go226
-rw-r--r--internal/engine/apply.go118
-rw-r--r--internal/engine/apply_test.go243
-rw-r--r--internal/engine/plan_bench_test.go108
-rw-r--r--internal/journal/read_test.go214
-rw-r--r--internal/tui/tui.go6
-rw-r--r--internal/tui/tui_test.go8
-rw-r--r--man/krino.1348
-rw-r--r--man/krino.conf.5696
-rwxr-xr-xscripts/deps99
-rwxr-xr-xscripts/man-lint68
30 files changed, 2939 insertions, 50 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5adf3e..5bd80c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,4 +2,34 @@
## Unreleased
-- Initial development: config language, `krino init`, `krino new`, `krino check`, `krino explain`, and the `-n` dry run.
+## 0.0.1 — 2026-09-13
+
+- Per-directory rule files, a main file listing which directories run, and a
+ template for new ones.
+- Conditions with `and`, `or` and `not` over type, name, path, content, size,
+ age and duplicates.
+- Actions copy, move, rename and delete (to Trash by default), chained.
+- Plan, review and approve — all files or per file — with `-y` to skip
+ review.
+- A log of every step, and `krino undo`.
+- Placeholders in destinations and new names.
+- Builds for Linux, FreeBSD and OpenBSD.
+
+Performance: no threshold or target for 0.0.1. These were measured during
+development and are reported rather than promised — throughput is dominated
+by external content extractors, not by krino itself:
+
+- Full plans over a real folder of about 265 files, 164 of them needing text
+ extraction through `pdftotext` at roughly 80 ms each, measured between
+ about 13 and 20 s across runs.
+- A synthetic benchmark during development planned a generated tree of 4,405
+ files needing no extraction in 1.09 s. That benchmark is not `make bench`,
+ whose generated tree is smaller.
+
+Files that share a byte size but differ in content — six templated invoices,
+for instance — are still told apart, because the duplicate check hashes the
+first and last 64 KiB and then the whole file rather than trusting size.
+
+Known limitation: `(duplicate)` conditions with different scopes can elect
+different originals for the same content, and within one directory's rules
+this can select every copy for deletion — see `krino.conf(5)`, DUPLICATES.
diff --git a/Makefile b/Makefile
index 9dfc7d4..62e2b7e 100644
--- a/Makefile
+++ b/Makefile
@@ -4,11 +4,15 @@
DESTDIR ?=
PREFIX ?= $(HOME)/.local
BINDIR = $(DESTDIR)$(PREFIX)/bin
+SHAREDIR = $(DESTDIR)$(PREFIX)/share
+MANDIR = $(SHAREDIR)/man
+DOCDIR = $(SHAREDIR)/doc/krino
BIN = krino
VERSION != git describe --tags --always --dirty 2>/dev/null || echo dev
LDFLAGS = -s -w -X main.version=$(VERSION)
+CROSS_PLATFORMS = linux/amd64 linux/arm64 freebsd/amd64 openbsd/amd64
-.PHONY: all help build install uninstall test vet fmt ci install-hooks clean
+.PHONY: all help build install uninstall test vet fmt lint ci bench cross dist release deps install-hooks clean
all: build
@@ -17,16 +21,36 @@ help: ## show this help
awk 'BEGIN {FS = ":.*## "}; {printf " %-10s %s\n", $$1, $$2}'
@echo " version: $(VERSION) prefix: $(PREFIX)"
-build: ## build ./krino; Go fetches module dependencies itself
+build: ## build ./krino; Go fetches module dependencies itself; reports missing optional extractors
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/krino
+ @missing=""; \
+ for t in pdftotext antiword catdoc xls2csv catppt; do \
+ command -v "$$t" >/dev/null 2>&1 || missing="$$missing $$t"; \
+ done; \
+ if [ -n "$$missing" ]; then \
+ echo "optional extractors not found:$$missing (needed for some content rules; run 'make deps' or install by hand)"; \
+ else \
+ echo "all optional extractors found: pdftotext antiword catdoc xls2csv catppt"; \
+ fi
-install: build ## install to $(PREFIX)/bin
- mkdir -p $(BINDIR)
+install: build ## install binary, man pages and examples under $(PREFIX)
+ mkdir -p $(BINDIR) $(MANDIR)/man1 $(MANDIR)/man5 $(DOCDIR)/examples
rm -f $(BINDIR)/$(BIN)
install -m 755 $(BIN) $(BINDIR)/$(BIN)
+ install -m 644 man/krino.1 $(MANDIR)/man1/krino.1
+ install -m 644 man/krino.conf.5 $(MANDIR)/man5/krino.conf.5
+ install -m 644 examples/*.conf $(DOCDIR)/examples/
+ install -m 644 docs/sexp-primer.md $(DOCDIR)/sexp-primer.md
-uninstall: ## remove the installed binary
+uninstall: ## remove everything install placed, and nothing else
rm -f $(BINDIR)/$(BIN)
+ rm -f $(MANDIR)/man1/krino.1
+ rm -f $(MANDIR)/man5/krino.conf.5
+ rm -f $(DOCDIR)/sexp-primer.md
+ for f in examples/*.conf; do \
+ rm -f $(DOCDIR)/examples/$$(basename "$$f"); \
+ done
+ -rmdir $(DOCDIR)/examples $(DOCDIR) 2>/dev/null
test: ## run the tests
go test ./...
@@ -37,7 +61,10 @@ vet: ## go vet
fmt: ## gofmt the tree
gofmt -w .
-ci: ## the gate: gofmt, vet, tests, dependencies, no personal data staged
+lint: ## lint the man pages
+ scripts/man-lint
+
+ci: ## the gate: gofmt, vet, tests, dependencies, no personal data staged, man page lint
@test -z "$$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; }
go vet ./...
GOOS=freebsd CGO_ENABLED=0 go vet ./...
@@ -46,8 +73,72 @@ ci: ## the gate: gofmt, vet, tests, dependencies, no personal data staged
@deps=$$(go list -deps ./... | grep -E '^[a-z0-9-]+\.[a-z]+/' | grep -vE '^golang\.org/x/(term|text|sys)(/|$$)' || true); \
test -z "$$deps" || { echo "unexpected dependencies:"; echo "$$deps"; exit 1; }
@scripts/leak-check
+ @scripts/man-lint
@echo "ci ok"
+bench: ## run the Go benchmarks on generated trees
+ go test -run '^$$' -bench . -benchmem ./...
+
+cross: ## cross-compile linux/amd64, linux/arm64, freebsd/amd64, openbsd/amd64 into dist/krino-$(VERSION)-<os>-<arch>/
+ @case '$(VERSION)' in \
+ ''|*[!A-Za-z0-9._+-]*) \
+ echo "refusing: VERSION must contain only letters, digits, and the characters . - _ +" >&2; \
+ exit 1 ;; \
+ esac
+ @for t in $(CROSS_PLATFORMS); do \
+ os=$${t%/*}; arch=$${t#*/}; \
+ dir=dist/krino-$(VERSION)-$$os-$$arch; \
+ case "$$dir" in \
+ dist/krino-*) rm -rf "$$dir" ;; \
+ *) echo "refusing to remove unexpected path: $$dir" >&2; exit 1 ;; \
+ esac; \
+ mkdir -p "$$dir"; \
+ echo "cross: $$os/$$arch -> $$dir/$(BIN)"; \
+ CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" -o "$$dir/$(BIN)" ./cmd/krino || exit 1; \
+ for f in README.md LICENSE CHANGELOG.md man/krino.1 man/krino.conf.5 docs/sexp-primer.md; do \
+ test -f "$$f" && cp "$$f" "$$dir/$$(basename "$$f")"; \
+ done; \
+ test -d examples && cp -r examples "$$dir/examples"; \
+ done
+
+# dist: cross plus tarballs and SHA256SUMS, no tag. Undocumented plumbing so
+# `make release` is verifiable without tagging; deliberately absent from
+# `make help` (no `## ` comment).
+dist: cross
+ @command -v sha256sum >/dev/null 2>&1 && SUM=sha256sum || SUM="sha256 -r"; \
+ tarballs=""; \
+ cd dist && \
+ for t in $(CROSS_PLATFORMS); do \
+ os=$${t%/*}; arch=$${t#*/}; \
+ name=krino-$(VERSION)-$$os-$$arch; \
+ tar czf "$$name.tar.gz" "$$name" || exit 1; \
+ tarballs="$$tarballs $$name.tar.gz"; \
+ done && \
+ $$SUM $$tarballs > SHA256SUMS
+
+release: ## make cross + tarballs + SHA256SUMS in dist/, then tag v$(VERSION) (annotated; never pushes) - usage: make release VERSION=0.0.1
+ @case '$(VERSION)' in \
+ ''|*[!A-Za-z0-9._+-]*) \
+ echo "refusing: VERSION must contain only letters, digits, and the characters . - _ +" >&2; \
+ exit 1 ;; \
+ esac
+ @default_version=$$(git describe --tags --always --dirty 2>/dev/null || echo dev); \
+ test -n "$(VERSION)" && test "$(VERSION)" != "$$default_version" || \
+ { echo "refusing: pass an explicit VERSION, e.g. make release VERSION=0.0.1"; exit 1; }
+ @test -z "$$(git status --porcelain)" || \
+ { echo "refusing: working tree is not clean (git status --porcelain)"; exit 1; }
+ @git rev-parse -q --verify refs/tags/v$(VERSION) >/dev/null 2>&1 && \
+ { echo "refusing: tag v$(VERSION) already exists"; exit 1; } || true
+ @test -f README.md || \
+ { echo "refusing: README.md is missing"; exit 1; }
+ $(MAKE) ci
+ $(MAKE) dist VERSION=$(VERSION)
+ git tag -a "v$(VERSION)" -m "krino v$(VERSION)"
+ @echo "release v$(VERSION) built in dist/; tag v$(VERSION) created, not pushed"
+
+deps: ## install the optional content extractors; may ask for privileges
+ scripts/deps
+
install-hooks: ## install the pre-commit hook that runs the leak check
install -m 755 scripts/hooks/pre-commit "$$(git rev-parse --git-path hooks)/pre-commit"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4b7d4c3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,214 @@
+# krino
+
+krino sorts the files in one or more directories by rules: conditions on a
+file's type, name, path, size, age, text content or duplicate status,
+combined with `and` / `or` / `not`, that trigger actions — copy, move,
+rename, delete (to the Trash by default), chained in the order written. It
+shows the plan before touching anything, lets you approve all of it or file
+by file, logs every step, and can undo a run. Version 0.0.1 is the engine
+and this command-line interface: per-directory rule files, placeholders in
+destinations and new names, and builds for Linux, FreeBSD and OpenBSD. It is
+not a GUI (that comes later), a watch mode, OCR, EXIF dates, macOS or
+Windows support, a content cache, a shell-command action, or a way to look
+inside archives.
+
+## Install
+
+```
+make && make install
+```
+
+`make` builds `./krino`; Go fetches the module dependencies itself. `make
+install` puts the binary in `$PREFIX/bin` (default `~/.local/bin`), the man
+pages in `$PREFIX/share/man`, and the examples and `docs/sexp-primer.md` in
+`$PREFIX/share/doc/krino`.
+
+Some `(content ...)` tests need external extractors — `pdftotext` for PDF,
+`antiword`/`catdoc` for legacy Word, `xls2csv`/`catppt` for legacy Excel and
+PowerPoint. `make build` reports which of these are missing; install them
+with:
+
+```
+make deps
+```
+
+`make deps` is the only target that asks for privileges — it calls the
+system package manager (`apt-get`, `pkg`, or `pkg_add`, depending on the
+OS). Plain `make` never does.
+
+## The 60-second quickstart
+
+Everything below happens in `/tmp/krino-demo` and a scratch config under
+`/tmp/krino-xdg`, so it never touches a real directory or your real krino
+config. Copy-paste it as written and your output should match what's pasted
+here, except for the run id and timestamps, which are different every time.
+
+Create a handful of files of different types:
+
+```
+mkdir -p /tmp/krino-demo
+cd /tmp/krino-demo
+for f in acme-invoice.pdf photo.jpg podcast.mp3 archive.zip app.deb notes.txt; do
+ printf 'demo file: %s\n' "$f" >"$f"
+done
+```
+
+Point krino at a scratch config for this walkthrough:
+
+```
+export XDG_CONFIG_HOME=/tmp/krino-xdg/config
+export XDG_STATE_HOME=/tmp/krino-xdg/state
+export XDG_DATA_HOME=/tmp/krino-xdg/data
+```
+
+Set it up:
+
+```
+$ krino init
+created /tmp/krino-xdg/config/krino/krino.conf
+created /tmp/krino-xdg/config/krino/template.conf
+next: krino new NAME PATH, for example: krino new downloads ~/Downloads
+$ krino new demo /tmp/krino-demo
+created /tmp/krino-xdg/config/krino/dirs/demo.conf and added "demo" to include
+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
+demo files we just created are younger than, so this also turns it down to
+0 for this directory:
+
+```
+cat > "$XDG_CONFIG_HOME/krino/dirs/demo.conf" <<'EOF'
+;; -*- mode: lisp -*-
+;; vim: set ft=lisp :
+
+(path "/tmp/krino-demo")
+(min-age 0s) ; demo files are seconds old; built-in is 2m
+
+(rule "acme"
+ (when (name "acme"))
+ (move "Filed/Acme"))
+EOF
+```
+
+This one rule matches any file whose name contains "acme" and moves it into
+`Filed/Acme`. See what it would do, without changing anything:
+
+```
+$ krino -n demo
+krino: demo /tmp/krino-demo
+6 scanned · 1 to act on · 0 warnings · 0.00s
+
+ # file actions rule
+ 1 acme-invoice.pdf move → Filed/Acme/ acme name "acme"
+
+not acted on: 5 unmatched (-v lists them)
+```
+
+Without `-n` or `-y`, `krino demo` shows the same plan and then asks, one
+key at a time, no Enter needed:
+
+```
+[a] apply all [c] choose per file [s] skip this directory [q] quit
+```
+
+`c` asks about each file in turn:
+
+```
+ [y] yes [n] no [a] yes to this and all remaining [d] done, apply chosen so far [q] quit, apply nothing
+```
+
+Approval is per file: a file's whole chain runs, or none of it. 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:
+
+```
+$ krino -y demo
+krino: demo /tmp/krino-demo
+6 scanned · 1 to act on · 0 warnings · 0.00s
+
+ # file actions rule
+ 1 acme-invoice.pdf move → Filed/Acme/ acme name "acme"
+
+not acted on: 5 unmatched (-v lists them)
+1 applied · 0 failed · 0 declined
+```
+
+`acme-invoice.pdf` is now in `Filed/Acme/`. Every run is logged:
+
+```
+$ krino log
+20260913T005509-db14 2026-09-13 00:55 demo 1 moved
+```
+
+And every run can be undone — `undo` alone would show its own plan and ask
+the same way, with a menu of its own:
+
+```
+[a] apply all [c] choose per file [s] skip [q] quit
+```
+
+`-y` applies it directly:
+
+```
+$ krino undo -y
+krino: undo 20260913T005509-db14
+1 files · 1 to reverse · 0 refused
+
+ # file steps
+ 1 demo/acme-invoice.pdf undo-move → /tmp/krino-demo/acme-invoice.pdf
+ undo-mkdir /tmp/krino-demo/Filed/Acme
+ undo-mkdir /tmp/krino-demo/Filed
+1 applied · 0 failed · 0 declined
+```
+
+`/tmp/krino-demo` is back exactly as the first `printf` loop left it — that
+is what `krino undo` is for. Delete both scratch directories when you're
+done: `rm -rf /tmp/krino-demo /tmp/krino-xdg`.
+
+## Configuration
+
+Rules are s-expressions. `docs/sexp-primer.md` is a short tutorial on the
+syntax with no Lisp background assumed; `krino.conf(5)` (`man/krino.conf.5`
+in the source, installed as a man page) is the reference for every form,
+setting and condition. `examples/` has four worked rule files: by type, by
+content (invoices), by age (old installers), and renaming (screenshots).
+
+Rules live in `$XDG_CONFIG_HOME/krino` (default `~/.config/krino`) —
+**never in this repository**. A leak check guards that: it runs in `make ci`,
+and on every commit only after `make install-hooks`. It refuses staged files
+that contain your home directory's path or an email address, and it catches
+rule content only when a private pattern list is configured with
+`git config krino.leakpatterns FILE`.
+
+## Safety
+
+- `(delete)` moves a file to the freedesktop.org Trash by default, not
+ `unlink(2)` — recoverable, by hand or with `krino undo`. `(delete
+ permanent)` is the explicit opt-out, and undo can never reverse it.
+- Every step of every run is appended to `$XDG_STATE_HOME/krino/krino.log`
+ (default `~/.local/state/krino/krino.log`), tab-separated and
+ `grep`-able.
+- `krino undo` reverses the most recent run, or a specific one by id from
+ `krino log`. A reversal that the world has moved on since (the target is
+ gone, changed, or the original path is occupied again) is refused for
+ that file rather than guessed at.
+- `-n` prints the plan and changes nothing, ever — no config is written, no
+ file is touched, nothing is logged.
+
+## Status
+
+**0.0.1, local only.** The module path is `krino`; `go install
+krino@v0.0.1` only works once a remote is chosen and the imports are
+renamed to match (`docs/design.md`, §18). Until then, build from a clone.
+`krino -n --json`'s output shape is unstable before krino 1.0 — don't
+script against it yet.
+
+## License
+
+Everything in this repository — code, man pages, examples and the embedded
+templates — is GPL-3.0-or-later. Full text in `LICENSE`. Go files, shell
+scripts and man pages carry an SPDX identifier.
diff --git a/cmd/krino/history_test.go b/cmd/krino/history_test.go
index 2667b63..23b1e5f 100644
--- a/cmd/krino/history_test.go
+++ b/cmd/krino/history_test.go
@@ -315,3 +315,66 @@ func TestReviewUndoInvalidKeyReprompts(t *testing.T) {
t.Errorf("no mention of the rejected key:\n%s", out)
}
}
+
+// TestGlobalDryRunBeforeUndo: -n written before the subcommand, the way
+// krino.1 teaches flags, is a dry run of the undo. It shows the plan,
+// exits 0, and moves nothing back.
+func TestGlobalDryRunBeforeUndo(t *testing.T) {
+ h := matchingFixture(t)
+ if code, _, errOut := runCLI(t, "-y"); code != 0 {
+ t.Fatalf("apply: %d %s", code, errOut)
+ }
+ filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt")
+
+ code, out, errOut := runCLI(t, "-n", "undo")
+ if code != 0 || !strings.Contains(out, "undo-move") {
+ t.Errorf("-n undo: %d %q\n%s", code, errOut, out)
+ }
+ if _, err := os.Stat(filed); err != nil {
+ t.Errorf("-n undo moved a file: %v", err)
+ }
+ if _, out, _ = runCLI(t, "log"); strings.Contains(out, "undone") {
+ t.Errorf("-n undo marked the run undone:\n%s", out)
+ }
+}
+
+// TestGlobalDryRunBeforeUndoConflictsWithYes: -n before the subcommand and
+// -y after it is the same conflict as both after it: exit 2, nothing
+// changed.
+func TestGlobalDryRunBeforeUndoConflictsWithYes(t *testing.T) {
+ h := matchingFixture(t)
+ if code, _, errOut := runCLI(t, "-y"); code != 0 {
+ t.Fatalf("apply: %d %s", code, errOut)
+ }
+ filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt")
+
+ code, _, errOut := runCLI(t, "-n", "undo", "-y")
+ if code != 2 || !strings.Contains(errOut, "-y and -n cannot be used together") {
+ t.Errorf("-n undo -y: %d %q", code, errOut)
+ }
+ if _, err := os.Stat(filed); err != nil {
+ t.Errorf("-n undo -y moved a file: %v", err)
+ }
+ if _, out, _ := runCLI(t, "log"); strings.Contains(out, "undone") {
+ t.Errorf("-n undo -y marked the run undone:\n%s", out)
+ }
+}
+
+// TestGlobalYesBeforeUndo: -y before the subcommand applies the undo.
+func TestGlobalYesBeforeUndo(t *testing.T) {
+ h := matchingFixture(t)
+ if code, _, errOut := runCLI(t, "-y"); code != 0 {
+ t.Fatalf("apply: %d %s", code, errOut)
+ }
+ filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt")
+
+ if code, _, errOut := runCLI(t, "-y", "undo"); code != 0 {
+ t.Fatalf("-y undo: %d %s", code, errOut)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "inv1.txt")); err != nil {
+ t.Errorf("-y undo did not put the file back: %v", err)
+ }
+ if _, err := os.Stat(filed); !os.IsNotExist(err) {
+ t.Error("the filed copy survived -y undo")
+ }
+}
diff --git a/cmd/krino/main.go b/cmd/krino/main.go
index e204b5f..f5fdfea 100644
--- a/cmd/krino/main.go
+++ b/cmd/krino/main.go
@@ -9,12 +9,32 @@ import (
"fmt"
"io"
"os"
+ "runtime/debug"
"strings"
)
// version is stamped by the Makefile with -ldflags "-X main.version=...".
var version = "dev"
+// displayVersion returns the version to print for --version. The Makefile
+// stamps a git tag, which carries a leading "v" (required for
+// go install ...@v0.0.1, per design.md §14); that "v" belongs on the tag,
+// not in the output, so it is stripped here. A "go install" build carries no
+// ldflags, leaving version at its built-in "dev"; in that case fall back to
+// the build info Go embeds automatically.
+func displayVersion() string {
+ v := version
+ if v == "dev" {
+ if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
+ v = info.Main.Version
+ }
+ }
+ if len(v) > 1 && v[0] == 'v' && v[1] >= '0' && v[1] <= '9' {
+ v = v[1:]
+ }
+ return v
+}
+
const usage = `usage: krino [-y | -n] [-v] [--json] [-c FILE] [NAME...]
krino init
krino new NAME PATH
@@ -63,7 +83,7 @@ func run(args []string, stdout, stderr io.Writer) int {
return code
}
if *showVersion {
- fmt.Fprintf(stdout, "krino %s\n", version)
+ fmt.Fprintf(stdout, "krino %s\n", displayVersion())
return 0
}
rest := fs.Args()
diff --git a/cmd/krino/main_test.go b/cmd/krino/main_test.go
index 1c01fa0..6e4df44 100644
--- a/cmd/krino/main_test.go
+++ b/cmd/krino/main_test.go
@@ -62,3 +62,24 @@ func TestSortNoConfig(t *testing.T) {
t.Fatalf("got %d %q", code, errOut)
}
}
+
+func TestVersionStripsTheTagPrefix(t *testing.T) {
+ old := version
+ t.Cleanup(func() { version = old })
+ for _, tc := range []struct{ stamped, want string }{
+ {"v0.0.1", "krino 0.0.1\n"},
+ {"v0.0.1-3-gabc1234", "krino 0.0.1-3-gabc1234\n"},
+ {"v0.0.1-dirty", "krino 0.0.1-dirty\n"},
+ {"0.0.1", "krino 0.0.1\n"},
+ {"dev", "krino dev\n"},
+ } {
+ version = tc.stamped
+ var out, errOut strings.Builder
+ if code := run([]string{"--version"}, &out, &errOut); code != 0 {
+ t.Errorf("%s: exit %d", tc.stamped, code)
+ }
+ if out.String() != tc.want {
+ t.Errorf("stamped %q: got %q, want %q", tc.stamped, out.String(), tc.want)
+ }
+ }
+}
diff --git a/cmd/krino/sort_test.go b/cmd/krino/sort_test.go
index e482f44..fa9a7ca 100644
--- a/cmd/krino/sort_test.go
+++ b/cmd/krino/sort_test.go
@@ -110,3 +110,66 @@ func TestAllSkippedDirectoryReportsZeroAndLogsNothing(t *testing.T) {
t.Errorf("journal gained entries for a directory with nothing to act on:\n%s", data)
}
}
+
+// TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy drives the
+// whole run end to end on the shape where a (duplicate "DIR") overlaps the
+// scanned tree: a recursive root holding Archive/x.pdf and a loose, older
+// x-copy.pdf with the same bytes, and a rule that permanently deletes
+// duplicates of anything under Archive. Whatever the verdicts, applying
+// the plan must leave the content on disk; the expected outcome is that
+// the archived copy stays and only the loose one goes.
+func TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy(t *testing.T) {
+ h := home(t)
+ dl := filepath.Join(h, "dl")
+ content := []byte("%PDF acme statement")
+ archived := filepath.Join(dl, "Archive", "x.pdf")
+ loose := filepath.Join(dl, "x-copy.pdf")
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for p, mt := range map[string]time.Time{archived: old.Add(time.Hour), loose: old} {
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, content, 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)
+ }
+ rules := "(path \"~/dl\")\n(recursive yes)\n(min-age 0s)\n" +
+ "(rule \"dups\" (when (duplicate \"Archive\")) (delete permanent))\n"
+ if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ code, out, errOut := runCLI(t, "-y")
+
+ var copies []string
+ err := filepath.WalkDir(dl, func(p string, d os.DirEntry, err error) error {
+ if err != nil || !d.Type().IsRegular() {
+ return err
+ }
+ if b, err := os.ReadFile(p); err == nil && string(b) == string(content) {
+ copies = append(copies, p)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(copies) == 0 {
+ t.Fatalf("every copy was deleted (exit %d)\nstdout:\n%s\nstderr:\n%s", code, out, errOut)
+ }
+ if code != 0 {
+ t.Fatalf("run: %d %s", code, errOut)
+ }
+ if len(copies) != 1 || copies[0] != archived {
+ t.Errorf("copies left = %v, want only %s", copies, archived)
+ }
+}
diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go
index 7ffe428..c773f9a 100644
--- a/cmd/krino/undo.go
+++ b/cmd/krino/undo.go
@@ -41,8 +41,10 @@ func init() { commands["undo"] = cmdUndo }
// approval here is keyed by index rather than by name.
func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
fs := flagSet("undo", g)
- fs.BoolVar(&g.yes, "y", false, "")
- fs.BoolVar(&g.dry, "n", false, "")
+ // The defaults are the values run() already parsed, so -y or -n written
+ // before "undo" survives registering them again here.
+ fs.BoolVar(&g.yes, "y", g.yes, "")
+ fs.BoolVar(&g.dry, "n", g.dry, "")
if code, ok := parse(fs, args, stdout, stderr); !ok {
return code
}
diff --git a/docs/design.md b/docs/design.md
index 8e4e914..9a15425 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -1,6 +1,6 @@
# krino design
-Status: draft for review, 2026-09-11. Target release: **0.0.1**.
+Status: describes krino 0.0.1, 2026-09-13.
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,
@@ -216,12 +216,22 @@ Candidates are the scanned files plus, for `(duplicate "DIR"...)`, every
regular file under those directories (symlinks skipped). Files are grouped
by size. Only groups of two or more are hashed: first the first and last
64 KiB, then SHA-256 of the whole file. Empty files are never duplicates.
+Two names for the same file — the same device and inode, as a hardlink
+creates — are never duplicates of each other; they may still both be
+duplicates of a separate identical file.
The **original** in each group is, in order of preference: a file under one
of the given DIRs, then the oldest by mtime, then the shortest name, then
the name that sorts first. `(duplicate)` is true for every other scanned
file in the group.
+`(duplicate)` conditions with different scopes do not share an original:
+two conditions electing from different candidate sets can each treat a
+different file as the original of the same content, and within one
+directory's rules this can select every copy in a group for deletion. A
+run-wide election that keeps at least one copy of every group is not in
+0.0.1.
+
## 6. Content extraction
| Format | Method |
@@ -427,9 +437,10 @@ not logged; declined files are.
## 10. Undo
- `krino log` lists recent runs: id, time, directories, counts.
-- `krino undo` reverses the most recent run that has not been undone, in
- every directory it touched; `krino undo RUN` a specific one. Undo runs
- cannot themselves be undone.
+- `krino undo` reverses the most recent run, in every directory it
+ touched; if that run is itself an undo run, it is refused. An older run
+ 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).
@@ -526,17 +537,19 @@ Rules for the GUI to come:
- Measured before release on a real downloads directory of a few hundred
files, about half of them PDFs, and reported in the release notes.
**No performance target for 0.0.1.** Measured throughput is dominated by the
- external extractors, not by krino: on a real folder of 265 files, 164 of them
- needing `pdftotext` at roughly 80 ms each, a full plan takes 12-17 s, while a
- synthetic tree of 4405 files needing no extraction takes 1.09 s. A single
- threshold would therefore describe poppler's speed and the shape of one
- folder rather than anything 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.
+ external extractors, not by krino: on a real folder of about 265 files, 164
+ of them needing `pdftotext` at roughly 80 ms each, full plans measured
+ between about 13 and 20 s across runs, while a synthetic tree of 4405 files
+ needing no extraction takes 1.09 s. A single threshold would therefore
+ describe poppler's speed and the shape of one folder rather than anything
+ 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.
## 14. Build, dependencies, release
-- Go 1.24 or newer. `CGO_ENABLED=0`: static binaries.
+- Go 1.24 or newer. `CGO_ENABLED=0`: static binaries on Linux and FreeBSD;
+ on OpenBSD Go links against the system libc, as that platform requires.
- Go dependencies: `golang.org/x/term` (key-at-a-time input) and
`golang.org/x/text` (Unicode normalisation for `fold`). Everything else is
the standard library, including the s-expression reader and the gitignore
@@ -571,8 +584,7 @@ Repository contents for 0.0.1: `README.md` (60-second quickstart),
`LICENSE`, `CHANGELOG.md`, `Makefile`, `go.mod`, `cmd/`, `internal/`, `scripts/`,
`man/krino.1`, `man/krino.conf.5`, `docs/design.md`,
`docs/sexp-primer.md`, `examples/` (by type, invoices by tax number with
-placeholder numbers, screenshots by date, cleaning up old installers),
-`testdata/`.
+placeholder numbers, screenshots by date, cleaning up old installers).
## 15. Testing
@@ -583,8 +595,10 @@ placeholder numbers, screenshots by date, cleaning up old installers),
path against `git check-ignore --no-index` (skipped if git is absent).
- **Conditions:** truth tables; a property test that random condition trees
give the same result with and without cost reordering.
-- **Extraction:** small fixtures in `testdata/` for each format; PDF and
- legacy-format tests skip when the tool is missing.
+- **Extraction:** the tests generate their fixtures in temporary
+ directories and use fake tools for PDF and the legacy formats (doc, xls,
+ ppt); the real `pdftotext` test skips when the tool is missing. Real
+ office-suite files and real legacy-tool runs are not tested in 0.0.1.
- **Plan, apply, undo:** in temporary directories: build a tree, plan,
apply, check; undo; check the tree is identical to the start (paths,
contents, modes, mtimes). A cross-filesystem move test runs when a tmpfs is
@@ -629,8 +643,9 @@ keyword. Its defects shaped these decisions:
remote is chosen, `go mod edit -module <path>` and one `sed` over the
`"krino/internal/...` imports rename it; nothing else depends on it.
`go install ...@v0.0.1` works only after that.
-2. **License: GPL-3.0-or-later.** Full text in `LICENSE`; every source file
- carries `// SPDX-License-Identifier: GPL-3.0-or-later`.
+2. **License: GPL-3.0-or-later.** Full text in `LICENSE`; every Go file,
+ shell script and man page carries an
+ `SPDX-License-Identifier: GPL-3.0-or-later` line.
## Appendix A: type groups
diff --git a/examples/by-type.conf b/examples/by-type.conf
new file mode 100644
index 0000000..5b363df
--- /dev/null
+++ b/examples/by-type.conf
@@ -0,0 +1,55 @@
+;; -*- mode: lisp -*-
+;; vim: set ft=lisp :
+;;
+;; by-type.conf — file everything into one folder per type group.
+;;
+;; Demonstrates: (type ...) with the built-in type groups, (move ...), and
+;; (stop) so a file already claimed by one rule is never moved again by a
+;; later one. The six groups below happen not to overlap each other, but
+;; krino's type groups can (a .json file is both "code" and "text", for
+;; instance — see TYPE GROUPS in krino.conf(5)), so (stop) is worth keeping
+;; once you add rules of your own.
+;;
+;; To use: copy this to dirs/<name>.conf, fix (path ...) below, and add
+;; <name> to (include ...) in krino.conf — or just run:
+;; krino new <name> ~/wherever
+;; and paste the (rule ...) forms in below the generated header. A short
+;; tutorial on the syntax is docs/sexp-primer.md in the source repository,
+;; installed alongside this file at
+;; $PREFIX/share/doc/krino/sexp-primer.md; every form is described in
+;; krino.conf(5).
+
+(path "~/Downloads") ; CHANGE THIS to the directory to sort
+
+;; Leave partial downloads and dotfiles alone.
+(ignore "*.part" "*.crdownload" "*.aria2" ".*")
+
+(rule "images"
+ (when (type image))
+ (move "Filed/Images")
+ (stop))
+
+(rule "documents"
+ (when (type document))
+ (move "Filed/Documents")
+ (stop))
+
+(rule "spreadsheets"
+ (when (type spreadsheet))
+ (move "Filed/Spreadsheets")
+ (stop))
+
+(rule "presentations"
+ (when (type presentation))
+ (move "Filed/Presentations")
+ (stop))
+
+(rule "archives"
+ (when (type archive))
+ (move "Filed/Archives")
+ (stop))
+
+(rule "audio-video"
+ (when (or (type audio) (type video)))
+ (move "Filed/Media")
+ (stop))
diff --git a/examples/invoices.conf b/examples/invoices.conf
new file mode 100644
index 0000000..9fca018
--- /dev/null
+++ b/examples/invoices.conf
@@ -0,0 +1,33 @@
+;; -*- mode: lisp -*-
+;; vim: set ft=lisp :
+;;
+;; invoices.conf — file invoices by content, into a folder per year.
+;;
+;; Demonstrates: (content ...) matching text pulled out of PDFs and other
+;; documents (see krino.conf(5), CONTENT EXTRACTION), and the {mtime:%Y}
+;; placeholder in a (move ...) destination.
+;;
+;; 0000000000 below is a placeholder, not an issued Polish NIP — no such
+;; number is ever assigned. The conditions in (when ...) must all hold, and
+;; one (content ...) is true when any of its keywords appears, so the rule
+;; needs a keyword AND the number. Replace the number with your supplier's
+;; or your own NIP to narrow the rule to that tax number, or delete the
+;; (content "0000000000") condition to match on keywords alone.
+;;
+;; To use: copy this to dirs/<name>.conf, fix (path ...) below, and add
+;; <name> to (include ...) in krino.conf. A short tutorial on the syntax is
+;; docs/sexp-primer.md in the source repository, installed alongside this
+;; file at $PREFIX/share/doc/krino/sexp-primer.md; every form is described
+;; in krino.conf(5).
+
+(path "~/Downloads") ; CHANGE THIS to the directory to sort
+
+;; Leave partial downloads and dotfiles alone.
+(ignore "*.part" "*.crdownload" "*.aria2" ".*")
+
+(rule "acme-invoices"
+ (when (type document)
+ (content "faktura" "invoice")
+ (content "0000000000")) ; 0000000000: fake NIP, see above
+ (move "Filed/Invoices/{mtime:%Y}")
+ (stop))
diff --git a/examples/old-installers.conf b/examples/old-installers.conf
new file mode 100644
index 0000000..6ad63e7
--- /dev/null
+++ b/examples/old-installers.conf
@@ -0,0 +1,26 @@
+;; -*- mode: lisp -*-
+;; vim: set ft=lisp :
+;;
+;; old-installers.conf — clean up old installers with (age ...) and (delete).
+;;
+;; Demonstrates: (type package), (age ...) by modification time, and
+;; (delete) — which moves a matched file to the desktop trash
+;; ($XDG_DATA_HOME/Trash), NOT a permanent delete; recover a mistake from
+;; there. Use (delete permanent) instead only if you really want an unlink
+;; that undo cannot reverse — see krino.conf(5), ACTIONS.
+;;
+;; To use: copy this to dirs/<name>.conf, fix (path ...) below, and add
+;; <name> to (include ...) in krino.conf. A short tutorial on the syntax is
+;; docs/sexp-primer.md in the source repository, installed alongside this
+;; file at $PREFIX/share/doc/krino/sexp-primer.md; every form is described
+;; in krino.conf(5).
+
+(path "~/Downloads") ; CHANGE THIS to the directory to sort
+
+;; Leave partial downloads and dotfiles alone.
+(ignore "*.part" "*.crdownload" "*.aria2" ".*")
+
+(rule "old-installers"
+ (when (type package)
+ (age > 90d))
+ (delete))
diff --git a/examples/screenshots.conf b/examples/screenshots.conf
new file mode 100644
index 0000000..6532bab
--- /dev/null
+++ b/examples/screenshots.conf
@@ -0,0 +1,27 @@
+;; -*- mode: lisp -*-
+;; vim: set ft=lisp :
+;;
+;; screenshots.conf — rename screenshots by date, then file them by year.
+;;
+;; Demonstrates: (rename ...) with the {mtime:FMT} placeholder run before
+;; (move ...) — a rule's steps run in the order written, and a later step
+;; sees the name or path an earlier one just produced — so the date lands
+;; in the file's own name, not only in its destination.
+;;
+;; To use: copy this to dirs/<name>.conf, fix (path ...) below, and add
+;; <name> to (include ...) in krino.conf. A short tutorial on the syntax is
+;; docs/sexp-primer.md in the source repository, installed alongside this
+;; file at $PREFIX/share/doc/krino/sexp-primer.md; every form is described
+;; in krino.conf(5).
+
+(path "~/Pictures") ; CHANGE THIS to the directory to sort
+
+;; Leave partial downloads and dotfiles alone.
+(ignore "*.part" "*.crdownload" "*.aria2" ".*")
+
+(rule "screenshots"
+ (when (type image)
+ (name "screenshot")) ; case-insensitive by default; adjust to taste
+ (rename "{mtime:%Y-%m-%d}_{name}")
+ (move "Filed/Screenshots/{mtime:%Y}")
+ (stop))
diff --git a/internal/apply/apply.go b/internal/apply/apply.go
index 2cd52e4..f177a17 100644
--- a/internal/apply/apply.go
+++ b/internal/apply/apply.go
@@ -153,7 +153,7 @@ func runFileStep(step plan.Step) StepResult {
case plan.Move:
err = moveFile(step.Src, dst)
case plan.Rename:
- err = os.Rename(step.Src, dst)
+ err = renameFile(step.Src, dst)
}
if err != nil {
return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go
index 64b0176..4bf6c30 100644
--- a/internal/apply/apply_test.go
+++ b/internal/apply/apply_test.go
@@ -250,3 +250,45 @@ func TestChainMadeIsOutermostFirstForNestedDirectories(t *testing.T) {
t.Errorf("Made = %v, want %v (outermost first)", got[0].Made, want)
}
}
+
+// TestMoveFileRefusesOccupiedDestination is item 16 (fix round 2026-09-12,
+// plan 5 Task 2): moveFile must refuse an occupied destination on its own,
+// not merely rely on runFileStep having already checked - the exact
+// arrangement that produced plan 4's Task 5 Critical, where a helper that
+// replaced silently was trusted because some caller had checked. Called
+// directly, bypassing runFileStep's own pre-check entirely.
+func TestMoveFileRefusesOccupiedDestination(t *testing.T) {
+ dir := t.TempDir()
+ src := write(t, filepath.Join(dir, "x.pdf"), "source", 0o644)
+ dst := write(t, filepath.Join(dir, "y.pdf"), "already there", 0o644)
+
+ if err := moveFile(src, dst); err == nil {
+ t.Fatal("moveFile overwrote an existing destination")
+ }
+ if b, err := os.ReadFile(src); err != nil || string(b) != "source" {
+ t.Errorf("moveFile touched its source: %q, %v", b, err)
+ }
+ if b, err := os.ReadFile(dst); err != nil || string(b) != "already there" {
+ t.Errorf("moveFile touched its destination: %q, %v", b, err)
+ }
+}
+
+// TestRenameFileRefusesOccupiedDestination is item 16's other half:
+// runFileStep's bare os.Rename call for the Rename kind was just as
+// unguarded in itself as moveFile was. renameFile is the helper that now
+// carries the same independent guard, called directly here.
+func TestRenameFileRefusesOccupiedDestination(t *testing.T) {
+ dir := t.TempDir()
+ src := write(t, filepath.Join(dir, "x.pdf"), "source", 0o644)
+ dst := write(t, filepath.Join(dir, "y.pdf"), "already there", 0o644)
+
+ if err := renameFile(src, dst); err == nil {
+ t.Fatal("renameFile overwrote an existing destination")
+ }
+ if b, err := os.ReadFile(src); err != nil || string(b) != "source" {
+ t.Errorf("renameFile touched its source: %q, %v", b, err)
+ }
+ if b, err := os.ReadFile(dst); err != nil || string(b) != "already there" {
+ t.Errorf("renameFile touched its destination: %q, %v", b, err)
+ }
+}
diff --git a/internal/apply/fs.go b/internal/apply/fs.go
index 205089f..823d5c8 100644
--- a/internal/apply/fs.go
+++ b/internal/apply/fs.go
@@ -108,6 +108,16 @@ func moveFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
+ // Item 16 (fix round 2026-09-12, plan 5 Task 2): this guard must hold
+ // independently of runFileStep's own pre-check, layered rather than
+ // moved - the exact arrangement that produced plan 4's Task 5 Critical,
+ // where a helper that replaced silently was trusted because some caller
+ // had checked. Placed immediately before the operation that would
+ // otherwise clobber dst, the same way copyFile's own guard sits right
+ // before its rename into place.
+ if err := refuseIfExists(dst); err != nil {
+ return err
+ }
err := os.Rename(src, dst)
if err == nil {
return nil
@@ -121,6 +131,31 @@ func moveFile(src, dst string) error {
return os.Remove(src)
}
+// renameFile renames src to dst, refusing on its own when dst already
+// exists rather than trusting that a caller checked first (item 16, same
+// reasoning as moveFile's guard above): a bare os.Rename silently replaces
+// an occupied destination, and runFileStep's own pre-check must not be the
+// only thing standing between a rename step and that.
+func renameFile(src, dst string) error {
+ if err := refuseIfExists(dst); err != nil {
+ return err
+ }
+ return os.Rename(src, dst)
+}
+
+// refuseIfExists reports an error naming dst if something is already there
+// (os.Lstat succeeds, following no symlink), and propagates any other stat
+// failure. A nil return means dst was confirmed absent at the moment of the
+// check.
+func refuseIfExists(dst string) error {
+ if _, err := os.Lstat(dst); err == nil {
+ return fmt.Errorf("destination already exists: %s", dst)
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
// maxSuffixAttempts bounds nextFreeName. internal/plan/conflict.go and
// internal/trash/trash.go each have their own cap of the same size, for the
// same reason given below: nextFreeName solves yet another, independent
diff --git a/internal/config/skel/krino.conf b/internal/config/skel/krino.conf
index 48780ea..dbd883b 100644
--- a/internal/config/skel/krino.conf
+++ b/internal/config/skel/krino.conf
@@ -1,8 +1,9 @@
;; -*- mode: lisp -*-
;; vim: set ft=lisp :
;;
-;; krino's main configuration. The syntax is explained in
-;; docs/sexp-primer.md; every form is described in krino.conf(5).
+;; krino's main configuration. The syntax is explained in sexp-primer.md,
+;; installed as share/doc/krino/sexp-primer.md under the install prefix,
+;; which is ~/.local by default; every form is described in krino.conf(5).
;; The directories to sort, in this order. Each NAME has its rules in
;; dirs/NAME.conf. Add one with: krino new NAME PATH
diff --git a/internal/config/skel/template.conf b/internal/config/skel/template.conf
index 867b681..c3af09d 100644
--- a/internal/config/skel/template.conf
+++ b/internal/config/skel/template.conf
@@ -1,8 +1,9 @@
;; -*- mode: lisp -*-
;; vim: set ft=lisp :
;;
-;; krino rules for one directory. The syntax is explained in
-;; docs/sexp-primer.md; every form is described in krino.conf(5).
+;; krino rules for one directory. The syntax is explained in sexp-primer.md,
+;; installed as share/doc/krino/sexp-primer.md under the install prefix,
+;; which is ~/.local by default; every form is described in krino.conf(5).
(path "@PATH@")
diff --git a/internal/dup/dup.go b/internal/dup/dup.go
index e3c7424..32bcfb0 100644
--- a/internal/dup/dup.go
+++ b/internal/dup/dup.go
@@ -174,18 +174,47 @@ func (x *Index) Lookup(path string) (original string, dup bool, err error) {
return path, false, nil
}
orig := x.candidates[x.original(identical)].path
- return orig, orig != path, nil
+ if orig == path {
+ return orig, false, nil
+ }
+ // Spec §5.5: two names for one file are never duplicates of each other.
+ // The other name may be a hardlink, or path itself indexed a second time
+ // under a DIR that overlaps the scanned tree. identicalTo gives every
+ // member of the content class the same set, so every lookup elects the
+ // same original, and no name for that original's file is reported as a
+ // duplicate: its content always keeps at least one name. Portable:
+ // os.SameFile, never a Stat_t.Dev/Ino read (that field's type differs
+ // across freebsd/openbsd, which `make ci` vets).
+ origInfo, err := os.Lstat(orig)
+ if err != nil {
+ return "", false, err
+ }
+ pathInfo, err := os.Lstat(path)
+ if err != nil {
+ return "", false, err
+ }
+ if os.SameFile(origInfo, pathInfo) {
+ return orig, false, nil
+ }
+ return orig, true, nil
}
// identicalTo returns the indexes in group (which all share idx's size,
// idx included) whose content matches candidates[idx]: same partial hash,
-// then, only for those that collide, the same full hash. idx is the file
-// Lookup was asked about; a failure hashing it propagates, since Lookup can
-// answer nothing without it. A failure hashing any other candidate in group
-// only removes that candidate from consideration: a vanished candidate
-// (errors.Is fs.ErrNotExist) is dropped silently, any other failure is
-// recorded on the Index (see recordCandidateError) so the caller can warn
-// about it once matching is done.
+// then, only for those that collide, the same full hash. Every candidate
+// with identical bytes is included, whatever its path or inode: a hardlink
+// of idx, and idx's own path indexed a second time under an overlapping
+// extra directory, are both members. That keeps the set the same whichever
+// member Lookup was asked about, so every member elects the same original;
+// Lookup, not this function, decides that a name for the elected original's
+// own file is not a duplicate of it.
+//
+// idx is the file Lookup was asked about; a failure hashing it propagates,
+// since Lookup can answer nothing without it. A failure hashing any other
+// candidate in group only removes that candidate from consideration: a
+// vanished candidate (errors.Is fs.ErrNotExist) is dropped silently, any
+// other failure is recorded on the Index (see recordCandidateError) so the
+// caller can warn about it once matching is done.
func (x *Index) identicalTo(idx int, group []int) ([]int, error) {
idxPartial, err := x.partialHash(x.candidates[idx].path)
if err != nil {
@@ -219,9 +248,10 @@ func (x *Index) identicalTo(idx int, group []int) ([]int, error) {
x.recordCandidateError(x.candidates[j].path, err)
continue
}
- if jFull == idxFull {
- same = append(same, j)
+ if jFull != idxFull {
+ continue
}
+ same = append(same, j)
}
return same, nil
}
diff --git a/internal/dup/dup_test.go b/internal/dup/dup_test.go
index 5d2621c..ed7c1a3 100644
--- a/internal/dup/dup_test.go
+++ b/internal/dup/dup_test.go
@@ -352,3 +352,229 @@ func TestExtraDirSymlinkNotFollowed(t *testing.T) {
t.Errorf("symlinked extra dir was indexed despite the error: dup=%v orig=%s", dup, orig)
}
}
+
+// linked builds a scan.File for a hardlink of an already-put file: same
+// inode, so same content and metadata by construction, under a new name.
+func linked(t *testing.T, dir, name string, target scan.File) scan.File {
+ t.Helper()
+ p := filepath.Join(dir, name)
+ if err := os.Link(target.Path, p); err != nil {
+ t.Fatal(err)
+ }
+ fi, err := os.Stat(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return scan.File{Path: p, Rel: name, Name: name, Size: fi.Size(), ModTime: fi.ModTime()}
+}
+
+// TestHardlinksAreNotDuplicatesOfEachOther is R10 (plan 5 Task 2, added to
+// the task outside the brief): spec §5.5 groups duplicate candidates by size
+// and hash, with no inode check, so two hardlinked names - one inode, byte-
+// identical by construction - were judged a duplicate pair. A rule of
+// (when (duplicate)) (delete) would then remove a name the user relies on
+// even though nothing was ever actually copied. os.SameFile must stop a
+// file being judged a duplicate of itself under another name.
+func TestHardlinksAreNotDuplicatesOfEachOther(t *testing.T) {
+ d := t.TempDir()
+ a := put(t, d, "a.pdf", []byte("same content"), 0)
+ b := linked(t, d, "b.pdf", a)
+
+ x, errs := NewIndex([]scan.File{a, b}, nil)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ if orig, dup := lookup(t, x, a); dup {
+ t.Errorf("a.pdf reported as a duplicate of its own hardlink b.pdf (orig=%s)", orig)
+ }
+ if orig, dup := lookup(t, x, b); dup {
+ t.Errorf("b.pdf reported as a duplicate of its own hardlink a.pdf (orig=%s)", orig)
+ }
+}
+
+// TestHardlinksAreStillDuplicatesOfASeparateIdenticalFile is R10's other
+// direction, and the one a naive "same size+hash means never a duplicate"
+// fix would get wrong: a.pdf and b.pdf are hardlinks of one inode, but
+// c.pdf is a genuinely separate, byte-identical copy under an extra
+// (duplicate "DIR") directory, so spec §5.5 prefers it as the original.
+// Deleting a.pdf and b.pdf then leaves the content intact in c.pdf - they
+// really are duplicates, of c.pdf, and must still be reported as such.
+func TestHardlinksAreStillDuplicatesOfASeparateIdenticalFile(t *testing.T) {
+ scanned, filed := t.TempDir(), t.TempDir()
+ a := put(t, scanned, "a.pdf", []byte("same content"), 0)
+ b := linked(t, scanned, "b.pdf", a)
+ put(t, filed, "c.pdf", []byte("same content"), 0) // extra-dir copy: preferred as the original regardless of mtime
+
+ x, errs := NewIndex([]scan.File{a, b}, []string{filed})
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ cPath := filepath.Join(filed, "c.pdf")
+ if orig, dup := lookup(t, x, a); !dup || orig != cPath {
+ t.Errorf("a.pdf: dup=%v orig=%s, want a real duplicate of %s", dup, orig, cPath)
+ }
+ if orig, dup := lookup(t, x, b); !dup || orig != cPath {
+ t.Errorf("b.pdf: dup=%v orig=%s, want a real duplicate of %s", dup, orig, cPath)
+ }
+}
+
+// TestHardlinkUnderExtraDirWithNoOtherCopyIsNotADuplicate is R10 extended
+// to extra-directory candidates: a candidate is never a duplicate of a
+// candidate that is the same file, and extra-directory candidates are
+// candidates - the rule is not scanned-vs-scanned only. This is the shape
+// (duplicate "DIR") exists for: (duplicate "~/backup") means "the backup
+// already holds a copy, so the local name can go", but if ~/backup/a.pdf is
+// a hardlink of ~/dl/a.pdf, the backup holds no copy at all, just the same
+// file under a second name - judging the scanned file a duplicate would
+// delete the only copy while the user believes it is backed up.
+//
+// Lookup's os.SameFile check compares the elected original with the file
+// asked about whether the original was scanned or found under an extra
+// directory, so this case needs no special handling; the test pins it as
+// its own named case.
+func TestHardlinkUnderExtraDirWithNoOtherCopyIsNotADuplicate(t *testing.T) {
+ scanned, backup := t.TempDir(), t.TempDir()
+ a := put(t, scanned, "a.pdf", []byte("same content"), 0)
+ backupPath := filepath.Join(backup, "a.pdf")
+ if err := os.Link(a.Path, backupPath); err != nil {
+ t.Fatal(err)
+ }
+
+ x, errs := NewIndex([]scan.File{a}, []string{backup})
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ if orig, dup := lookup(t, x, a); dup {
+ t.Errorf("a.pdf reported as a duplicate of its own hardlink under the extra dir (orig=%s)", orig)
+ }
+}
+
+// TestThreeWayHardlinksAreNotDuplicatesOfEachOther is the direct
+// generalisation of TestHardlinksAreNotDuplicatesOfEachOther to N names for
+// one inode: three names, one inode, no other copy anywhere - none of them
+// is a duplicate of either of the others. No special-casing for N > 2: all
+// three elect the same original, and Lookup's SameFile check finds each
+// name to be that original's own file.
+func TestThreeWayHardlinksAreNotDuplicatesOfEachOther(t *testing.T) {
+ d := t.TempDir()
+ a := put(t, d, "a.pdf", []byte("same content"), 0)
+ b := linked(t, d, "b.pdf", a)
+ c := linked(t, d, "c.pdf", a)
+
+ x, errs := NewIndex([]scan.File{a, b, c}, nil)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ for _, f := range []scan.File{a, b, c} {
+ if orig, dup := lookup(t, x, f); dup {
+ t.Errorf("%s reported as a duplicate (orig=%s)", f.Name, orig)
+ }
+ }
+}
+
+// dupVerdicts looks up every file in fs and returns the paths reported as
+// not a duplicate, and the originals the duplicates were reported against.
+func dupVerdicts(t *testing.T, x *Index, fs ...scan.File) (kept []string, origs map[string]string) {
+ t.Helper()
+ origs = make(map[string]string)
+ for _, f := range fs {
+ orig, dup := lookup(t, x, f)
+ if dup {
+ origs[f.Path] = orig
+ } else {
+ kept = append(kept, f.Path)
+ }
+ }
+ return kept, origs
+}
+
+// TestExtraDirIsTheScannedRoot: (duplicate "DIR") where DIR is the scanned
+// root itself, so every scanned file is also indexed as an extra-directory
+// candidate under the same path. Two identical files are one duplicate and
+// one original, never two duplicates of each other: a (delete) rule must
+// leave one copy.
+func TestExtraDirIsTheScannedRoot(t *testing.T) {
+ d := t.TempDir()
+ a := put(t, d, "a.pdf", []byte("same content"), 0) // older: the original
+ b := put(t, d, "b.pdf", []byte("same content"), 5)
+
+ x, errs := NewIndex([]scan.File{a, b}, []string{d})
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ kept, origs := dupVerdicts(t, x, a, b)
+ if len(kept) != 1 || kept[0] != a.Path {
+ t.Errorf("kept = %v, want exactly %s (duplicates: %v)", kept, a.Path, origs)
+ }
+ if origs[b.Path] != a.Path {
+ t.Errorf("b.pdf: original = %q, want %s", origs[b.Path], a.Path)
+ }
+}
+
+// TestExtraDirInsideRecursiveRoot: DIR is a subdirectory of a recursively
+// scanned root, the "the archive already holds a copy, so the loose one can
+// go" shape. The copy under DIR is the original even though it is newer;
+// only the copy outside DIR is a duplicate.
+func TestExtraDirInsideRecursiveRoot(t *testing.T) {
+ d := t.TempDir()
+ archived := put(t, d, "Archive/x.pdf", []byte("same content"), 5)
+ loose := put(t, d, "x-copy.pdf", []byte("same content"), 0) // older, but not under DIR
+
+ x, errs := NewIndex([]scan.File{archived, loose}, []string{filepath.Join(d, "Archive")})
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ kept, origs := dupVerdicts(t, x, archived, loose)
+ if len(kept) != 1 || kept[0] != archived.Path {
+ t.Errorf("kept = %v, want exactly %s (duplicates: %v)", kept, archived.Path, origs)
+ }
+ if origs[loose.Path] != archived.Path {
+ t.Errorf("x-copy.pdf: original = %q, want %s", origs[loose.Path], archived.Path)
+ }
+}
+
+// TestExtraDirIsAnAncestorOfTheRoot: DIR contains the scanned root, as
+// (duplicate "~") would for a root under the home directory.
+func TestExtraDirIsAnAncestorOfTheRoot(t *testing.T) {
+ parent := t.TempDir()
+ root := filepath.Join(parent, "dl")
+ a := put(t, root, "a.pdf", []byte("same content"), 0) // older: the original
+ b := put(t, root, "b.pdf", []byte("same content"), 5)
+
+ x, errs := NewIndex([]scan.File{a, b}, []string{parent})
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ kept, origs := dupVerdicts(t, x, a, b)
+ if len(kept) != 1 || kept[0] != a.Path {
+ t.Errorf("kept = %v, want exactly %s (duplicates: %v)", kept, a.Path, origs)
+ }
+ if origs[b.Path] != a.Path {
+ t.Errorf("b.pdf: original = %q, want %s", origs[b.Path], a.Path)
+ }
+}
+
+// TestThreeCopiesWithOverlappingExtraDirKeepOne: two copies under DIR and a
+// third outside it, all scanned. Exactly one of the three is not a
+// duplicate, it lies under DIR, and every duplicate names that same file as
+// its original.
+func TestThreeCopiesWithOverlappingExtraDirKeepOne(t *testing.T) {
+ d := t.TempDir()
+ x1 := put(t, d, "Archive/x1.pdf", []byte("same content"), 3)
+ x2 := put(t, d, "Archive/x2.pdf", []byte("same content"), 4)
+ loose := put(t, d, "x-copy.pdf", []byte("same content"), 0)
+
+ x, errs := NewIndex([]scan.File{x1, x2, loose}, []string{filepath.Join(d, "Archive")})
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ kept, origs := dupVerdicts(t, x, x1, x2, loose)
+ if len(kept) != 1 || kept[0] != x1.Path {
+ t.Fatalf("kept = %v, want exactly %s (duplicates: %v)", kept, x1.Path, origs)
+ }
+ for p, orig := range origs {
+ if orig != x1.Path {
+ t.Errorf("%s: original = %q, want %s", p, orig, x1.Path)
+ }
+ }
+}
diff --git a/internal/engine/apply.go b/internal/engine/apply.go
index c74414d..878cb18 100644
--- a/internal/engine/apply.go
+++ b/internal/engine/apply.go
@@ -9,6 +9,7 @@ import (
"io"
"os"
"path/filepath"
+ "sort"
"strings"
"syscall"
"time"
@@ -660,6 +661,50 @@ func refuseIfSrcExists(us UndoStep, proj *undoProjection) string {
// declined - a single pass, not two, so the two kinds of file interleave in
// the log exactly as the run touched them, the same as Apply's own
// approved-and-declined chains do.
+//
+// Task 1 (plan 5): after every file's reversal has been attempted, a second,
+// run-wide pass retries the directory removals that were refused as
+// non-empty. planUndoFile puts the undo-mkdir step for a shared destination
+// on whichever file's chain first created it (spec §9: only the step that
+// actually created a directory logs a "mkdir" entry, so only that file's
+// reversal carries the matching undo-mkdir); when that file reverses first,
+// its siblings are usually still inside, the removal is correctly refused as
+// non-empty (spec §10), and - without this pass - nothing ever retries it,
+// leaving empty directories behind even though every file came back. This
+// mirrors planUndoFile's own undoProjection insight (see its comment) one
+// level up: a removal judged too early is judging the wrong world, whether
+// that "too early" is mid-file (what the projection fixes) or mid-run (what
+// this retry fixes).
+//
+// The retry is a run-level tidy-up, never a re-run of a step: it does not
+// touch what the first undo-mkdir attempt already logged (that entry, ok or
+// failed, stands exactly as it was written), and a directory the retry does
+// manage to remove gets an ADDITIONAL journal entry - never a rewrite - so
+// the log never disagrees with reality (my ruling on the point the brief
+// left open: spec §9 logs every step, and a directory removed while the log
+// still says its removal was refused would be a false record). Because
+// journal.ranAnyUndoStep already excludes "undo-mkdir" from what marks a run
+// "(undone)", this extra "ok" entry cannot change that marking either -
+// TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone pins
+// it rather than assuming it. A retried removal is likewise never folded
+// into ApplyResult: it is
+// collected from candidates whose first attempt already went through
+// tallyFile once (via isFileAffecting's exemption), and counting it again
+// here would double-count a directory that failed once and then quietly
+// tidied itself away.
+//
+// Candidates are collected only from directories this run's own reversal
+// created - by construction, since every candidate comes from an undo-mkdir
+// step, and an undo-mkdir step exists only for a directory the forward run's
+// Made recorded - never a directory the retry merely happens to find empty.
+// They are retried deepest path first (retryDirRemovals), so a nested
+// directory - e.g. Work/Sub under Work - is removed before its
+// now-possibly-empty parent, the same outermost-created/innermost-removed
+// discipline logStep and undoFile already keep within one file's own chain,
+// applied here across files. A directory still non-empty at retry time
+// genuinely holds something else (or the retry runs before every sibling
+// happens to have reversed, on a later undo of a different run) and simply
+// stays, with its original refusal the only record of it.
func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer, run string) (*ApplyResult, error) {
result := &ApplyResult{}
@@ -695,6 +740,7 @@ func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer,
return result, fmt.Errorf("engine: apply undo: %w", err)
}
+ var retries []dirRetry
for _, f := range actionable {
if err := ctx.Err(); err != nil {
return result, err
@@ -714,6 +760,15 @@ func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer,
}
result.Files = append(result.Files, fr)
tallyFile(result, fr.Steps, func(i int) bool { return isFileAffecting(f.Steps[i].Action) })
+ for i, us := range f.Steps {
+ if us.Action == "undo-mkdir" && fr.Steps[i].Status == "failed" {
+ retries = append(retries, dirRetry{dir: us.Src, dirName: f.Dir, file: f.File, step: i + 1})
+ }
+ }
+ }
+
+ if err := e.retryDirRemovals(j, run, retries); err != nil {
+ return result, fmt.Errorf("engine: apply undo: %w", err)
}
if err := j.Append(journal.Entry{Time: e.Now(), Run: run, Action: "run-end", Status: "ok"}); err != nil {
@@ -722,6 +777,69 @@ func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer,
return result, nil
}
+// dirRetry names one directory whose undo-mkdir was refused (as non-empty)
+// during ApplyUndo's main pass, kept for the run-wide retry once every
+// file's reversal has been attempted. file and dirName are the file and
+// config directory name that owned the original undo-mkdir step, carried
+// forward so retryDirRemovals's journal entry - if the retry succeeds -
+// names the same file and directory the original refusal did, not an
+// arbitrary one; step is that same step's 1-based index, so the two entries
+// (the original "failed" and, if the retry succeeds, this "ok") read
+// together under the same File/Step in the log.
+type dirRetry struct {
+ dir string
+ dirName string
+ file string
+ step int
+}
+
+// retryDirRemovals is ApplyUndo's run-wide second pass (Task 1, plan 5): once
+// every file's reversal has run, some directories an undo-mkdir step could
+// not remove earlier may now be empty, because a sibling file that shared
+// the directory has since reversed too. candidates is sorted deepest path
+// first (by descending path-segment count) so a nested directory is removed
+// before its parent, exactly the order a real cleanup needs; a directory
+// still non-empty at its turn genuinely holds something else and is left
+// exactly as its first attempt recorded it - no second entry, no error.
+//
+// This never rewrites or removes the original undo-mkdir entry (ok or
+// failed, whichever the first attempt logged): a directory the retry does
+// manage to remove gets one ADDITIONAL entry instead (my ruling on the point
+// the brief left open - see ApplyUndo's comment), so the log always agrees
+// with what is actually on disk. The new entry's own Action is still
+// "undo-mkdir", so journal.ranAnyUndoStep - which excludes that action on
+// principle, not by accident (see its own comment) - continues to treat this
+// exactly like any other undo-mkdir for the purpose of marking a run
+// "(undone)": tidying up an empty directory, on the first attempt or the
+// retry, is still not a restoration.
+func (e *Engine) retryDirRemovals(j *journal.Writer, run string, candidates []dirRetry) error {
+ sort.SliceStable(candidates, func(i, j int) bool {
+ return pathDepth(candidates[i].dir) > pathDepth(candidates[j].dir)
+ })
+ for _, c := range candidates {
+ if err := os.Remove(c.dir); err != nil {
+ // Still not empty (or gone, or otherwise unremovable): the
+ // original refusal already recorded this, and it stands.
+ continue
+ }
+ if err := j.Append(journal.Entry{
+ Time: e.Now(), Run: run, Dir: c.dirName, File: c.file, Step: c.step,
+ Action: "undo-mkdir", Status: "ok", Src: c.dir,
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// pathDepth counts path's separators after cleaning it, so retryDirRemovals
+// can sort deepest first: a nested directory (more separators) is always
+// removed before the parent it sits under, whatever the two paths' common
+// root.
+func pathDepth(path string) int {
+ return strings.Count(filepath.Clean(path), string(filepath.Separator))
+}
+
// declineUndoFile logs f's reversal as declined without carrying out any of
// it - spec §9's "declined files are logged even though nothing happens to
// them", extended to undo (fix round 2026-09-12, item 2 of Task 8's review):
diff --git a/internal/engine/apply_test.go b/internal/engine/apply_test.go
index 42724a0..a54bffc 100644
--- a/internal/engine/apply_test.go
+++ b/internal/engine/apply_test.go
@@ -1180,3 +1180,246 @@ func TestTallyFileCountsMixedOutcomesOnceEach(t *testing.T) {
t.Errorf("result = %+v, want one of each", result)
}
}
+
+// --- Plan 5, Task 1 ---
+
+// sharedDestUndoFixture builds a downloads directory with three pdf files
+// (a.pdf, b.pdf, c.pdf) and a single rule moving all of them into dest,
+// applies the move, and returns the sandbox home, the loaded engine, the
+// journal's path and the forward run's ID.
+//
+// All three files landing on one destination that this one run creates is
+// the shape that exercises the run-wide directory retry (Task 1, plan 5):
+// whichever file's chain first creates dest carries its undo-mkdir step(s),
+// and that file's own reversal typically runs while its siblings still
+// occupy dest - refusing the removal correctly, at first. dest may name a
+// nested path ("Work/Sub"): apply.mkdirAllTracked then records every
+// directory the move had to create, outermost first, and every one of them
+// still lands on that same first file's chain.
+//
+// Extracted per fix round 1 (Important 2): TestApplyUndoRemovesSharedDirectoryAfterEveryFileReverses
+// and TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone
+// used to duplicate this setup verbatim; TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst
+// needed the identical shape with only dest varying, which is what named the
+// parameter rather than hard-coding "Filed" here.
+func sharedDestUndoFixture(t *testing.T, dest string) (h string, e *Engine, logPath string, run string) {
+ t.Helper()
+ h = sandbox(t)
+ dl := filepath.Join(h, "dl")
+ if err := os.MkdirAll(dl, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // Three files that all move into ONE created destination. The bug this
+ // task fixes is that only the file whose chain first creates it ever
+ // carries the undo-mkdir step, and that step is attempted while its
+ // siblings are still inside.
+ for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} {
+ if err := os.WriteFile(filepath.Join(dl, n), []byte(n), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ }
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(min-age 0s)
+(rule "pdfs" (when (type pdf)) (move "` + dest + `"))
+`})
+ loaded, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ e = loaded
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ approved := map[string]bool{}
+ for _, c := range dp.Chains {
+ approved[c.File.Rel] = true
+ }
+ logPath = filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ run = journal.NewRunID(time.Now())
+ if _, err := e.Apply(context.Background(), dp, approved, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+ return h, e, logPath, run
+}
+
+func TestApplyUndoRemovesSharedDirectoryAfterEveryFileReverses(t *testing.T) {
+ h, e, logPath, run := sharedDestUndoFixture(t, "Filed")
+ dl := filepath.Join(h, "dl")
+ filed := filepath.Join(dl, "Filed")
+ if _, err := os.Stat(filed); err != nil {
+ t.Fatalf("apply did not create the directory: %v", err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2.Close()
+ if res.Failed != 0 {
+ t.Fatalf("undo reported %d failures: %+v", res.Failed, res)
+ }
+ if _, err := os.Stat(filed); !os.IsNotExist(err) {
+ t.Errorf("undo left the created directory behind: %v", err)
+ }
+ for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} {
+ if _, err := os.Stat(filepath.Join(dl, n)); err != nil {
+ t.Errorf("%s did not come back: %v", n, err)
+ }
+ }
+}
+
+// TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst pins fix round 1's
+// Important 1: retryDirRemovals must retry deepest path first. All three
+// files move into Work/Sub, so Apply's single mkdirAllTracked call creates
+// both Work and Work/Sub on the FIRST file's own chain (outermost first),
+// which means that one file's reversal carries two undo-mkdir steps, one for
+// each directory - and both are refused on that file's own turn, since the
+// other two files still sit in Work/Sub at that point.
+//
+// Retried deepest first, Work/Sub empties out and is removed, and Work -
+// now itself empty - is removed right after. Retried shallowest first
+// instead, Work is tried while Work/Sub (now empty, but not yet removed)
+// still sits inside it, so Work is refused as non-empty and never retried
+// again in this run; Work/Sub is then removed, leaving the outer Work
+// directory behind. So end state alone - no directory left over - already
+// distinguishes correct (deepest-first) ordering from inverted or dropped
+// ordering; unlike the flat-destination tests above, where only one
+// directory ever entered `retries`, this is the case built to tell the two
+// apart.
+func TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst(t *testing.T) {
+ h, e, logPath, run := sharedDestUndoFixture(t, "Work/Sub")
+ dl := filepath.Join(h, "dl")
+ work := filepath.Join(dl, "Work")
+ sub := filepath.Join(work, "Sub")
+ if _, err := os.Stat(sub); err != nil {
+ t.Fatalf("apply did not create the nested directory: %v", err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2.Close()
+ if res.Failed != 0 {
+ t.Fatalf("undo reported %d failures: %+v", res.Failed, res)
+ }
+ if _, err := os.Stat(sub); !os.IsNotExist(err) {
+ t.Errorf("undo left the nested directory behind: %v", err)
+ }
+ if _, err := os.Stat(work); !os.IsNotExist(err) {
+ t.Errorf("undo left the outer directory behind - retryDirRemovals is not retrying deepest path first: %v", err)
+ }
+ for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} {
+ if _, err := os.Stat(filepath.Join(dl, n)); err != nil {
+ t.Errorf("%s did not come back: %v", n, err)
+ }
+ }
+}
+
+// TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone pins
+// the journal half of the ruling on the point Task 1's brief left open: when
+// retryDirRemovals succeeds in removing a directory, it appends an
+// ADDITIONAL journal entry for it - the original "failed" undo-mkdir entry,
+// recorded on whichever file's chain first created the directory, is never
+// rewritten or removed - and, because that entry's Action is still
+// "undo-mkdir" like the first, journal.ranAnyUndoStep continues to exclude
+// it from what marks a run "(undone)" (the brief's constraint: it "cannot
+// change whether a run shows as (undone); confirm that rather than assume
+// it").
+func TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone(t *testing.T) {
+ _, e, logPath, run := sharedDestUndoFixture(t, "Filed")
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ undoRun := journal.NewRunID(time.Now())
+ res, err := e.ApplyUndo(context.Background(), up, j2, undoRun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2.Close()
+ if res.Failed != 0 {
+ t.Fatalf("undo reported %d failures: %+v", res.Failed, res)
+ }
+
+ entries, err := journal.Entries(logPath, undoRun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var failed, ok *journal.Entry
+ var mkdirCount int
+ for i := range entries {
+ en := &entries[i]
+ if en.Action != "undo-mkdir" {
+ continue
+ }
+ mkdirCount++
+ switch en.Status {
+ case "failed":
+ failed = en
+ case "ok":
+ ok = en
+ }
+ }
+ if mkdirCount != 2 {
+ t.Fatalf("undo-mkdir entries = %d, want exactly 2 (the original refusal plus the retry's addition): %+v", mkdirCount, entries)
+ }
+ if failed == nil {
+ t.Fatal("the original refused undo-mkdir entry is missing - it must never be rewritten or removed")
+ }
+ if ok == nil {
+ t.Fatal("no successful undo-mkdir entry was appended for the retry")
+ }
+ if failed.Src != ok.Src {
+ t.Errorf("failed.Src = %q, ok.Src = %q; want the same directory", failed.Src, ok.Src)
+ }
+ if failed.File != ok.File {
+ t.Errorf("failed.File = %q, ok.File = %q; want the retry entry to carry the file that owned the original undo-mkdir", failed.File, ok.File)
+ }
+ if failed.Dir != ok.Dir {
+ t.Errorf("failed.Dir = %q, ok.Dir = %q; want the same directory name", failed.Dir, ok.Dir)
+ }
+
+ runs, err := e.Runs(0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ byID := map[string]bool{}
+ for _, r := range runs {
+ byID[r.ID] = r.Undone
+ }
+ if !byID[run] {
+ t.Errorf("original run %q not marked Undone, though every file came back", run)
+ }
+ if byID[undoRun] {
+ t.Errorf("the undo run %q itself must never read as Undone", undoRun)
+ }
+}
diff --git a/internal/engine/plan_bench_test.go b/internal/engine/plan_bench_test.go
new file mode 100644
index 0000000..a3d08bb
--- /dev/null
+++ b/internal/engine/plan_bench_test.go
@@ -0,0 +1,108 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "krino/internal/plan"
+)
+
+// BenchmarkPlan measures krino's own cost of planning: walking a tree,
+// matching rules and building action chains (Engine.Plan, which wraps
+// Engine.Match and plan.Build). It does NOT measure krino's real-world
+// throughput - spec §13 is explicit that there is no performance target
+// for 0.0.1, because a full run's wall time is dominated by the external
+// extractors (pdftotext and friends), not by krino itself. This benchmark
+// therefore uses a config with no (content ...) test, so no extractor
+// ever runs and the result is identical on any machine, with or without
+// poppler installed.
+//
+// The tree is built once in b.TempDir(), before the timer starts; each
+// iteration re-plans the same on-disk tree with a fresh plan.Claims, so
+// iterations are independent and repeatable.
+func BenchmarkPlan(b *testing.B) {
+ root := b.TempDir()
+ scanRoot := filepath.Join(root, "Filed")
+ buildBenchTree(b, scanRoot)
+
+ mainFile := filepath.Join(root, "krino.conf")
+ if err := os.WriteFile(mainFile, []byte(`(include "dl")`), 0o644); err != nil {
+ b.Fatal(err)
+ }
+ dirsDir := filepath.Join(root, "dirs")
+ if err := os.MkdirAll(dirsDir, 0o755); err != nil {
+ b.Fatal(err)
+ }
+ // Type-only rules (no content test), one per group present in the
+ // generated tree, mirroring examples/by-type.conf; "dat" files match
+ // none of them and take the unmatched path through Match.
+ dirConf := fmt.Sprintf(`
+(path %q)
+(recursive yes)
+(min-age 0s)
+(rule "images" (when (type image)) (move "Sorted/Images") (stop))
+(rule "documents" (when (type document)) (move "Sorted/Documents") (stop))
+(rule "spreadsheets" (when (type spreadsheet)) (move "Sorted/Spreadsheets") (stop))
+(rule "archives" (when (type archive)) (move "Sorted/Archives") (stop))
+(rule "media" (when (or (type audio) (type video))) (move "Sorted/Media") (stop))
+`, scanRoot)
+ if err := os.WriteFile(filepath.Join(dirsDir, "dl.conf"), []byte(dirConf), 0o644); err != nil {
+ b.Fatal(err)
+ }
+
+ e, errs := Load(mainFile, "dl")
+ if len(errs) > 0 {
+ b.Fatalf("config errors: %v", errs)
+ }
+
+ ctx := context.Background()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if _, err := e.Plan(ctx, e.Dirs[0], plan.NewClaims()); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// benchTreeDirs * benchFilesPerDir files are generated, spread over
+// nested directories so the walk itself is exercised, not just a single
+// flat directory read.
+const (
+ benchTreeDirs = 12
+ benchFilesPerDir = 150
+)
+
+// buildBenchTree creates a synthetic tree under root for BenchmarkPlan:
+// nested "Sub" directories holding files that cycle through extensions
+// spanning several of Appendix A's type groups, plus one extension
+// ("dat") that matches no rule. Names are neutral (Sub, a<N>.<ext>) -
+// never anything from a real folder, per the leak-check patterns.
+func buildBenchTree(b *testing.B, root string) {
+ b.Helper()
+ exts := []string{"pdf", "jpg", "xlsx", "zip", "mp3", "dat"}
+ old := time.Now().Add(-time.Hour)
+ n := 0
+ for d := 0; d < benchTreeDirs; d++ {
+ dir := filepath.Join(root, fmt.Sprintf("Sub%d", d), "Nested")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ b.Fatal(err)
+ }
+ for f := 0; f < benchFilesPerDir; f++ {
+ ext := exts[n%len(exts)]
+ p := filepath.Join(dir, fmt.Sprintf("a%04d.%s", n, ext))
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ b.Fatal(err)
+ }
+ if err := os.Chtimes(p, old, old); err != nil {
+ b.Fatal(err)
+ }
+ n++
+ }
+ }
+}
diff --git a/internal/journal/read_test.go b/internal/journal/read_test.go
index 3ffa14d..2f0f40f 100644
--- a/internal/journal/read_test.go
+++ b/internal/journal/read_test.go
@@ -196,6 +196,220 @@ func TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir(t *testing.T) {
}
}
+// TestEntriesCrashedRunReturnsNilError is item 1: a run-start present,
+// run-end absent, and otherwise clean is exactly the crashed-run shape
+// Entries' own doc comment says it must accept - "to end of file when there
+// is no run-end (a crashed run, which is precisely when corruption is
+// likely)". Pinning it as its own test, rather than leaving it implicit in
+// tests about something else, is the point of the item.
+func TestEntriesCrashedRunReturnsNilError(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ // No run-end: the process crashed right here.
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err != nil {
+ t.Fatalf("a crashed but otherwise clean run returned an error: %v", err)
+ }
+ if len(got) != 2 || got[0].Action != "run-start" || got[1].Action != "move" {
+ t.Errorf("entries = %+v, want [run-start, move]", got)
+ }
+}
+
+// TestEntriesIntactRunReturnsNilError is item 2: a complete, clean run -
+// run-start, a step, run-end, nothing corrupt - must read back with a nil
+// error. Every other test in this file needs this to be true along the way,
+// but none of them state it as their own point; this one does.
+func TestEntriesIntactRunReturnsNilError(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err != nil {
+ t.Fatalf("a fully intact run returned an error: %v", err)
+ }
+ if len(got) != 3 || got[0].Action != "run-start" || got[1].Action != "move" || got[2].Action != "run-end" {
+ t.Errorf("entries = %+v, want [run-start, move, run-end]", got)
+ }
+}
+
+// TestEntriesBothFailureModesReportsBadLineFirst is item 3: a run that both
+// has an unparsable line inside its window AND lacks a readable run-start
+// must surface as the unparsable-line error, not the missing-run-start one -
+// Entries checks badLine before sawRunStart. The run-start line here is
+// destroyed unattributably (as in TestEntriesFailsClosedOnMissingRunStart),
+// and a second, still-attributable line is separately corrupted so badLine
+// is set via the runFieldOf fallback rather than the window check.
+func TestEntriesBothFailureModesReportsBadLineFirst(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("fixture has %d lines, want 3", len(lines))
+ }
+ // Line 1 (run-start): destroyed unattributably - no tabs at all, so
+ // runFieldOf cannot even recover its Run column.
+ lines[0] = "totally-mangled-no-tabs-here"
+ // Line 2 (move): corrupt its Step column only - it keeps its tabs and
+ // its Run column ("A") stays readable via runFieldOf's fallback.
+ fields := strings.Split(lines[1], "\t")
+ fields[4] = "not-a-number"
+ lines[1] = strings.Join(fields, "\t")
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err == nil {
+ t.Fatal("Entries returned no error with both a bad line and a missing run-start present")
+ }
+ if !strings.Contains(err.Error(), "unparsable line 2") {
+ t.Errorf("error = %q, want it to report the unparsable line (line 2), not the missing run-start", err)
+ }
+ if len(got) != 1 || got[0].Action != "run-end" {
+ t.Errorf("entries = %+v, want just the surviving run-end", got)
+ }
+}
+
+// TestEntriesAdjacentRunStartsOneCorrupted is item 4. Ruling R2: this pins
+// what journal.Entries does TODAY for two runs whose run-start lines are
+// adjacent, one of them corrupted - it does not assert an invented "correct"
+// result, and internal/journal is not touched by this task. The log here is
+// exactly:
+//
+// 1 run-start A (good)
+// 2 run-start B (corrupted: no tabs, unattributable)
+// 3 move A (good)
+// 4 run-end A (good)
+// 5 move B (good)
+// 6 run-end B (good)
+//
+// Observed behaviour, traced by hand against Entries and confirmed by this
+// test: Entries(path, "A") fails closed with the unparsable-line error,
+// because line 2 falls inside A's own window (opened by line 1, not yet
+// closed by a run-end) even though the corrupted line was actually B's
+// run-start, not A's - this is exactly the "residual risk... a false
+// refusal, not a false success" the function's own doc comment already
+// names. Entries(path, "B"), in contrast, never sees line 2 as inside its
+// window (B's window has not opened - its own run-start is the corrupted
+// line), so it reaches the end of the file with no badLine, and instead
+// fails on B's missing run-start.
+//
+// Concern (not fixed here, per R2 - flagged for judgement, not code
+// change): the SAME corrupted line produces two different error shapes
+// depending only on which run asks, which is a surprising inconsistency in
+// the message a caller sees, even though both directions correctly fail
+// closed.
+func TestEntriesAdjacentRunStartsOneCorrupted(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "B", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "B", Dir: "dl", File: "y.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/y.pdf", Dst: "/b/y.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "B", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+ if len(lines) != 6 {
+ t.Fatalf("fixture has %d lines, want 6", len(lines))
+ }
+ // Line 2, B's run-start, adjacent to A's on line 1: destroyed
+ // unattributably.
+ lines[1] = "totally-mangled-no-tabs-here"
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ gotA, errA := Entries(path, "A")
+ if errA == nil {
+ t.Fatal("Entries(A) returned no error; pinned behaviour expects one (the corrupted adjacent line falls inside A's window)")
+ }
+ if !strings.Contains(errA.Error(), "unparsable line 2") {
+ t.Errorf("Entries(A) error = %q, want it to name the unparsable line 2", errA)
+ }
+ if len(gotA) != 3 || gotA[0].Action != "run-start" || gotA[1].Action != "move" || gotA[2].Action != "run-end" {
+ t.Errorf("Entries(A) = %+v, want A's own [run-start, move, run-end]", gotA)
+ }
+
+ gotB, errB := Entries(path, "B")
+ if errB == nil {
+ t.Fatal("Entries(B) returned no error; pinned behaviour expects one (B's own run-start is the corrupted line)")
+ }
+ if !strings.Contains(errB.Error(), "no readable run-start") {
+ t.Errorf("Entries(B) error = %q, want it to name the missing run-start", errB)
+ }
+ if len(gotB) != 2 || gotB[0].Action != "move" || gotB[1].Action != "run-end" {
+ t.Errorf("Entries(B) = %+v, want B's surviving [move, run-end]", gotB)
+ }
+}
+
// TestEntriesReportsAMangledLine: a corrupt line that is not the log's
// final line must not be silently dropped by Entries the way Runs drops it
// - PlanUndo needs to know a step went missing so it can refuse the whole
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 96bb728..c9f1241 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -36,9 +36,9 @@ func Colour(w io.Writer) bool {
return !noColour
}
-// Height is the terminal's row count, 0 when it is not a terminal or the
+// height is the terminal's row count, 0 when it is not a terminal or the
// size cannot be read.
-func Height(w io.Writer) int {
+func height(w io.Writer) int {
f, ok := w.(*os.File)
if !ok || !isTerminal(int(f.Fd())) {
return 0
@@ -73,7 +73,7 @@ func Page(w io.Writer, text string) error {
// the terminal the pager would inherit, not the writer text is otherwise
// sent to.
func fitsWithoutPaging(text string) bool {
- h := Height(os.Stdout)
+ h := height(os.Stdout)
if h <= 0 {
return true
}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 6918859..a3640ac 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -37,8 +37,8 @@ func TestColourNeedsTerminalAndNoNOCOLOR(t *testing.T) {
func TestHeight(t *testing.T) {
var buf bytes.Buffer
- if h := Height(&buf); h != 0 {
- t.Errorf("Height on a non-terminal writer = %d, want 0", h)
+ if h := height(&buf); h != 0 {
+ t.Errorf("height on a non-terminal writer = %d, want 0", h)
}
oldT, oldS := isTerminal, termSize
@@ -46,8 +46,8 @@ func TestHeight(t *testing.T) {
isTerminal = func(fd int) bool { return true }
termSize = func(fd int) (int, int, error) { return 80, 24, nil }
- if h := Height(os.Stdout); h != 24 {
- t.Errorf("Height = %d, want 24", h)
+ if h := height(os.Stdout); h != 24 {
+ t.Errorf("height = %d, want 24", h)
}
}
diff --git a/man/krino.1 b/man/krino.1
new file mode 100644
index 0000000..627a2ee
--- /dev/null
+++ b/man/krino.1
@@ -0,0 +1,348 @@
+.\" SPDX-License-Identifier: GPL-3.0-or-later
+.Dd September 13, 2026
+.Dt KRINO 1
+.Os
+.Sh NAME
+.Nm krino
+.Nd sort files in a directory by rules
+.Sh SYNOPSIS
+.Nm
+.Op Fl y | Fl n
+.Op Fl v
+.Op Fl -json
+.Op Fl c Ar file
+.Op Ar name ...
+.Pp
+.Nm
+.Cm init
+.Pp
+.Nm
+.Cm new
+.Ar name path
+.Pp
+.Nm
+.Cm check
+.Op Ar name ...
+.Pp
+.Nm
+.Cm explain
+.Ar file
+.Pp
+.Nm
+.Cm log
+.Op Fl n Ar count
+.Pp
+.Nm
+.Cm undo
+.Op Ar run
+.Sh DESCRIPTION
+.Nm
+sorts the files in one or more configured directories according to rules
+kept in
+.Xr krino.conf 5 .
+For each directory in turn it scans the files, evaluates every rule against
+every file, and builds a plan: the chain of actions
+.Pq Ic copy , Ic move , Ic rename , Ic delete
+that the file's matching rules add up to.
+.Pp
+Every rule that matches a file adds its actions to that file's chain, in
+the order the rules are written;
+.Sy (stop)
+on a matching rule ends the chain for that file, so a later rule is never
+even evaluated against it.
+A rule with no actions besides
+.Sy (stop)
+is an exclusion.
+.Pp
+Every test sees the file as it was when the directory was scanned: name,
+path, size, modification time and content.
+Actions already added to a file's chain by an earlier rule never change
+what a later rule's tests see, so the whole plan for a directory can be
+computed, shown, and approved before anything is touched.
+.Pp
+With no
+.Fl y
+or
+.Fl n ,
+.Nm
+prints the plan and asks how to proceed
+.Pq Sx REVIEW .
+.Fl y
+applies the plan without asking;
+.Fl n
+prints it and changes nothing.
+If standard input is not a terminal and neither is given,
+.Nm
+refuses rather than guess.
+.Sh OPTIONS
+.Bl -tag -width Ds
+.It Fl y
+Apply the plan without asking.
+Cannot be combined with
+.Fl n .
+.It Fl n
+Dry run: print the plan and exit without changing anything.
+.It Fl v
+Also list files that were skipped as unmatched, ignored or busy, and show
+the full reason a test matched or not.
+.It Fl -json
+With
+.Fl n ,
+print the plan as JSON instead of the table.
+Refused unless
+.Fl n
+is also given.
+The document's own
+.Ic note
+field says its shape is unstable before krino 1.0; a script that reads it
+should expect it to change before then.
+.It Fl c Ar file
+Use
+.Ar file
+in place of
+.Pa $XDG_CONFIG_HOME/krino/krino.conf .
+.It Fl h , Fl -help
+Print usage and exit.
+.It Fl -version
+Print
+.Dq krino Ar version
+and exit.
+.El
+.Sh SUBCOMMANDS
+.Bl -tag -width Ds
+.It Ic init
+Create the config directory with a commented
+.Pa krino.conf
+and
+.Pa template.conf .
+Refuses if
+.Pa krino.conf
+already exists, and leaves an existing
+.Pa template.conf
+alone.
+.It Ic new Ar name path
+Copy
+.Pa template.conf
+to
+.Pa dirs/ Ns Ar name Ns Pa .conf ,
+fill in
+.Ar path ,
+and append
+.Ar name
+to
+.Ic include
+in
+.Pa krino.conf ,
+keeping its comments.
+.It Ic check Op Ar name ...
+Validate the configuration, list each included directory's rules, and list
+which content-extraction tools
+.Pq Xr krino.conf 5 , Sx CONTENT EXTRACTION
+are available.
+With no
+.Ar name ,
+checks every included directory.
+.It Ic explain Ar file
+Evaluate every rule of
+.Ar file Ns 's
+directory against it and show each test's result, so a rule that should
+match but does not
+.Pq or the reverse
+can be diagnosed test by test.
+See
+.Sx KNOWN LIMITATIONS .
+.It Ic log Op Fl n Ar count
+List the most recent runs, newest first
+.Pq Ar count No defaults to 10 :
+the run id, its start time, the directories it touched, and what it did.
+A run that a later
+.Ic undo
+has reversed at least one file of is marked
+.Pq undone .
+.It Ic undo Op Ar run
+Reverse
+.Ar run .
+With no
+.Ar run ,
+reverse the most recent run; if that run is itself an undo, it is refused.
+An older run is undone by naming it.
+See
+.Sx UNDO .
+.El
+.Sh REVIEW
+With neither
+.Fl y
+nor
+.Fl n ,
+after showing one directory's plan
+.Nm
+asks:
+.Bd -literal -offset indent
+[a] apply all [c] choose per file [s] skip this directory [q] quit
+.Ed
+.Pp
+.Ic a
+applies every step of every chain shown.
+.Ic s
+applies nothing in this directory and moves on to the next one.
+.Ic q
+stops
+.Nm
+entirely; directories already applied earlier in this run stay applied and
+can be reversed with
+.Ic undo .
+.Pp
+.Ic c
+asks about each file in turn:
+.Bd -literal -offset indent
+ [y] yes [n] no [a] yes to this and all remaining [d] done, 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 d
+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
+already marked
+.Ic yes ,
+and folds into the top-level
+.Ic q
+above.
+.Sh UNDO
+.Ic krino undo
+reverses a run's steps, last step first within each file, after building
+and showing a plan of its own
+.Pq shown and approved the same way as a sort plan; Fl y No and Fl n No apply .
+One undo plan covers every directory the run touched, so its menu has no
+per-directory skip;
+.Ic s
+and
+.Ic q
+both apply nothing:
+.Bd -literal -offset indent
+[a] apply all [c] choose per file [s] skip [q] quit
+.Ed
+.Pp
+An undo run cannot itself be undone: naming it to
+.Ic undo
+is refused.
+.Pp
+A step's reversal is refused when the world has moved on since the step ran
+.Pq its target is gone or has changed, or the original path is occupied again ;
+a refused file has nothing of its chain reversed, so no file is left half
+undone.
+An exception is a created directory found non-empty at its own turn: that
+alone does not refuse the rest of the file's reversal, since it means
+another file still lives there, not that something unexpected changed
+underfoot.
+Every created directory still empty once every file in the run has had its
+turn is then removed, deepest path first, so an undo does not leave the
+empty directories it made behind.
+.Pp
+A permanent delete,
+.Sy (delete permanent) ,
+is never undoable; it is shown in the plan with that reason and nothing is
+attempted for it.
+.Pp
+.Ic krino log
+marks a run
+.Pq undone
+once its undo run has reversed at least one file's chain \(em not that
+every file in it was restored: a run left partly reversed, because some
+files were declined during the undo's own review, is still shown as
+.Pq undone
+in full.
+.Sh ENVIRONMENT
+.Bl -tag -width Ds
+.It Ev XDG_CONFIG_HOME
+Base of the configuration directory
+.Pq Pa $XDG_CONFIG_HOME/krino ;
+default
+.Pa ~/.config .
+.It Ev XDG_STATE_HOME
+Base of the log and the per-directory lock files
+.Pq Pa $XDG_STATE_HOME/krino ;
+default
+.Pa ~/.local/state .
+.It Ev XDG_DATA_HOME
+Base of the trash a
+.Sy (delete)
+step uses
+.Pq Pa $XDG_DATA_HOME/Trash ;
+default
+.Pa ~/.local/share .
+.It Ev PAGER
+Used to show a plan taller than the terminal; default
+.Dq less -FRX .
+Never used for
+.Fl -json
+output.
+.It Ev NO_COLOR
+When set, disables the ANSI colour
+.Nm
+otherwise uses on a terminal for a permanent delete and a refused undo
+step.
+.El
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa $XDG_CONFIG_HOME/krino/krino.conf
+The main configuration file: which directories run, defaults, the log
+path.
+.It Pa $XDG_CONFIG_HOME/krino/template.conf
+Copied by
+.Ic krino new
+for each new directory.
+.It Pa $XDG_CONFIG_HOME/krino/dirs/ Ns Ar name Ns Pa .conf
+One file per configured directory.
+.It Pa $XDG_STATE_HOME/krino/krino.log
+The append-only log every run writes to.
+.It Pa $XDG_STATE_HOME/krino/ Ns Ar name Ns Pa .lock
+Held for the duration of a run against directory
+.Ar name ,
+so a second
+.Nm
+against the same directory waits, or fails immediately with
+.Fl y .
+.It Pa $XDG_DATA_HOME/Trash
+The freedesktop.org trash a
+.Sy (delete)
+step moves files into.
+.El
+.Sh EXIT STATUS
+.Bl -tag -width Ds
+.It 0
+Success, including a run that found nothing to do, or one whose plan was
+entirely declined at review.
+.It 1
+A step failed, a directory's lock was already held by another
+.Nm ,
+or a configured directory could not be read.
+.It 2
+A usage mistake or a configuration error.
+Flags must come before any directory name on the command line: a flag
+found after a directory name is a usage error rather than a guess.
+.It 130
+Interrupted
+.Pq Ic Ctrl-C
+or terminated; the current step, if any, is finished and logged first.
+.El
+.Sh KNOWN LIMITATIONS
+.Ic krino explain
+evaluates every test of every rule's condition against the file, without
+the cost-ordered short-circuiting a real run uses within an
+.Sy and/or .
+It can therefore run a content-extraction tool, and report a
+.Dq content unreadable
+warning, for a test that a real run would never reach because an earlier,
+cheaper test in the same condition already decided the rule did not match.
+.Ic explain
+is a full trace of every test, not a cost-faithful preview of what
+.Fl n
+would actually spend time on.
+.Sh SEE ALSO
+.Xr krino.conf 5
+.Pp
+.Pa docs/sexp-primer.md
+in the source repository
+.Pq installed at Pa $PREFIX/share/doc/krino/sexp-primer.md
+is a short tutorial on the configuration syntax.
diff --git a/man/krino.conf.5 b/man/krino.conf.5
new file mode 100644
index 0000000..fbd1c06
--- /dev/null
+++ b/man/krino.conf.5
@@ -0,0 +1,696 @@
+.\" SPDX-License-Identifier: GPL-3.0-or-later
+.Dd September 13, 2026
+.Dt KRINO.CONF 5
+.Os
+.Sh NAME
+.Nm krino.conf
+.Nd krino's configuration language and files
+.Sh DESCRIPTION
+.Xr krino 1 Ns 's
+configuration is written in s-expressions: each form is a list inside
+parentheses, holding an operation optionally followed by its arguments.
+A short tutorial is
+.Pa docs/sexp-primer.md
+in the source repository, installed at
+.Pa $PREFIX/share/doc/krino/sexp-primer.md .
+This page is the reference for every form.
+.Pp
+.Bl -bullet -compact
+.It
+.Sy List :
+.Ql \&(
+items separated by whitespace
+.Ql \&) .
+.It
+.Sy String :
+.Ql \&"...\&" .
+A backslash escapes only
+.Ql \&"
+and
+.Ql \e ;
+any other backslash is kept literally, so
+.Ql \&"\ebacme\eb\&"
+is the regex
+.Ql \ebacme\eb .
+Strings may span lines.
+.It
+.Sy Symbol :
+any other run of characters except whitespace,
+.Ql \&( ,
+.Ql \&) ,
+.Ql \&" ,
+.Ql \&; .
+.It
+.Sy Comment :
+.Ql \&;
+to end of line.
+.El
+.Pp
+Encoding is UTF-8 only.
+There is no other syntax: no quote characters, no dotted pairs, no block
+comments.
+Paths, keywords, regexes and rule names must be strings; settings values,
+type names, operators, sizes and durations are symbols.
+.Pp
+Every list and atom's byte offset, line and column is recorded, so
+.Ic krino new
+can splice a name into
+.Ic include
+without disturbing anything else in the file, and a syntax error is
+reported as
+.Ar file : Ns Ar line : Ns Ar col .
+.Sh FILES
+.Ss krino.conf
+The main file, read first:
+.Bd -literal -offset indent
+(include "downloads" "invoices") ; dirs/\*(Ltname\*(Gt.conf, run in this order
+(log "~/.local/state/krino/krino.log") ; optional
+(defaults ; optional; any setting below
+ (min-age 5m))
+.Ed
+.Bl -tag -width Ds
+.It Ic (include Ar name No ...)
+The directories to sort, in the order given.
+Each
+.Ar name
+has its rules in
+.Pa dirs/ Ns Ar name Ns Pa .conf .
+.It Ic (log Ar path )
+Where the log goes.
+Optional; defaults to
+.Pa $XDG_STATE_HOME/krino/krino.log .
+.It Ic (defaults Ar setting No ...)
+Defaults for every directory; a directory's own file, and a rule inside
+it, can override them.
+See
+.Sx SETTINGS .
+.El
+.Ss dirs/name.conf
+One file per configured directory:
+.Bd -literal -offset indent
+(path "~/downloads") ; required
+(recursive no) ; any setting from SETTINGS
+(ignore "*.part" "*.aria2" ".*") ; may repeat; patterns accumulate in order
+
+(rule "acme"
+ (when (type document)
+ (or (content "acme ltd" "0000000000")
+ (name "(^|[^a-z0-9])acme([^a-z0-9]|$)"))
+ (not (name "^draft")))
+ (move "Work/Acme/{mtime:%Y}")
+ (stop))
+.Ed
+.Pp
+Top-level forms may appear in any order, except that rules run in the
+order written.
+.Bl -tag -width Ds
+.It Ic (path Ar dir )
+Required.
+The directory this file sorts.
+.Ar ~
+expands.
+.It Ic (ignore Ar pattern No ...)
+May repeat; patterns accumulate in order.
+Gitignore syntax:
+.Ql * ,
+.Ql ** ,
+.Ql \&? ,
+.Ql [...] ;
+a leading
+.Ql /
+anchors to the root; a trailing
+.Ql /
+matches directories only;
+.Ql \&!
+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 (rule Ar name item No ...)
+See
+.Sx RULES .
+.El
+.Sh SETTINGS
+Each setting may appear in
+.Ic (defaults ...) ,
+at the top of a directory file, or, where marked
+.Pq rule ,
+inside a
+.Ic rule
+form.
+The most specific wins: rule, then directory, then defaults, then the
+built-in default below.
+Durations are an integer plus
+.Ql s m h d w ;
+sizes an integer plus an optional
+.Ql K M G T
+.Pq powers of 1024 .
+.Bl -tag -width Ds
+.It Ic case
+.Pq rule .
+.Sy ignore
+or
+.Sy strict :
+case sensitivity for
+.Ic name , path
+and
+.Ic content .
+Built-in:
+.Sy ignore .
+.It Ic fold
+.Pq rule .
+.Sy yes
+or
+.Sy no :
+strip diacritics before comparing
+.Po
+a with an ogonek becomes plain a, an l with a stroke becomes plain l,
+e acute becomes plain e, u with diaeresis becomes plain u,
+and so on for every Latin letter
+.Pc .
+Built-in:
+.Sy yes .
+.It Ic recursive
+.Sy yes
+or
+.Sy no .
+Built-in:
+.Sy no .
+.It Ic max-depth
+An integer;
+.Sy 1
+means the root only.
+Built-in: unlimited.
+.It Ic min-age
+A duration.
+Files modified more recently are skipped as busy.
+Built-in:
+.Sy 2m .
+.It Ic max-read
+A size.
+No content is extracted from a file above this size.
+Built-in:
+.Sy 50M .
+.It Ic busy
+One or more suffixes.
+A file is skipped when its own name with one of these suffixes appended
+also exists beside it, e.g.
+.Pa report.pdf.part
+beside
+.Pa report.pdf .
+Built-in:
+.Sy .part .aria2 .crdownload .
+.It Ic on-conflict
+.Pq rule .
+.Sy suffix , skip
+or
+.Sy overwrite :
+see
+.Sx Conflicts .
+Built-in:
+.Sy suffix .
+.El
+.Sh RULES
+.Bd -literal -offset indent
+(rule NAME ITEM...)
+.Ed
+.Pp
+Items, in any order except that actions run in the order written:
+.Bl -tag -width Ds
+.It Ic (when Ar cond No ...)
+The condition; see
+.Sx CONDITIONS .
+Several conditions means all must hold.
+A rule with no
+.Ic when
+matches every file.
+An empty
+.Ic (when)
+is an error.
+.It Ic case , Ic fold , Ic on-conflict
+Rule-level settings; see
+.Sx SETTINGS .
+.It Ic (copy Ar dest )
+Copy the file into directory
+.Ar dest .
+.It Ic (move Ar dest )
+Move the file into directory
+.Ar dest .
+.It Ic (rename Ar name )
+Rename in place.
+.Ar name
+must not contain
+.Ql / .
+.It Ic (delete)
+Move to the trash.
+.It Ic (delete permanent)
+Unlink.
+Cannot be undone.
+.It Ic (stop)
+Once this rule matches, evaluate no further rules for this file.
+.El
+.Pp
+A rule with only
+.Ic (stop)
+is an exclusion: files it matches receive no actions from later rules.
+.Pp
+.Ar dest
+is a directory: a relative path is relative to the root,
+.Ql ~
+expands, an absolute path is allowed, and it is created if missing.
+.Ar dest
+and
+.Ar name
+take placeholders; see
+.Sx Placeholders .
+.Sh CONDITIONS
+.Ss Operators
+.Ic (and Ar cond No ...) ,
+.Ic (or Ar cond No ...) ,
+.Ic (not Ar cond ) .
+.Ic and
+and
+.Ic or
+take one or more arguments;
+.Ic not
+exactly one.
+.Ss Tests
+.Bl -tag -width Ds
+.It Ic (type Ar t No ...)
+True when the name ends in
+.Ql \&. Ns Ar t
+for any
+.Ar t ,
+case-insensitively.
+.Ar t
+may be a type group
+.Po
+e.g.\&
+.Ic document ;
+see
+.Sx TYPE GROUPS
+.Pc
+or a multi-part suffix like
+.Ic tar.gz .
+.It Ic (name Ar re No ...)
+True when the file name matches any of the regexes.
+.It Ic (path Ar re No ...)
+True when the path relative to the root matches any of the regexes.
+.It Ic (content Ar keyword No ...)
+True when the extracted text contains any of the keywords; see
+.Sx CONTENT EXTRACTION .
+.It Ic (size Ar op size )
+.Ar op
+is one of
+.Ql > >= < <= = .
+.It Ic (age Ar op duration )
+Age by modification time.
+.It Ic (duplicate)
+True when another scanned file has identical content and this one is not
+the chosen original; see
+.Sx DUPLICATES .
+.It Ic (duplicate Ar dir No ...)
+The same, also comparing against every regular file under
+.Ar dir .
+.It Ic (matched)
+True when an earlier rule already matched this file.
+.El
+.Pp
+Regexes use Go's RE2 syntax: POSIX extended regular expressions plus
+.Ql \ed \ew \es \eb ,
+non-greedy quantifiers, and the inline flags
+.Ql (?i)
+and
+.Ql (?-i) ;
+there are no backreferences and no lookaround.
+.Ss Case, folding and word boundaries
+.Ic case
+and
+.Ic fold
+apply to
+.Ic name , path
+and
+.Ic content ,
+in both the file's data and the pattern;
+.Ic type
+is always case-insensitive.
+A regex can override
+.Ic case
+locally with
+.Ql (?i)
+or
+.Ql (?-i) .
+.Pp
+.Sy RE2 treats
+.Ql _
+as a word character.
+.Ql \eb
+is the boundary between a word character and a non-word character, so
+.Ql \ebacme\eb
+does
+.Em not
+match
+.Ar ACME_REPORT_2026.pdf :
+the
+.Ql E
+before the underscore and the underscore itself are both word characters,
+so there is no boundary there for
+.Ql \eb
+to match.
+Use a character class instead of
+.Ql \eb
+when the name may be glued to the rest with an underscore or a digit:
+.Bd -literal -offset indent
+(name "(^|[^a-z0-9])acme([^a-z0-9]|$)")
+.Ed
+matches
+.Ar ACME_REPORT_2026.pdf
+.Pq case-insensitively, by default
+because it treats anything that is not a lowercase letter or digit,
+including
+.Ql _ ,
+as a separator.
+.Sh TYPE GROUPS
+A group name in
+.Ic (type ...)
+stands for every extension listed for it:
+.Bl -tag -width "presentation"
+.It Ic image
+jpg jpeg png gif webp bmp tif tiff heic heif avif svg ico raw cr2 nef arw dng
+.It Ic video
+mp4 mkv webm mov avi m4v mpg mpeg wmv flv 3gp
+.It Ic audio
+mp3 flac ogg opus m4a aac wav wma aiff
+.It Ic archive
+zip tar gz tgz bz2 tbz2 xz txz zst 7z rar lz lzma cpio
+.It Ic document
+pdf doc docx odt rtf txt md tex
+.It Ic spreadsheet
+xls xlsx ods csv tsv
+.It Ic presentation
+ppt pptx odp
+.It Ic ebook
+epub mobi azw azw3 fb2 djvu
+.It Ic code
+go c h cpp hpp py sh js ts rs java rb pl lua html css json yaml yml toml xml sql
+.It Ic text
+txt md log csv tsv json yaml yml toml xml ini conf
+.It Ic package
+deb rpm apk appimage exe msi flatpak snap
+.It Ic font
+ttf otf woff woff2
+.El
+.Pp
+Groups overlap; a file can belong to several.
+.Sh CONTENT EXTRACTION
+.Bl -tag -width "presentation"
+.It Sy text
+Known text extensions, or detected from the first 8 KiB: valid UTF-8, or
+UTF-16 with a BOM, and no NUL bytes.
+.It Sy pdf
+.Ic pdftotext
+.Pq 30 second timeout .
+.It Sy docx, xlsx, pptx, odt, ods, odp, epub
+Zip plus streaming XML, Go standard library only.
+.It Sy html, xml
+Tags removed, entities decoded.
+.It Sy doc
+.Ic antiword ,
+else
+.Ic catdoc .
+.It Sy xls / ppt
+.Ic xls2csv
+or
+.Ic catppt
+.Pq both from catdoc .
+.It Sy anything else
+No content.
+.El
+.Pp
+External tools are optional and looked up once per run;
+.Ic krino check
+lists which were found.
+A file above
+.Ic max-read
+is not extracted.
+Tools run without a shell and are given absolute paths only, so a file
+name starting with
+.Ql -
+is never read as an option, and each is killed at its timeout.
+.Pp
+Before matching, text and keywords are normalised the same way: case
+.Pq if Sy ignore ,
+folding
+.Pq if Sy yes ,
+and runs of whitespace collapsed to one space, so a keyword split across
+lines in a PDF still matches.
+Matching is substring:
+.Ql \&"acme\&"
+matches
+.Ql \&"acmeco\&" .
+Words hyphenated across lines in a PDF are not rejoined.
+.Sh ACTIONS
+.Ss Chains
+A file's chain is the actions of every matching rule, in rule order, and
+within a rule in the order written.
+.Ic copy
+leaves the file where it is;
+.Ic rename
+changes its name and
+.Ic move
+its directory, and later steps use the new path;
+.Ic delete
+ends the chain, and steps after it are shown in the plan as
+.Dq skipped: deleted by rule Ar x .
+.Pp
+The plan warns when a chain moves a file more than once; that is usually
+a missing
+.Ic (stop) .
+.Ss How each action runs
+.Bl -tag -width Ds
+.It Ic move
+.Xr rename 2
+when source and destination share a filesystem; otherwise a copy to a
+temporary file in the destination, fsync, rename into place, then the
+source is removed.
+If any step fails the source is untouched and the temporary file is
+removed.
+.It Ic copy
+To a temporary file in the destination, then renamed into place.
+Mode and modification time are preserved.
+.It Ic rename
+.Xr rename 2
+within the same directory.
+.It Ic delete
+Into the freedesktop.org trash at
+.Pa $XDG_DATA_HOME/Trash .
+A file on a different filesystem from the trash is not trashed: the step
+fails with a message suggesting
+.Ic (delete permanent)
+or a move.
+.It Ic (delete permanent)
+.Xr unlink 2 .
+.El
+.Pp
+Before each step, the executor checks that the source still exists with
+the size and modification time recorded in the plan; if not, the step
+fails as
+.Dq changed since plan
+and the rest of that file's chain is skipped.
+.Ss Placeholders
+.Bl -tag -width "{mtime:FMT}"
+.It Ic {name}
+The current file name.
+.It Ic {stem}
+The name without its last extension.
+.It Ic {ext}
+The last extension with its dot, e.g.
+.Ql .pdf ;
+empty if none.
+.It Ic {1} No ... Ic {9}
+Capture groups of the first
+.Ic name
+test, not inside a
+.Ic not ,
+that matched while evaluating this rule.
+.Ic krino check
+refuses a rule that uses
+.Ic { Ns Ar n Ns Ic }
+unless every such
+.Ic name
+test in it has at least
+.Ar n
+groups, and refuses one that uses
+.Ic { Ns Ar n Ns Ic }
+with no
+.Ic name
+test at all.
+.It Ic {mtime:FMT}
+The file's modification time.
+.It Ic {now:FMT}
+The start of the run.
+.It Ic {{ No and Ic }}
+A literal
+.Ql {
+or
+.Ql } .
+.El
+.Pp
+.Ar FMT
+is a strftime subset:
+.Ql %Y %m %d %H %M %S %j %% .
+.Ss Conflicts
+When the computed target already exists:
+.Bl -tag -width Ds
+.It Ic suffix
+.Pq default .
+Use
+.Pa stem_1.ext , stem_2.ext ,
+and so on.
+.It Ic skip
+Skip this step; the chain continues from the file's current path.
+.It Ic overwrite
+Move the existing target to the trash first
+.Pq logged, so undo restores it ,
+then proceed.
+.El
+.Pp
+A
+.Ic copy
+whose target already has identical content is skipped as
+.Dq already there ,
+whatever the policy, so a backup rule can run every time.
+A step whose target is the file itself
+.Pq a Ar dest No that resolves to the file's own directory, or a Ic rename No to its current name
+is skipped as
+.Dq already there
+as well; a directory whose destination's first path component is a
+placeholder relies on this to avoid re-filing its own output \(em see
+.Sx KNOWN LIMITATIONS .
+.Pp
+Conflicts between files in the same plan are resolved when planning, so
+the plan shows final names; an in-plan claim is never displaced.
+The claim set spans the whole run, so two configured directories cannot
+plan the same final name.
+The executor re-checks at execution time; if the name has to change, the
+log records the actual name used.
+.Sh DUPLICATES
+Candidates are the scanned files plus, for
+.Ic (duplicate Ar dir ) ,
+every regular file under
+.Ar dir
+.Pq symlinks skipped .
+Files are grouped by size; only groups of two or more are hashed, first
+the first and last 64 KiB, then SHA-256 of the whole file.
+Empty files are never duplicates.
+.Pp
+Two names for the same file
+.Pq the same device and inode, as a hard link creates
+are never duplicates of
+.Em each other ;
+they may still both independently be duplicates of a separate file with
+identical content.
+.Pp
+The
+.Em original
+in each group is, in order of preference: a file under one of the given
+.Ar dir
+arguments, then the oldest by modification time, then the shortest name,
+then the name that sorts first.
+.Ic (duplicate)
+is true for every other scanned file in the group.
+.Pp
+.Sy Warning:
+.Ic (duplicate)
+cannot tell a deliberate second copy from an accidental one.
+Byte-identical content is byte-identical either way; the distinction
+between
+.Dq I meant to keep two
+and
+.Dq this should not exist twice
+exists only in the user's intent, not in the files.
+Pairing
+.Ic (duplicate)
+with
+.Ic (delete)
+over a directory that may hold intentional copies
+.Pq a mirror, a staging queue, anything another tool manages
+will delete things the user meant to keep.
+Keep such a rule on a directory scoped narrowly enough that every
+byte-identical pair in it really is an accident.
+.Pp
+.Sy Warning:
+each
+.Ic (duplicate)
+or
+.Ic (duplicate Ar dir )
+condition elects its own original from its own candidates; two conditions
+with different scopes can therefore elect
+.Em different
+originals for the same content.
+Within one directory's rules
+.Pq one rule combining conditions with Ic or , or two separate rules ,
+this can make every copy in a group selected by some condition, and with
+.Ic (delete permanent)
+every copy selected is gone for good.
+.Ic krino
+plans and applies one directory at a time, so a directory already applied
+in the run stays applied while a later directory is planned; a
+.Fl n
+run only ever plans, so it shows every directory's plan as if none of the
+others had been applied.
+Two files in different directories that a
+.Fl n
+plan each lists as a duplicate of the other are therefore not necessarily
+both deleted when the same run is applied with
+.Fl y :
+deleting the first can remove the very original the second file was a
+duplicate of, leaving the second no longer a duplicate by the time its own
+directory is planned.
+Use one duplicate scope for rules that delete within a directory; prefer
+.Ic (delete)
+to
+.Ic (delete permanent)
+with
+.Ic (duplicate) ;
+and within one directory's rules, two files each listed as a duplicate of
+the other in the
+.Fl n
+plan means both would be deleted.
+.Sh KNOWN LIMITATIONS
+A
+.Ar dest
+whose
+.Em first
+path component is itself a placeholder
+.Pq e.g. Ic {ext} No or Ic {mtime:%Y}
+has no static prefix, so nothing under it can be excluded from the walk
+before scanning starts, and a recursive directory walks into
+.Ic krino Ns 's
+own output.
+A file already sitting at its own computed destination is a no-op,
+.Dq already there
+.Pq Sx Conflicts ,
+rather than being re-filed on every run, but the walk still visits it,
+which a
+.Ic (duplicate)
+or
+.Ic content
+test in another rule can also see.
+Give such a rule its own narrow
+.Ic ignore
+pattern, or a
+.Ar dest
+whose first path component is a literal string, when that matters.
+.Pp
+.Ic (duplicate)
+conditions with different scopes do not share an original: two conditions
+electing from different candidate sets can each treat a different file as
+the original of the same content
+.Pq Sx DUPLICATES .
+.Sh SEE ALSO
+.Xr krino 1
+.Pp
+.Pa docs/sexp-primer.md
+in the source repository
+.Pq installed at Pa $PREFIX/share/doc/krino/sexp-primer.md .
diff --git a/scripts/deps b/scripts/deps
new file mode 100755
index 0000000..06faaa2
--- /dev/null
+++ b/scripts/deps
@@ -0,0 +1,99 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# scripts/deps: install krino's optional content extractors with the host's
+# system package manager, per docs/design.md §14.
+#
+# --print prints the exact command this script would run (and the reason,
+# when one platform's package list differs from the others), then exits
+# without running anything and without asking for privileges. `make deps`
+# calls this script with no flag; plain `make`/`make build` never calls it
+# at all, so plain `make` never asks for privileges (§14).
+#
+# Detection:
+# - Debian family - /etc/os-release's ID *or* ID_LIKE names "debian" as
+# one of its (possibly multi-word) values. Devuan is ID=devuan,
+# ID_LIKE=debian: keying on ID=debian alone would leave it unrecognised.
+# -> apt-get install poppler-utils catdoc antiword
+# - FreeBSD (uname -s) -> pkg install poppler-utils antiword. catdoc is
+# left out on purpose: it pulls in tcl/tk.
+# - OpenBSD (uname -s) -> pkg_add poppler-utils catdoc antiword
+# - anything else: print what is needed and exit non-zero. This script
+# never guesses a package manager.
+#
+# Privileges: doas if present, else sudo; if neither exists (and we are not
+# already root), the command is printed for the user to run as root
+# themselves, rather than attempting it unprivileged and failing silently.
+
+set -eu
+
+print_only=0
+case "${1:-}" in
+ --print) print_only=1 ;;
+ "") ;;
+ *)
+ echo "usage: $0 [--print]" >&2
+ exit 2
+ ;;
+esac
+
+# is_debian_family: true if /etc/os-release's ID or ID_LIKE contains
+# "debian" as one of its space-separated words.
+is_debian_family() {
+ [ -r /etc/os-release ] || return 1
+ (
+ . /etc/os-release
+ for word in ${ID:-} ${ID_LIKE:-}; do
+ [ "$word" = "debian" ] && exit 0
+ done
+ exit 1
+ )
+}
+
+# privileged CMD...: CMD prefixed with doas or sudo, whichever is found
+# first; unprefixed if we are already root or if neither exists (the
+# caller decides what to do in that last case).
+privileged() {
+ if [ "$(id -u)" = 0 ]; then
+ printf '%s\n' "$*"
+ elif command -v doas >/dev/null 2>&1; then
+ printf 'doas %s\n' "$*"
+ elif command -v sudo >/dev/null 2>&1; then
+ printf 'sudo %s\n' "$*"
+ else
+ printf '%s\n' "$*"
+ fi
+}
+
+note=""
+if is_debian_family; then
+ pkgcmd="apt-get install poppler-utils catdoc antiword"
+elif [ "$(uname -s)" = "FreeBSD" ]; then
+ pkgcmd="pkg install poppler-utils antiword"
+ note="catdoc left out on FreeBSD: it pulls in tcl/tk."
+elif [ "$(uname -s)" = "OpenBSD" ]; then
+ pkgcmd="pkg_add poppler-utils catdoc antiword"
+else
+ echo "krino's optional extractors: poppler-utils (pdftotext), catdoc, antiword" >&2
+ echo "no supported package manager detected (uname -s: $(uname -s), no debian-family /etc/os-release)" >&2
+ echo "install them yourself with whatever this system uses" >&2
+ exit 1
+fi
+
+cmd=$(privileged $pkgcmd)
+
+[ -n "$note" ] && echo "$note"
+
+if [ "$print_only" = 1 ]; then
+ echo "$cmd"
+ exit 0
+fi
+
+if [ "$(id -u)" != 0 ] && ! command -v doas >/dev/null 2>&1 && ! command -v sudo >/dev/null 2>&1; then
+ echo "neither doas nor sudo found; run this yourself as root:" >&2
+ echo "$cmd" >&2
+ exit 1
+fi
+
+echo "$cmd"
+exec $cmd
diff --git a/scripts/man-lint b/scripts/man-lint
new file mode 100755
index 0000000..7b563b7
--- /dev/null
+++ b/scripts/man-lint
@@ -0,0 +1,68 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# man-lint: check the mdoc man pages for real errors, not just "did it run".
+#
+# Prefers `mandoc -Tlint -Wwarning`. -W sets the lowest message level that
+# mandoc reports and counts in its exit status, so this fails (exit 2 or
+# higher) on any WARNING, ERROR or UNSUPP message. STYLE and BASE messages
+# (for example "referenced manual not found" for pages not yet installed)
+# are below that level: not shown, and never a failure. -Werror would not
+# do: it hides warnings and exits 0 on them.
+#
+# Falls back to `groff -ww -z -mdoc` when mandoc is not installed. Two traps
+# with that fallback, both deliberate here:
+# - the pages are written in the mdoc macro package, not man(7), so this
+# must ask groff for -mdoc specifically; the wrong package produces
+# noise (or silently accepts things mdoc would reject);
+# - groff exits 0 whether or not it complained - -z asks it to run only
+# the diagnostics pass (no formatted output), so anything at all on its
+# stderr, not its exit code, is what "failed" means here.
+#
+# Skips with a printed notice, exit 0, when neither tool is installed: a
+# machine with no man toolchain must not fail this gate, the same way
+# internal/ignore's git-oracle test skips itself when git is absent.
+#
+# Usage: scripts/man-lint [FILE...] (default: man/*.[15])
+
+set -eu
+
+cd "$(git rev-parse --show-toplevel)"
+
+if [ "$#" -gt 0 ]; then
+ # shellcheck disable=SC2124 # deliberately captured as one word list below
+ files="$*"
+else
+ files='man/*.[15]'
+fi
+# shellcheck disable=SC2086 # $files is a glob pattern or an arg list, meant to split/expand
+set -- $files
+if [ "$#" -eq 0 ] || [ ! -e "$1" ]; then
+ echo "man-lint: no man pages found ($files)" >&2
+ exit 1
+fi
+
+if command -v mandoc >/dev/null 2>&1; then
+ echo "man-lint: mandoc -Tlint -Wwarning $*"
+ exec mandoc -Tlint -Wwarning "$@"
+fi
+
+if command -v groff >/dev/null 2>&1; then
+ fail=0
+ for f in "$@"; do
+ echo "man-lint: groff -ww -z -mdoc $f"
+ out=$(groff -ww -z -mdoc "$f" 2>&1 >/dev/null) || true
+ if [ -n "$out" ]; then
+ printf 'man-lint: %s:\n%s\n' "$f" "$out" | sed '2,$s/^/ /' >&2
+ fail=1
+ fi
+ done
+ if [ "$fail" -ne 0 ]; then
+ echo "man-lint: groff reported the warnings above; it exits 0 regardless, so stderr output alone is the failure signal here" >&2
+ exit 1
+ fi
+ exit 0
+fi
+
+echo "man-lint: neither mandoc nor groff is installed; skipping man page lint" >&2
+exit 0