diff options
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. @@ -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 |
