# krino design The design of krino's engine and command line, up to date for 0.0.9. The window has a document of its own, `gui-design.md`; what changed in each release is in `CHANGELOG.md`. Source comments cite the sections here by number, so the numbering is stable. 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"; `0` means unlimited | 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. An exclude fails closed: when its value depends on a content test that cannot read the file (over `max-read`, a tool missing, failing or timing out, or a document read only in part), the exclude holds, and the file is set aside as "(content unreadable)" with the warning (§5.4 rule 4). One that is false whatever the text holds, such as `(exclude (content "x") (type txt))` on a pdf, does not. A file whose format has no text at all (an image, an archive) is not unreadable: it contains no keyword, so its content tests are simply false and raise no warning. So is a file of no known extension that starts as text and holds binary data further on, such as a self-extracting installer. An exclude protects files, so a file krino could not check is left alone. Mistakes in krino.conf's excludes are reported even while no directory is included. 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, ẞ included) to ASCII. Invalid UTF-8 is replaced by U+FFFD before folding. ### 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 **unknown** and the plan shows a warning naming the rule that wanted it. A document read only in part (an archive entry that would not open) answers a keyword found in what was read; one not found is unknown. `and`, `or` and `not` combine unknowns by three-valued (Kleene) logic: `and` is false if any argument is false, `or` true if any is true, `not` of unknown is unknown, so an unknown only reaches the top where the text could change the answer. A rule matches only when its condition is true; an unknown exclude holds (§4.6). While no earlier rule has matched a file but one could not be decided, `(matched)` is unknown too, so a catch-all `(not (matched))` does not take the file, and a rule with `(stop)` whose condition is unknown ends the search for the file. A `duplicate` test whose lookup fails is unknown in the same way (an exclude holds, marked "(duplicate check failed)"). `explain` shows an unknown test as `?` and an undecided rule as "undecided". A format with no text (§6, "anything else") is not a failure: `content` is false, silently. ### 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 is elected once for all identical content, so a scanned file whose hardlink under a DIR is elected is that original, and not a duplicate, even when the DIR also holds a separate copy. 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; any other file whose first 8 KiB start with a UTF-16 BOM, or are valid UTF-8 with no NUL byte and so is the rest of it (one that turns binary further on, like a self-extracting installer, has no content) | | 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, modification time and extension (the extension picks the extractor), which a move within one filesystem keeps. The keywords themselves are stored, normalised the way the test compares them (case, folding, whitespace), and only those the directory still uses. - 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 its fingerprint changes: a tool installed, removed or replaced; krino's extraction code (`extract.Version`) or normalisation (`norm.Version`) changing; the Go release or the Unicode tables changing; or the directory's `max-read` changing. - 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 made 0700, the file 0600. `explain` reads it and never writes. A directory with no content tests has no cache, and an old one is removed. - Known gaps: a file edited in place with its size and modification time deliberately preserved keeps its old answers, and so does a new file that reuses a deleted file's inode with the same size, extension and preserved modification time. A `.doc` that `antiword` failed on (a timeout, say) and `catdoc` then read is cached with `catdoc`'s 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. It also rejects any placeholder that could never expand: an unknown name, `{mtime}` or `{now}` without a format, an unknown format code, `{0}`, `{10}` and up, an unclosed `{`. With `fold` on, a `name` test matches the folded name, but its captures are the original name's characters: `(name "^(.+)-faktura")` on `Łódź-faktura.pdf` makes `{1}` `Łódź`, not `Lodz`. A capture that ends inside a letter folding to two (`ß` to `ss`) takes the whole letter. ### 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. A real run applies each directory before planning the next, so a later directory sees the earlier one's result on disk; of its claims only the paths its files ended up at (each copy, and a moved or renamed file's last place) carry over, so a later `overwrite` never displaces this run's own result. A dry run (`-n`) applies nothing: its claim set spans every directory, so two configured directories cannot show the same final name, but each directory is otherwise planned as if no earlier one had been applied (a file the first moves into the second is not in the second's plan). 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/` (`{{` and `}}` are literal text there). `-v` lists each such destination that exists, under "not scanned". 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. What closes it is 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)` and `(partly 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, and `(undone)` once undo has reversed every reversible step of a run, or `(partly undone)` while some are not (declined, refused or failed). A file whose chain ends in a permanent delete is never undoable and not counted. - `krino undo` reverses the most recent run, in every directory it touched. An older run is undone by naming it: `krino undo RUN`. Undo runs cannot themselves be undone: naming one is refused. - A run is undone as far as it can be. Reversals an earlier undo of the same run completed are not offered again, so an undo that stopped part way (a refused or failed step, an interrupt) is finished by undoing the run once more; when the most recent run is itself an undo, plain `krino undo` does exactly that for the run it undid. - A damaged log line (a crash or a full disk cutting it) refuses only the file it names, when that file can still be read from the line; the rest of the run is reversed. A line of another run is ignored. Only a line whose file cannot be read refuses the whole run. - A file is identified by its directory and its path within it, so two directories' files of the same name are reversed apart. A file that one directory's rules move into another included directory, which sorts it again in the same run, is two files to undo, reversed in the order the run touched them; the first may then be refused as missing (known limitation). - 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, is not the file this run trashed (size/mtime), or its `.trashinfo` now records another original path; 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. Known limitation: a reversal recreates the directories it needs to move a file back and removes them again only when the file's reversal completes. An undo interrupted or failed after recreating one, and never finished for that file, leaves it behind, empty. ## 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 no file (krino's empty state directory may be created, the keyword cache refreshed) -v also list unmatched, ignored and busy files, and destinations not scanned; full match reasons --json with -n: the plan as JSON (format unstable before 1.0); invalid UTF-8 in a name becomes U+FFFD -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); sorting and explain only; a future modification time counts as age 0 -h, --help help --version print "krino VERSION" ``` 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 (SIGTERM, or SIGHUP unless started under `nohup`) during apply finishes the current step, logs it, skips the rest of that file's chain, and stops; during an undo it finishes reversing the file it is on, then stops. A second interrupt exits at once: the step in flight is not logged, and a copy in progress can leave a temporary `.krino-*` file and an empty directory behind. 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`, Go's default, downloads it itself. OpenBSD's `go` package sets `GOTOOLCHAIN=local` in its `go.env`, so there build with `GOTOOLCHAIN=auto make`; FreeBSD's keeps `auto`. `make ci` passes on OpenBSD 7.9 and FreeBSD 15.0 with that toolchain. `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`, character widths). Everything else is the standard library, including the s-expression reader and the gitignore matcher. `make ci` refuses any other module, tests included, for Linux, FreeBSD and OpenBSD alike. - `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=...`, without the tag's `v`; every target that uses `VERSION` refuses one holding anything but letters, digits and `. - _ +`. `make uninstall` removes krino's own files and `share/doc/krino`, never the shared `bin` and `man` directories. - `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:** most tests generate their fixtures in temporary directories and use fake tools for PDF and the legacy formats (doc, xls, ppt). `testdata/real` holds documents LibreOffice and pandoc made from invented text (docx, odt, ods, xlsx, pptx, epub, pdf, doc, xls); each is read, the tool formats only when a tool for them is installed. There is no real `.ppt` or `.odp` fixture (LibreOffice could not convert pandoc's pptx), and xls/ppt have only run through fake tools where `catdoc` is not installed. antiword refuses a `.doc` with very little text; catdoc reads it. - **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 (plans 8 and 9): - A name never controls the terminal. Everything printed from a name, path, reason, warning, error, explain trace, JSON plan or log field is escaped: C0 controls, DEL, C1 controls, every Unicode bidirectional control (embeddings, overrides, isolates and the marks), line and paragraph separators, and invalid UTF-8 (`\x1b`, `\u202e`). All of stderr goes through the same escaping. Invisible format characters that are not controls are printed as they are (known limitation). - A name never redirects a step. A destination with a placeholder must resolve at or under the directory its text names before the first placeholder, so a capture of "..", "~" or nothing cannot move it elsewhere; a rename whose placeholders produce "", "." or ".." is 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 step that had to land at a free name stops the rest of its chain. Overwrite trashes only a regular file, never a directory or another file of the same plan. - Every step is logged as soon as it has run, so a run killed mid-chain can be undone as far as it got. - Undo restores a trash entry only while it is still the file the run put there (size, mtime, recorded path), re-checks every file at execution time, and can finish an undo that stopped part way. - An exclude whose value depends on a content test that cannot read the file holds (fails closed); a rule whose value does is not matched, so `(not (content "x"))` never acts on a file krino could not read. - 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`). - For generated trees and rules, apply then undo also restores every directory, and the test fails if too few cases apply anything. - Every value of the enums planning, the log and the summary depend on (`plan.Kind`, `config.ActionKind`, `config.Conflict`, `scan.Reason`) 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 | a history that can be published as it stands; 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: `git.labunix.xyz/krino`,** public over cgit and clonable without an account. That is also the module path. cgit serves no `go-import` meta tag, so nginx answers a `?go-get=1` request for `/NAME` with one naming `https://git.labunix.xyz/NAME.git`; without it `go install` would need the path to carry a `.git` suffix. 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.