# krino design Status: describes krino 0.0.1, 2026-09-13; amended 2026-09-14 for 0.0.2: duplicates are never deleted (§5.5), and coloured output with `--no-color` (§8.2, §11); amended again 2026-09-14 for 0.0.3: the plan shown as one block per file, wrapped to the terminal, and `-P` (§8.2, §8.3, §11); amended again 2026-09-14 for 0.0.4: `max-size` (§4.4), `(exclude ...)` (§4.2, §4.3, §4.6) and `--min-age` (§11); amended again 2026-09-14 for 0.0.5: the keyword cache (§3, §6.1, §13), and `t`, `d` and `w` in review (§8.3, §10); amended again 2026-09-14 for 0.0.6: `w` applies and quits, and each choice is echoed in red (§8.2, §8.3); amended again 2026-09-14 for 0.0.7: the threat model and its tests (§15.1). 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, age, text content or duplicate status, combined with `and` / `or` / `not`, and applies actions: copy, move, rename, delete. krino shows what it will do, lets you approve all of it or file by file, logs every step, and can undo a run. It grew out of an earlier Python prototype; the lessons that shaped it are in §16. --- ## 1. Scope **0.0.1 is:** the engine and a command-line interface. - per-directory rule files, a main file listing which directories run, a template for new ones - conditions with `and` / `or` / `not` over type, name, path, content, size, age, duplicates - actions copy, move, rename, delete (to Trash by default), chained - plan, review, approve all or per file, `-y` to skip review - a log of every step, and `krino undo` - placeholders in destinations and new names - builds for Linux, FreeBSD and OpenBSD **Not in 0.0.1:** the GUI (a separate project after 0.0.1, see §12), watch mode, OCR, EXIF dates, macOS and Windows, a content cache, a shell-command action, looking inside archives. ## 2. Terms | Term | Meaning | |---|---| | directory | a configured directory krino sorts, e.g. `~/downloads`; one config file each | | root | that directory's path | | rule | a named condition plus actions | | test | one leaf of a condition, e.g. `(type pdf)` | | chain | the ordered actions krino will perform on one file | | plan | every chain for one directory, computed before anything changes | | run | one invocation of krino that applied at least one step, across all the directories it processed; has an id; the unit of undo | ## 3. Files and locations ``` $XDG_CONFIG_HOME/krino/ default ~/.config/krino krino.conf main file: which directories run, defaults, log path template.conf copied by `krino new` dirs/.conf one per directory $XDG_STATE_HOME/krino/ default ~/.local/state/krino krino.log the log (§9) .lock per-directory lock while a run is active $XDG_CACHE_HOME/krino/ default ~/.cache/krino .cache keyword cache (§6.1), 0600, safe to delete ``` `krino init` creates the config directory with a commented `krino.conf` and `template.conf` (both embedded in the binary). It refuses if krino.conf exists and keeps an existing template.conf. `krino new NAME PATH` copies `template.conf` to `dirs/NAME.conf`, fills in the path, and appends NAME to `include` in `krino.conf`, keeping its comments. Generated files begin with `;; -*- mode: lisp -*-` and `;; vim: set ft=lisp :` so editors apply Lisp highlighting and parenthesis matching. ## 4. Config language ### 4.1 Syntax The files are s-expressions (see `docs/sexp-primer.md` for a tutorial). - **List:** `(` items separated by whitespace `)` - **String:** `"..."`. A backslash escapes only `"` and `\`; any other backslash is kept literally, so `"\bacme\b"` is the regex `\bacme\b`. Strings may span lines. - **Symbol:** any other run of characters except whitespace, `(`, `)`, `"`, `;`. - **Comment:** `;` to end of line. - Encoding UTF-8. No other syntax: no quote characters, no dotted pairs, no block comments. The reader records the byte offset, line and column of every list and atom. krino edits a file by splicing text at those offsets (`krino new` inserts a name into `include`), so everything else, comments and layout included, stays byte for byte. A printer for generated forms arrives with the GUI. Errors report `file:line:col`. An unclosed list is reported where it opened, described by its first atoms: `"(" never closed: (rule "acme" ...)`. Paths, keywords, regexes and rule names must be strings. Settings values, type names, operators, sizes and durations are symbols. ### 4.2 `krino.conf` ```lisp (include "downloads" "documents") ; dirs/.conf, run in this order (log "~/.local/state/krino/krino.log") ; optional (defaults ; optional; any setting from 4.4 (min-age 5m)) (exclude (type iso)) ; optional, may repeat; every directory (4.6) ``` ### 4.3 `dirs/.conf` ```lisp (path "~/downloads") ; required (recursive no) ; any setting from 4.4 (ignore "*.part" "*.aria2" ".*") ; may repeat; patterns accumulate in order (exclude (name "^keep-")) ; may repeat; this directory only (4.6) (rule "acme" (when (type document) (or (content "acme ltd" "0000000000") (name "\bacme\b")) (not (name "^draft"))) (move "Work/Acme/{mtime:%Y}") (stop)) ``` Top-level forms may appear in any order, except that rules are evaluated in the order written. ### 4.4 Settings Each setting can appear in `(defaults ...)`, at the top of a directory file, or (where marked) inside a rule. The most specific wins: rule, then directory, then defaults, then built-in. | Setting | Values | Built-in | In a rule | |---|---|---|---| | `case` | `ignore` \| `strict` | `ignore` | yes | | `fold` | `yes` \| `no`. Strip diacritics before comparing: ą→a, ł→l, é→e, ü→u, and so on for all Latin letters | `yes` | yes | | `recursive` | `yes` \| `no` | `no` | | | `max-depth` | integer, 1 = root only | unlimited | | | `min-age` | duration. Skip files modified more recently | `2m` | | | `max-read` | size. No content extraction above this file size | `50M` | | | `max-size` | size. Skip files larger than this, as "too big" | unlimited | | | `busy` | suffixes. Skip `f` when `f` exists beside it | `".part" ".aria2" ".crdownload"` | | | `on-conflict` | `suffix` \| `skip` \| `overwrite` (§7.4) | `suffix` | yes | Durations: integer plus `s m h d w`. Sizes: integer plus optional `K M G T` (powers of 1024). ### 4.5 Rules ```lisp (rule NAME ITEM...) ``` Items, in any order except that actions run in the order written: | Item | Meaning | |---|---| | `(when COND...)` | the condition. Several conditions means all must hold. A rule without `when` matches every file. An empty `(when)` is an error. | | `(case ...)` `(fold ...)` `(on-conflict ...)` | rule-level settings | | `(copy DEST)` | copy the file into directory DEST | | `(move DEST)` | move the file into directory DEST | | `(rename NAME)` | rename in place; NAME must not contain `/` | | `(delete)` | move to Trash | | `(delete permanent)` | unlink; cannot be undone | | `(stop)` | once this rule matches, evaluate no further rules for this file | A rule with only `(stop)` is an exclusion: files it matches receive no actions from later rules. A rule whose condition contains `(duplicate)` anywhere, including inside `and`, `or` or `not`, cannot contain `(delete)` or `(delete permanent)`: `krino check` and every run refuse it (§5.5). `DEST` is a directory: relative paths are relative to the root, `~` expands, absolute paths are allowed. It is created if missing. `DEST` and `NAME` take placeholders (§7.3). ### 4.6 Exclusions ```lisp (exclude (type iso img)) ; by extension (exclude (name "^keep-")) ; by name (exclude (type pdf) (content "confidential")) ; by content ``` `(exclude COND...)` sets files aside before any rule sees them. Its conditions are the tests of §5.2, and all of them must hold, as in `when`; a file matching any `exclude` form is excluded. Forms in `krino.conf` apply to every directory and are tested first, then the directory's own. They are compiled with the directory's `case` and `fold`. An excluded file is counted as "excluded" in the plan, listed with the form that matched under `-v`, and traced by `explain`; `check` lists every directory's exclusions. Since no rule has run yet, `(matched)` is never true inside an exclude. Unlike `ignore`, which never looks inside a file and never descends into an ignored directory, an exclude can test content and size, and is evaluated per file after the walk. ## 5. Conditions ### 5.1 Operators `(and C...)`, `(or C...)`, `(not C)`. `and` and `or` take one or more arguments; `not` exactly one. ### 5.2 Tests | Test | True when | |---|---| | `(type T...)` | the name ends in `.T` for any T, case-insensitively. T may be a group (§A) or a multi-part suffix like `tar.gz`. | | `(name "RE"...)` | the file name matches any regex | | `(path "RE"...)` | the path relative to the root matches any regex | | `(content "KW"...)` | the extracted text contains any keyword (§6) | | `(size OP SIZE)` | OP is `> >= < <= =` | | `(age OP DURATION)` | age by modification time | | `(duplicate)` | another scanned file has identical content, and this one is not the original (§5.5) | | `(duplicate "DIR"...)` | the same, also comparing against files under those directories | | `(matched)` | an earlier rule matched this file | Regexes use Go's RE2 syntax: POSIX ERE plus `\d \w \s \b`, non-greedy quantifiers, and inline flags `(?i)` `(?-i)`; no backreferences or lookaround. ### 5.3 Case and diacritics `case` and `fold` apply to `name`, `path` and `content`, in both the file's data and the pattern. `type` is always case-insensitive. A regex can override `case` locally with `(?i)` or `(?-i)`. Folding decomposes letters (Unicode NFD), drops combining marks, and maps the letters that do not decompose (ł ø đ ħ ß æ œ and their capitals) to ASCII. ### 5.4 Evaluation 1. Every test sees **the original file**: name, path, size, mtime and content as they were when scanning. Actions from earlier rules do not change what later rules see. 2. Rules are evaluated in order. A matching rule adds its actions to the file's chain. `(stop)` on a matching rule ends evaluation for that file. 3. Tests have no side effects, so `and` / `or` evaluate their arguments cheapest first: `type`, `size`, `age` and `matched`, then `name` and `path`, then `duplicate`, then `content`. Content is extracted at most once per file, and only if evaluation reaches a `content` test. 4. If a file's text cannot be extracted, `content` is false and the plan shows a warning naming the rule that wanted it. ### 5.5 Duplicates 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. **Duplicates are found, never deleted.** Deciding which copy of identical content to remove is left to the user, or to a tool built for it such as jdupes. Two rules enforce this: 1. A rule whose condition contains `(duplicate)` cannot contain a `delete` action (§4.5). 2. In a directory whose rules use `(duplicate)`, a file that is a duplicate under any of the duplicate scopes those rules use — each distinct set of DIRs, and the plain `(duplicate)`, looked up for the file whether or not evaluation reached that test — gets no delete step from any rule. The plan shows the step as "skipped: a duplicate is never deleted" and the rest of the chain continues from the file's current path. This covers what rule 1 cannot see: a later rule deleting through `(matched)` or through a condition of its own. If that lookup fails, the delete is skipped too, as "duplicate check failed, so not deleted: REASON": krino cannot show the file is not a duplicate, so it keeps it. Together they mean no rule can delete every copy of content that a duplicate test in that directory can see. Under each scope every scanned file in a group except that scope's original is a duplicate, so a scanned file that is a duplicate under no scope must be the original of every scope, and there is at most one such file (its hard-linked names count as that one file). Every other copy is a duplicate somewhere, and a duplicate is never deleted. A file displaced by `(on-conflict overwrite)` is not covered; it goes to the Trash, and `krino undo` restores it (§7.4). The way to deal with duplicates is to move them aside and decide later: ```lisp (rule "dupes" (when (duplicate "~/docs/Archive")) (move "~/.dupes/") (stop)) ``` Every move is logged (§9), so `krino undo` puts them back. Duplicate conditions with different scopes do not share an original: each elects from its own candidates, so two such rules can each select a different copy of the same content, and between them move every copy aside. Nothing is deleted, and undo restores them. ## 6. Content extraction | Format | Method | |---|---| | text | known text extensions, or detected from the first 8 KiB: valid UTF-8, or UTF-16 with a BOM, and no NUL bytes | | pdf | `pdftotext -q -enc UTF-8 FILE -`, 30 s timeout | | docx xlsx pptx odt ods odp epub | zip plus streaming XML, Go standard library | | html xml | tags removed, entities decoded | | doc | `antiword`, else `catdoc` | | xls / ppt | `xls2csv` / `catppt` (both from catdoc) | | anything else | no content | External tools are optional. Each is looked up once per run; `krino check` lists which were found. Files above `max-read` are not extracted. Tools run without a shell, get absolute paths only (so a name starting with `-` is never read as an option), and are killed at the timeout. Before matching, text and keywords are normalised the same way: case (if `ignore`), fold (if `yes`), and runs of whitespace collapsed to one space, so a keyword split across lines in a PDF still matches. Matching is substring: `"acme"` matches `"acmeco"`. Words hyphenated across lines in a PDF are not rejoined. ### 6.1 Keyword cache Extraction is nearly all of a run's time, so krino remembers what it found. For each file it extracted, `.cache` records which of the directory's content keywords the text contains, against the full list of keywords it was checked against. It stores no text and no file names; a file is known by device, inode, size and modification time, which krino's own moves and renames keep. The keywords themselves are stored, as they appear in the config. - When a file is extracted, every content keyword of its directory is answered at once, so one extraction serves every rule and exclude. - A content test is answered from the cache when the file's entry covers all of the test's keywords. Otherwise the file is extracted and its entry replaced: a changed file, or a new keyword, costs one extraction. - Files above `max-read` are refused before the cache is consulted. Failures (unreadable, tool missing, timeout) are never cached. - The cache is discarded whole when the extractor fingerprint changes: a tool installed, removed or replaced, or krino's extraction code changing (`extract.Version`). - Planning a run (`-n` included) reads the cache and writes it back holding only files still in the directory, under the directory's lock, via a temporary file renamed into place; the directory is 0700, the file 0600. `explain` reads it and never writes. A directory with no content tests has no cache. - Known gap: a file edited in place with its size and modification time deliberately preserved keeps its old answers. Deleting `~/.cache/krino` resets everything. ## 7. Actions ### 7.1 Chains A file's chain is the actions of every matching rule, in rule order, and within a rule in the order written. The executor tracks the file's current path: - `copy` leaves the file where it is; - `rename` changes its name, `move` its directory; later steps use the new path; - `delete` ends the chain. Steps after it appear in the plan as "skipped: deleted by rule X". A `delete` of a duplicate is itself skipped (§5.5) and does not end the chain. The plan warns when a chain moves a file more than once; that is usually a missing `(stop)`. ### 7.2 How each action is carried out - **move:** `rename(2)` when source and destination are on the same filesystem. Otherwise: copy to a temporary file in the destination directory, fsync, rename into place, then remove the source. If any step fails the source is untouched and the temporary file removed. - **copy:** to a temporary file in the destination, then rename into place. Mode and mtime are preserved. - **rename:** `rename(2)` within the same directory. - **delete:** into the freedesktop.org Trash in `$XDG_DATA_HOME/Trash` (`files/` plus a `.trashinfo` in `info/`). A file on a different filesystem from the Trash is not trashed: the step fails with a message suggesting `(delete permanent)` or a move. The Trash layout is the same on Linux, FreeBSD and OpenBSD. - **delete permanent:** `unlink(2)`. Before each step the executor checks that the source still exists with the size and mtime recorded in the plan. If not, the step fails as "changed since plan" and the rest of that file's chain is skipped. ### 7.3 Placeholders | Placeholder | Value | |---|---| | `{name}` | the current file name | | `{stem}` | the name without its last extension | | `{ext}` | the last extension with its dot, e.g. `.pdf`; empty if none | | `{1}` … `{9}` | capture groups of the first `name` test that matched in this rule | | `{mtime:FMT}` | the file's modification time | | `{now:FMT}` | the start of the run | | `{{` `}}` | literal braces | FMT is a strftime subset: `%Y %m %d %H %M %S %j %%`. Captures come from the first `name` test, not inside a `not`, that was true while evaluating this rule. Tests of equal cost keep their written order, so "first" is well defined. `krino check` rejects a rule that uses `{N}` unless every such `name` test in it has at least N groups, and a rule that uses `{N}` with no `name` test at all. ### 7.4 Conflicts When the target already exists: - `suffix` (default): use `stem_1.ext`, `stem_2.ext`, ... - `skip`: skip this step; the chain continues from the file's current path - `overwrite`: move the existing target to Trash first (logged, so undo restores it), then proceed A `copy` whose target has identical content is skipped as "already there", whatever the policy, so a backup rule can run every time. A step whose target is the file itself — a rule whose `DEST` resolves to the directory the file already sits in, or a `rename` to the name it already has — is skipped as "already there" as well, whatever the policy. Without that the file's own existence would read as a conflict with itself and the plan would rename it to `stem_1` on every run. Conflicts between files in the same plan are resolved when planning, so the plan shows final names. An in-plan claim is never displaced: when `overwrite` finds a target another step of the same plan has already claimed, it takes the next free name instead, because that path will hold the other step's output by the time either runs, and displacing it would destroy that step's result. 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. ## 8. Plan, review, approval ### 8.1 What is scanned - The root, recursively if `recursive yes`, to `max-depth`. - Symlinks are never followed; symlinked files are skipped. - `ignore` patterns use gitignore semantics: `*`, `**`, `?`, `[...]`; a leading `/` anchors to the root; a trailing `/` matches directories only; `!` re-includes; a pattern without `/` matches at any depth; the last matching pattern wins. Ignored directories are not descended into. - Always ignored: every rule `DEST` that lies inside the root, the Trash, and the config directory. For a `DEST` with placeholders, the part before the first placeholder is ignored: `Work/Acme/{mtime:%Y}` ignores `Work/Acme/`. Known limitation: a `DEST` whose *first* path component is itself a placeholder (`{ext}`, `{mtime:%Y}`) has no static prefix, so nothing can be excluded before the walk, and krino would re-examine its own output; plan 3 closes this by treating a file already at its computed destination as a no-op. - Skipped: files newer than `min-age` ("too new"), larger than `max-size` ("too big"), or with a `busy` sibling ("busy"). ### 8.2 Display One directory at a time, one block per file that has steps: its number and name, then each step under its kind word, then after each rule's steps that rule's name and the reason it matched (left out for a rule with no condition): ``` krino: downloads ~/downloads 266 scanned · 41 to act on · 2 warnings · 13.67s 1 scan001.pdf move → Work/Acme/2026/ rule acme because content "acme ltd" 2 fv_123.pdf copy → ~/backup/invoices/2026/ rule backup because content "invoice" move → Work/Acme/2026/ rule acme because name \bacme\b 3 IMG_2031.JPG rename → 2026-09-01_IMG_2031.JPG move → Photos/2026/ rule photos because type image 4 setup-1.2.deb DELETE permanently rule old-pkgs because age 94d 5 report (1).pdf move → ~/.dupes/ rule dupes because duplicate of ~/docs/work/report.pdf warnings brochure.doc content unreadable: antiword/catdoc not installed (rule acme) big.pdf content unreadable: 120M > max-read 50M not acted on: 3 busy · 12 ignored · 210 unmatched (-v lists them) [a] apply all [c] choose per file [s] skip this directory [q] quit ``` - On a terminal, every line is wrapped to its width: a long name, path or reason continues on lines indented under its own first column, breaking after a space, `/`, `_` or `-` where it can. Output that is not a terminal is never wrapped, so a plan redirected to a file keeps each field on one line. - Paths are shown relative to the directory when inside it, and with `~` for the home directory otherwise. - Colours use only the terminal's 16-colour ANSI palette plus bold and faint, never 256-colour or RGB values, so the terminal's theme decides what they look like. They appear only on a terminal, and never when `NO_COLOR` is set or `--no-color` is given. What is styled: | Element | Style | |---|---| | directory header `krino: NAME PATH`, and `krino: undo RUN` | bold | | `copy`, `move`, `rename` | green | | `trash` | yellow | | `DELETE permanently`, and undo's `refused:` | bold red | | a skipped step, and a match reason | faint | | rule name | blue | | the `warnings` heading and its lines | yellow | | outcome line | the applied count green when above 0, the failed count red when above 0 | | prompt keys `[a]` `[y]` … | bold | | the choice echoed in review, `→ yes` … | red | | `krino log`'s `(undone)` | faint | Permanent deletes are always marked in capitals, so they stand out without colour too. - A plan taller than the terminal is shown through `$PAGER` (default `less -FRX`) and the prompt follows when the pager exits. `-P` (`--no-pager`) prints it straight to the terminal instead. - Keys are read one at a time without Enter. - `[s]` applies nothing in this directory and moves on to the next one. `[q]` stops krino; directories already applied in this run stay applied and can be undone with `krino undo`. ### 8.3 Choosing per file Each file shows the same block the plan shows, under its position in the list: ``` [2/41] fv_123.pdf copy → ~/backup/invoices/2026/ rule backup because content "invoice" move → Work/Acme/2026/ rule acme because name \bacme\b [y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing ``` Approval is per file: the whole chain or none of it. `t` and `d` approve the file with its chain replaced by a single step on the file itself: to the Trash, or deleted permanently. They are the user's own decision, logged under the rule name `(review)`; `(review)` steps are not subject to the duplicate protection of §5.5, which governs rules. `d` asks `delete NAME permanently? [y/N]`, and any key but `y` deletes nothing and asks about the file again. Each choice is confirmed on its own red line under the file (`→ yes`, `→ no`, `→ trash`, `→ DELETE permanently`, `→ yes, and all remaining`). Enter is ignored. `w` applies what was decided so far and stops krino, without planning or asking about any later directory, so a long review can be done a session at a time. Files answered `n` are logged as declined; files never reached are not logged and are counted separately: `20 applied · 0 failed · 3 declined · 139 not reviewed`. Nothing about a declined file is remembered: the next run asks about it again (an `exclude` stops that). `q` applies nothing in this directory, choices included, and stops krino. ### 8.4 Modes - default: plan, review, approve. - `-y`: print the plan and apply everything. - `-n`: print the plan and exit. `-n --json` prints it as JSON. - If stdin is not a terminal and neither `-y` nor `-n` is given, krino refuses rather than guess. ## 9. Log Append-only, `$XDG_STATE_HOME/krino/krino.log`, one line per event, tab-separated: ``` time run dir file step action status rule src dst size mtime detail ``` - `time`: RFC 3339 with offset. `run`: e.g. `20260911T100203-4f2a`. - `action`: `run-start`, `mkdir`, `copy`, `move`, `rename`, `trash`, `delete`, `displace` (a target trashed by `overwrite`), `run-end`, and the `undo-` forms of each. - `status`: `ok`, `failed`, `skipped`, `declined`. - `size` and `mtime` describe the file at `dst` after the step, so undo can tell whether it has been touched since. - Fields are escaped: `\t`, `\n`, `\\`, and `\xNN` for control bytes and bytes that are not valid UTF-8. Every file name round-trips exactly, and the file stays readable with `grep` and `awk`. Dry runs are not logged. Files never acted on (unmatched, ignored, busy) are not logged; declined files are. ## 10. Undo - `krino log` lists recent runs: id, time, directories, counts. - `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). Its per-file prompt has no `t` or `d`. Reversals, last step first within each file: | Step | Reversal | Refused when | |---|---|---| | move, rename | move `dst` back to `src` | `dst` missing or changed (size/mtime), or `src` now exists | | copy | move `dst` to Trash | `dst` missing or changed | | trash | restore from Trash, remove the `.trashinfo` | the Trash entry is gone, or `src` now exists | | displace | restore the displaced target from Trash | as above | | mkdir | remove the directory if empty | not empty | | delete permanent | none; reported as not undoable | always | If a reversal is refused, the earlier steps of that file's chain are not reversed either, so no file is left half undone. Removing a directory that is not empty is the exception: it means another file still lives there, not that the world changed under us, so that refusal is recorded and the rest of the file's reversal proceeds. Undo runs are logged like any other run. ## 11. Command line ``` krino [-y | -n] [-v] [--json] [-c FILE] [NAME...] plan and apply every included directory, or only the named ones krino init create ~/.config/krino with krino.conf and template.conf krino new NAME PATH create dirs/NAME.conf from the template and include it krino check [NAME...] validate config, list rules and available extractors krino explain FILE evaluate every rule against FILE, show each test's result krino log [-n N] list recent runs krino undo [RUN] reverse a run (default: the last one) -y apply without asking -n dry run: show the plan, change nothing -v also list unmatched, ignored and busy files; full match reasons --json with -n: the plan as JSON (format unstable before 1.0) -c FILE use FILE instead of ~/.config/krino/krino.conf --no-color never colour the output, as when NO_COLOR is set -P, --no-pager print the plan straight out, never through the pager --min-age D for this run, every directory's min-age is D (0, 30s, 1d) -h, --help help --version print "krino 0.0.1" ``` Exit status: 0 success, including nothing to do and everything declined; 1 one or more steps failed; 2 usage or config error; 130 interrupted. A config error anywhere stops the whole run before any scanning: krino never acts on a config it only partly understood. Ctrl-C during apply finishes the current step, logs it, and stops. A second krino on the same directory waits for the lock, or fails immediately with `-y` (so a cron job never piles up). ## 12. Architecture One binary, frontends kept thin. Everything the GUI will need lives in the engine and returns data. ``` cmd/krino/ flags, subcommands, exit codes internal/sexp/ reader: syntax tree with positions and byte offsets internal/config/ syntax tree → typed config; validation; embedded template and defaults internal/cond/ condition tree: compile, cost ordering, evaluation, explain trace internal/scan/ walking, busy detection internal/ignore/ gitignore-compatible path matcher internal/extract/ text extraction internal/norm/ text normalisation: case, diacritics, white space internal/dup/ duplicate index internal/plan/ files × rules → chains; placeholders; conflicts; JSON internal/apply/ executing approved steps; cross-filesystem moves internal/trash/ freedesktop.org Trash internal/journal/ log writer and reader, run ids, undo planning internal/engine/ the facade: Load, Check, Plan, Apply, Explain, Runs, PlanUndo, NewDir, Init internal/tui/ terminal rendering, pager, key prompts ``` Rules for the GUI to come: 1. Every operation is an `engine` call that returns data. The CLI only renders it. 2. `Apply` takes a plan plus the set of approved files. The CLI's `a` and `c` and a GUI's checkboxes are the same call. 3. The reader records byte offsets, so a GUI can replace one form's text and leave the rest of the file, comments included, untouched. 4. `Explain` returns the condition tree with each test's value. 5. Log and undo are in the engine. ## 13. Performance - The walk uses `os.ReadDir` and prunes ignored directories. - Files are evaluated by a pool of `GOMAXPROCS` workers. `pdftotext` and the other tools run in parallel within that bound. - Cheap tests first; content only when reached; content at most once. - Keywords are normalised once at load time; matching is `strings.Contains` per keyword on normalised text. Aho-Corasick only if a benchmark shows keyword matching matters next to extraction. - Duplicates: stat-only grouping, partial hash, then full hash. - 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 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. - Keyword cache (§6.1, 0.0.5): measured on the same kind of folder, 170 files, a dry run took 12.78 s with an empty cache and 0.13 s with a warm one, printing the identical plan. Without the cache, 59 `pdftotext` runs took 21.5 s of wall time between them, one PDF 14.7 s alone; with extraction answered instantly, the run took 0.25 s. ## 14. Build, dependencies, release - Go 1.25 or newer (`golang.org/x/text` v0.39 fixed an infinite loop on invalid input, GO-2026-5970, and needs it). `go.mod` pins the toolchain, `go1.26.8`, so builds carry the standard library's security fixes: an older `go` with `GOTOOLCHAIN=auto`, the default, downloads it itself. `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 matcher. - `make` builds; Go downloads the module dependencies itself. It then checks for the optional extractors and prints what is missing. - `make deps` installs the extractors with the system package manager, asking for privileges: `apt-get install poppler-utils catdoc antiword` (Debian, Devuan), `pkg install poppler-utils antiword` (FreeBSD; catdoc is left out because it pulls in tcl/tk), `pkg_add poppler-utils catdoc antiword` (OpenBSD). Plain `make` never asks for privileges. - Targets: `help build install uninstall test vet fmt lint ci bench cross release deps install-hooks`. `install` puts the binary in `$PREFIX/bin` (default `~/.local`), man pages in `$PREFIX/share/man`, examples in `$PREFIX/share/doc/krino/examples`. - `make cross`: linux/amd64, linux/arm64, freebsd/amd64, openbsd/amd64. - `make release VERSION=0.0.1`: runs `ci` and `cross`, writes tarballs and `SHA256SUMS` into `dist/`, and creates the annotated tag **`v0.0.1`** (the `v` is required for `go install ...@v0.0.1`). It never pushes. - Version stamped with `-ldflags -X main.version=...`. - `scripts/leak-check` keeps personal data out of the repository. `make ci` runs it, and so does the pre-commit hook that `make install-hooks` installs. It checks the staged files against built-in patterns (the current user's home directory, email addresses) and against a private pattern list whose path is set with `git config krino.leakpatterns FILE`. The list lives outside the repository, so the secrets it names are never published either. Without it, only the built-in patterns run. - `/local/` is ignored by git: a place for personal configs and test data next to the code. 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). ## 15. Testing - **Unit tests** per package, table-driven. - **sexp:** positions and offsets (every list's offsets point at its own parentheses), error positions, and a fuzz target for the reader. - **gitignore:** an oracle test that builds trees and compares every 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:** 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 available on a different device, and skips otherwise. - **Trash:** with `XDG_DATA_HOME` pointed at a temporary directory. - **Duplicates never deleted:** `krino check` refuses `(duplicate)` with a `delete` action, including inside `or` and `not`; and no duplicate is deleted through `(matched)`, through another rule's own condition, through a duplicate test that evaluation skipped, or with two scopes whose originals differ. Each case plans and applies in a temporary directory and checks that every copy is still on disk. - **CLI:** golden files for plan rendering without colour; exit codes. With colour on, each styled element of §8.2 carries its escape and the columns still line up; `--no-color`, before or after a subcommand, and `NO_COLOR` each give output with no escape byte. - **Parity check before release:** run `krino -n` beside the prototype's dry run over the same real directory, and explain every difference. The rules and the results stay outside the repository. `make ci` is the gate: gofmt, vet, tests, the leak check, man page lint. ### 15.1 Hostile input krino runs on folders the internet writes into, so it treats as hostile every file name, every file's contents, and the output of the tools it runs on them. The configuration is trusted: it is the user's own. The state files (the log, the keyword cache, the Trash) are the user's own too: krino survives their corruption — a truncated line, a damaged cache, a hand-edited trashinfo — but does not defend against another local process with write access to them, which could already do anything krino can. What that means, and the tests that hold it (plan 8): - A name never controls the terminal. Every name, path, reason and warning is printed through `display`, which shows C0 controls, DEL, C1 controls, Unicode bidirectional embeddings, overrides and isolates, and invalid UTF-8 as escapes (`\x1b`, `\u202e`). - A name never redirects a step. A rename whose placeholders produce "", "." or "..", and a destination whose placeholders add a ".." segment, are skipped with a reason. - A file is acted on only while it is still the file that was planned: same size and modification time, still a regular file (not a symlink put in its place), and, at its planned path, the same inode. - A file reaches an external tool only as an absolute path, so a name starting with `-` is never read as an option. - A trash entry name from the log must be a plain name inside the Trash, and a trashinfo `Path` must be absolute. - Every decoder of outside data is fuzzed (`make fuzz`): the config reader, sizes and durations, placeholders, ignore patterns, the log's escaping, trashinfo paths, the keyword cache, markup and zip extraction, and text normalisation. - For generated trees and rules, apply then undo restores every file, and nothing is lost in between (property test, `KRINO_PROPERTY_RUNS`). - Every value of every enum the output, the log or planning depends on is handled: tests read the constants from source. - `make vulncheck` checks the standard library and dependencies against the Go vulnerability database; `make race` runs the tests under the race detector. ## 16. Lessons from the prototype krino replaces a Python prototype that sorted a downloads folder by keyword. Its defects shaped these decisions: | Prototype | krino | |---|---| | filename keywords were substrings, so a short keyword matched inside unrelated words (`"art"` matched `start.pdf`) | name tests are regexes, e.g. `\bart\b` | | `.doc` and `.xls` content was never read (the libraries do not support those formats) | antiword or catdoc, or a warning | | code files were listed as supported, but their content was never read | any text file is read | | the rules lived in the repository beside the code | rules live in `~/.config/krino`, never in the repository (§14 leak check) | | it installed Python packages with `pip --break-system-packages` | a static binary; system packages via `make deps` | ## 17. Decisions and why | Decision | Why | |---|---| | Config in one central place | target directories stay clean; one place to back up; the tool never sorts its own config | | Every matching rule runs, `(stop)` to end | chains like copy-then-move need it; `stop` gives first-match behaviour where wanted | | Tests see the original file | the plan is fully known before approval, and files can be evaluated in parallel | | s-expressions, not TOML, YAML or INI | conditions are the core of the config; in sexp they are structure rather than a string in a second language. No nested quoting (TOML's `'''` around single-quoted regexes), no precedence, actions in written order. YAML turns `*` and `!` in ignore patterns into syntax; INI has no standard escaping | | `pdftotext` rather than a Go PDF library | best text quality of the open tools, fast, packaged on all three systems | | Delete to Trash by default | recoverable, and undo can restore it | | Duplicates are found, never deleted | duplicate conditions with different scopes elect different originals, so deleting duplicates could remove every copy; moving them aside is always recoverable, and choosing which copy to delete belongs to the user or a tool built for it (jdupes). Decided 2026-09-14 after 0.0.1 shipped with the hazard documented | | A new repository rather than the prototype's | the prototype's history holds personal data; the prototype keeps working until krino reaches parity | | Personal configuration never enters the repository | rules hold private data; the leak check enforces it on every commit and in `make ci` | ## 18. Settled before implementation 1. **Hosting: local only for now.** The module path is `krino`. When a remote is chosen, `go mod edit -module ` 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 Go file, shell script and man page carries an `SPDX-License-Identifier: GPL-3.0-or-later` line. ## Appendix A: type groups | Group | Extensions | |---|---| | `image` | jpg jpeg png gif webp bmp tif tiff heic heif avif svg ico raw cr2 nef arw dng | | `video` | mp4 mkv webm mov avi m4v mpg mpeg wmv flv 3gp | | `audio` | mp3 flac ogg opus m4a aac wav wma aiff | | `archive` | zip tar gz tgz bz2 tbz2 xz txz zst 7z rar lz lzma cpio | | `document` | pdf doc docx odt rtf txt md tex | | `spreadsheet` | xls xlsx ods csv tsv | | `presentation` | ppt pptx odp | | `ebook` | epub mobi azw azw3 fb2 djvu | | `code` | go c h cpp hpp py sh js ts rs java rb pl lua html css json yaml yml toml xml sql | | `text` | txt md log csv tsv json yaml yml toml xml ini conf | | `package` | deb rpm apk appimage exe msi flatpak snap | | `font` | ttf otf woff woff2 | Groups overlap; a file can belong to several.