minifind 0.10.1

minimal find reimplementation
Documentation
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```sh
cargo build                   # debug build
cargo build --release         # optimized release build
cargo test                    # run all tests
cargo test <test_name>        # run a single test by name
cargo bench                   # run Criterion benchmarks (benches/)
cargo clippy -- -D warnings   # lint (warnings as errors)
cargo fmt                     # format code
cargo fmt -- --check          # check formatting without modifying
scdoc < man/minifind.1.scd > man/minifind.1   # regenerate the man page
```

The minimum supported Rust version (`rust-version = "1.85.1"`) and `edition = "2021"` are declared in `Cargo.toml`. There is no pinned `rust-toolchain.toml`, so builds use whatever toolchain rustup has active.

The man page is authored in `man/minifind.1.scd` ([scdoc](https://git.sr.ht/~sircmpwn/scdoc) markup) and the generated roff `man/minifind.1` is committed. Its `OPTIONS` section is maintained by hand, so **keep it in sync with `args.rs`'s `HELP` and the README** when adding or changing flags, then regenerate with the command above.

## Code Style

- Line width: **79 characters** (`rustfmt.toml`: `max_width = 79`, `use_small_heuristics = "max"`)
- Always run `cargo fmt` before committing.

## Architecture

`minifind` is a CLI with no async runtime, split into a thin binary (`main.rs`) and a library crate (`lib.rs`) so the pipeline can be integration-tested through its public API. The concurrency model, driven by `lib::run()`, is:

1. **Walker threads** (`walk::walk_parallel`, minifind's own work-stealing walker, `threads - 1` workers) — traverse the filesystem and push matched `walk::Entry` items onto a bounded `crossbeam-channel`. The internal `crossbeam-deque` work queue carries **directory-descent tasks only** (real dirs + followed symlink-dirs); a directory's read loop emits *every* child (files included) to the visitor **inline**, then enqueues a descend task only for the children it will recurse into. So the queue's size is dir-count, not entry-count, and a file's path is allocated once (no per-entry task round-trip / path clone). Each descend `Task` carries its parent directory's fd as `Arc<platform::DirFd>`; children are opened/stat'd *relative to it* (`openat`/`statat`), which bounds path re-resolution, closes the intermediate-component swap TOCTOU window (`O_NOFOLLOW` makes a swapped-in symlink fail closed), and makes the xdev/`DT_UNKNOWN` checks reach through the validated parent. Command-line roots have no parent fd (opened by absolute path) and emit themselves; the shared anchor fd is only ever an `openat`/`statat` lookup target — never iterated (`rustix`'s `Dir::read_from` `openat(".")`s its own fd), so concurrent sibling stats can't perturb a worker's iteration, and refcounting frees each parent fd as soon as its last queued subdir is opened. Each worker runs a per-thread visitor closure (built by a factory passed to `walk_parallel`) that applies every per-entry filter (type → `--name`/`--regex` → metadata) and accumulates survivors in a per-thread `BatchSender` (`lib.rs`), sending them in batches of `BATCH_SIZE` (256, tuned by sweeping a warm Linux-kernel tree); the trailing partial batch is flushed on `Drop` when the visitor closure ends. Batching amortizes the per-send atomic synchronization (~21% faster wall, ~83% fewer context switches on a Linux-kernel tree) and removes the prior throughput regression past the core count. `--threads` defaults to `available_parallelism()` and is validated to `2..=65535` in `args.rs::parse_threads`; the floor of 2 exists because one thread is always reserved for output (`threads - 1` must be ≥ 1). `main.rs` warns (but still honors) when `--threads` exceeds `available_parallelism()` via `args::oversubscription_warning`.
2. **Output thread** (1 dedicated thread) — a pure writer: drains batches and writes the already-filtered paths to a 256 KB `BufWriter` over a caller-supplied sink (locked stdout in the binary), enforcing `--max-results`. The path bytes and terminator are written straight into the `BufWriter` (which already coalesces) — no per-entry scratch buffer and one byte-copy per entry. *All* per-entry filtering (type, `--name`/`--regex`, the metadata predicates) runs upstream in the walker-thread visitor, cheapest-first, so rejected entries never cross the channel and an entry is `statx`'d only after the cheaper filters have kept it.

`run()` takes a `make_out: FnOnce() -> impl Write` factory rather than a writer, so the sink is constructed *on the output thread* — this lets the binary hand over a non-`Send` `StdoutLock` while tests inject an in-memory sink.

### Module responsibilities

| Module | Role |
|---|---|
| `main.rs` | Thin binary entry point: declares the `mimalloc` global allocator, parses args, warns on thread oversubscription, resets `SIGPIPE`, raises `RLIMIT_NOFILE` (`raise_nofile_limit`, Unix), calls `minifind::run()` with `\|\| io::stdout().lock()` |
| `lib.rs` | `run()` — wires everything together: register signals → build glob/regex sets → build channel + walker → spawn output thread → run walker → join. Also defines `BatchSender` (per-thread batched channel send, flush-on-`Drop`) and `raise_nofile_limit()` (Unix; soft→hard `RLIMIT_NOFILE`). Re-exports all modules as `pub` |
| `args.rs` | `lexopt`-based parser (`parse_inner` core + `Args::parse()` wrapper); validates thread count, normalizes input paths via `normpath`, exposes `oversubscription_warning()`. A pre-parse pass rewrites find-style single-dash multi-char tokens (`-name`, `-type`, `-maxdepth`, `-regex`, `-iname`, `-iregex`, `-empty`, `-xdev`/`-mount`, `-follow`, `-print0`, `-mindepth`, `-size`/`-mtime`/…, `-links`, `-inum`, `-newer`/`-anewer`/`-cnewer`, `-path`/`-wholename`/`-ipath`/`-iwholename`, `-lname`/`-ilname`, `-readable`/`-writable`/`-executable`, `-nouser`/`-nogroup`, `-quit`, `-print`, `-ignore_readdir_race`) to their canonical `--long` forms, since `lexopt` would otherwise split them into single chars |
| `walk.rs` | The custom parallel walker: `walk_parallel()` drives a `crossbeam-deque` work-stealing engine (atomic-counter termination) over `Args`, whose queue holds **descend-only `Task`s** each carrying `Option<Arc<platform::DirFd>>` (parent anchor) + a `follow` flag; emits `Entry { path, file_type, depth }` inline through a per-thread visitor (`descends_into` decides what's enqueued); handles `max_depth`, `one_filesystem`, `follow_symlinks` + symlink-loop detection, multi-root, and root emit-self |
| `walk/unix.rs` | `#[cfg(unix)]` leaf I/O: `rustix` `getdents`/`openat`/`fstat`/`statat`. `DirFd = OwnedFd`; `open_root` (absolute) vs `open_child` (relative `openat` on the parent fd, with an `EMFILE`/`ENFILE` → absolute-open backstop counted in `ABS_FALLBACK_COUNT` and a `#[cfg(test)]` force seam); `for_each_entry` resolves `DT_UNKNOWN` via `statat` *relative to the dir fd* (and yields each leaf name) and stops early on a `false` callback; `stat_at`/`stat_root` fetch metadata for the predicates — `statx` with a minimal field mask on Linux, `statat` (full `stat`) on other Unix |
| `walk/fallback.rs` | `#[cfg(not(unix))]` leaf I/O via `std::fs`; `DirFd = PathBuf` (no fd to anchor — `open_child` ignores the parent and opens by absolute path); `dev=0` (so `--one-filesystem` is a no-op) and `ino`=canonical-path hash for loop detection |
| `filetype.rs` | `EntryType` enum + `FileType` bitmask; `ignore_filetype(EntryType, &Path)` called **inside walker threads** for early rejection |
| `glob.rs` | `build_glob_set()` — builds a `globset::GlobSet`; matching happens in the walker-thread visitor against `file_name()` only |
| `regex.rs` | `build_regex_set()` — builds a `regex::bytes::RegexSet`; matching happens in the walker-thread visitor against the full path as bytes |
| `meta.rs` | Metadata predicates (`-size`/`-mtime`/`-ctime`/`-atime` + `-mmin`/`-cmin`/`-amin`/`-perm`/`-uid`/`-gid`/`-user`/`-group`/`-links`/`-inum`/`-newer`/`-anewer`/`-cnewer`/`-nouser`/`-nogroup`): parsing (find tri-state `+N`/`-N`/`N`, octal+symbolic perm), `-user`/`-group` → numeric id via reentrant NSS (`getpwnam_r`/`getgrnam_r`) at parse, `-newer*` → reference-file mtime at parse, `Predicates::{is_active,mask,matches}` over a platform-filled `Meta`, plus `NssCache` (per-thread `getpwuid_r`/`getgrgid_r` memo for `-nouser`/`-nogroup`) and the `access` bits for `-readable`/`-writable`/`-executable` |
| `interrupt.rs` | Registers Unix/Windows signals via `signal-hook`; sets an `Arc<AtomicBool>` flag checked in the walker closure |
| `ratelimit.rs` | `Limiter` wrapping `governor` — a synchronous, shutdown-aware token bucket (`acquire` loops `check()` + capped sleep); throttles one token per directory in `walk::descend` |
| `sched.rs` | `#[cfg(target_os = "linux")]` `--idle` scheduling: `set_idle_cpu()` (`sched_setscheduler(0, SCHED_IDLE)`) and `set_idle_io()` (`ioprio_set` → `IOPRIO_CLASS_IDLE`, via the raw syscall since libc has no wrapper) — **both called per worker thread** in `run()`'s visitor factory — and `lower_nice()` (`setpriority(PRIO_PROCESS, 0, 19)`, called once in `main.rs` before workers spawn so they inherit it) |

### Key design decisions

- **`mimalloc`** is the global allocator (`#[global_allocator]`) for improved allocation throughput.
- **`--name` and `--regex` are mutually exclusive** (enforced after the `lexopt` parse loop); `--name` matches only the filename component while `--regex` matches the full path. The three former clap `ArgAction::Set` bool options (`--follow-symlinks`, `--one-filesystem`, `--case-insensitive`) are now bare flags; `--one-filesystem` defaults on and is cleared via `--no-one-filesystem`/`--cross-filesystem`.
- The walker is minifind's own (no `ignore` dependency): a `crossbeam-deque` work-stealing engine in `walk.rs` over a `cfg`-split leaf (`rustix` `getdents` on Unix, `std::fs` elsewhere). `ignore` remains only as a dev-dependency for the `benches/filetype.rs` comparison.
- Entry type comes from the directory `d_type` (no per-entry `stat`); `filetype.rs` classifies on the platform-agnostic `EntryType` enum. Device/pipe/socket types are only distinguished on Unix (the `std::fs` fallback collapses non-dir/non-symlink to `File`). `DT_UNKNOWN` is resolved with a `statat` **relative to the directory fd** (in `walk/unix.rs::for_each_entry`), not an absolute-path `lstat` — cheaper and TOCTOU-consistent with the anchored open; an entry whose type still can't be resolved is skipped.
- **fd-anchored descent.** Subdirectories are opened with `openat(parent_fd, leaf, O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC)`; the parent fd rides the descend `Task` as `Arc<DirFd>`. This keeps the live-fd frontier near O(workers × depth) (the LIFO local deque biases depth-first, refcounting frees a parent fd once its last queued subdir is opened). `main.rs` raises the soft `RLIMIT_NOFILE` to the hard limit at startup (`raise_nofile_limit`, Unix, best-effort) for headroom; if an open still hits `EMFILE`/`ENFILE` it falls back to an absolute-path open (same fd cost, weaker TOCTOU, never a silent skip), tallied in `ABS_FALLBACK_COUNT`. There is **no** explicit LRU fd cache — the `Arc` lifetime releases fds promptly; add one only if profiling shows the fallback firing under realistic limits.
- Platform path output: Unix writes raw `OsStr` bytes (`OsStrExt::as_bytes`); non-Unix uses `Path::to_string_lossy().as_bytes()`. The same split lives in `regex::path_to_bytes`, which returns a `Cow<[u8]>` (borrowed on Unix, owned-on-lossy elsewhere) for full-path regex matching.
- **`-0`/`--null`** terminates each printed path with a NUL (`b'\0'`) instead of a newline, for piping into `xargs -0` and friends. The output thread picks the terminator byte once (`separator` in `lib.rs::run`) and writes it after each path. find(1)'s `-print0` spelling is a single multi-char short token that `lexopt` would split into `-p -r -i …`, so `args::parse_inner` rewrites the literal `-print0` to `--null` before parsing; `--print0` (fd-style) and `-0` are matched directly.
- **`--max-results <N>`** (`None`/`0` = unlimited) caps emitted results, for "does anything match" probes. Enforced in the **output thread** — the sole writer and the only place the post-`--name`/`--regex` match count is known — which breaks after writing the N-th result; returning drops `rx`, closing the channel, which walkers observe as `WalkState::Quit` (the existing early-stop path). Printed output is therefore **exactly `min(N, total_matches)`**; the only fuzz is bounded *extra walker traversal* (workers in flight keep reading until the channel-close propagates on their next send) — wasted work, never extra output.
- **`--min-depth <N>`** (find-style `-mindepth` alias, normalized like `-print0`) suppresses entries shallower than `N` (root = depth 0). Unlike `--max-depth` — a *traversal* control enforced in `walk::descend` (stops descent) — `--min-depth` is an *emission* gate enforced in the visitor (`lib.rs`): `Entry` carries its `depth`, and `entry.depth < min_depth` returns `Continue` (suppress, but keep descending), so deeper matches under a suppressed shallow level are still reached. Combine `--min-depth N --max-depth N` for an exact-depth slice.
- **Metadata predicates** (`-size`/`-mtime`/`-ctime`/`-atime`, `-mmin`/`-cmin`/`-amin`, `-perm`, `-uid`/`-gid`, `-user`/`-group`, `-links`, `-inum`, `-newer`/`-anewer`/`-cnewer`, `-nouser`/`-nogroup` — find-style single-dash tokens normalized to `--` long forms like `-print0`) live in `meta.rs` and are evaluated **last** in the walker-thread visitor, so the `stat` is paid lazily: only when `Predicates::is_active()`, and only for entries that already survived type + `--name`/`--regex` + `--path`. The `statx` is leaf-relative on the parent dir fd (`StatAt` in `walk.rs` → `platform::stat_at`) with a field mask (`Predicates::mask()`) covering only the active predicates (`STATX_SIZE`/`STATX_MTIME`/`STATX_NLINK`/…); other Unix falls back to a full `statat`. `-size` **requires a unit suffix** (`c`/`k`/`M`/`G`/`T`, 1024-based; no bare 512-byte blocks) and rounds up; time predicates use find's integer-division day/minute semantics against a `now` captured once at run start; `-newer`/`-anewer`/`-cnewer` compare the entry's m/a/c-time against the reference file's mtime (stat'd once at parse); `-perm` is find's `/` (any) `-` (all) exact tri-state over octal **or symbolic** (`u+w,g-x`) modes; `-uid`/`-gid`/`-links`/`-inum` share one numeric `IdPred`; `-user`/`-group` resolve once at arg-parse to a numeric id (reentrant `getpwnam_r`/`getgrnam_r`, numeric fallback). `-nouser`/`-nogroup` need a *reverse* lookup, so they're evaluated in the visitor (not `matches()`) against a per-thread `NssCache` (`getpwuid_r`/`getgrgid_r`, memoized by id). The mode/owner/link/inode predicates are Unix-only (the `std::fs` fallback has no such metadata). Unstattable entries are skipped, like find.
- **`-readable`/`-writable`/`-executable`** are a `faccessat` (real uid/gid, no `AT_EACCESS` — matching find's `access(2)`) via `StatAt::access`, evaluated in the visitor after the `statx` tier; Unix-only. **`-path`/`-wholename`** (`-ipath`/`-iwholename` add case-insensitivity) glob the **full path** — reusing `glob.rs` but matched against `entry.path`, where globset's default lets `*` cross `/` (find's `-path` semantics); the `--name` basename glob never sees a separator, so it's unaffected. **`-lname`** (`-ilname`) globs a symlink's target via `StatAt::readlink` (`readlinkat`), only for symlinks. **`-quit`** is `--max-results 1`; **`-print`** and `-ignore_readdir_race`/`-noignore_readdir_race` are accepted no-ops (minifind always prints and already skips readdir races).
- **`-E`/`--exclude <GLOB>`** (repeatable) is matched in the **walker** (`walk::descend`'s child loop, against the file name like `--name`), *not* the output thread where `--name`/`--regex` run — deliberately, so a matched **directory is pruned before its `opendir`**: its descend task is never enqueued, saving the `openat`/`getdents`/task churn for the whole subtree (verified via `strace`). A matched non-directory is simply not emitted. Roots are seeded directly and never pass through this check, so an explicit starting path is always kept. The `GlobSet` is built in `run()` (so an invalid glob errors before walking, exit-1 like a bad `--name`/`--regex`) and passed to `walk_parallel` as `Option<&GlobSet>` alongside the limiter.
- Channel buffer size is `CHAN_MULT * (threads - 1)` **batches** (`CHAN_MULT` = 4, each up to `BATCH_SIZE` = 256 entries); entries cross the channel in batches, not individually, to cut synchronization overhead. Both constants were tuned by sweeping a warm Linux-kernel tree (throughput is flat across `CHAN_MULT` 2–16; batch size flattens by ~256).
- The `--empty` (`-t e`) type implicitly enables both file and directory matching unless another type flag is also set.
- **`-s`/`--max-scan-rate <N>`** caps directories scanned per second globally across walker threads via `ratelimit::Limiter` (a `governor` token bucket); the single throttle point is in `walk::descend` before the `open_root`/`open_child` call, so each directory scanned spends one token. It throttles traversal rate, not literal device IOPS — one directory is several syscalls and cached reads cost no physical I/O. `None`/`0` means unlimited (no limiter is built, so the hot path is unchanged). A throttled worker rechecks the walker's abort flag in ≤100 ms chunks rather than blocking on a full directory's I/O; since that flag is only set once a worker next reaches the visitor, the stop can lag by up to roughly one token interval (≈1 s at `--max-scan-rate 1`).
- **`--idle`** (Linux-only; rejected as an unknown option elsewhere via `#[cfg(target_os = "linux")]` on the parse arm) makes a walk yield to everything else. Three mechanisms: (1) the **CPU class** — `sched.rs::set_idle_cpu()` (`sched_setscheduler(SCHED_IDLE)`) is applied **per worker thread**, inside `run()`'s visitor factory closure (which executes on each spawned walker), *not* whole-process — so the main and output threads stay `SCHED_OTHER` and keep draining results while the heavy walker pool runs only when the CPU is idle; (2) the **I/O class** — `sched.rs::set_idle_io()` (`ioprio_set` → `IOPRIO_CLASS_IDLE`) is applied **per worker thread** too (I/O priority is per-task in Linux, and the workers issue all the `openat`/`getdents`/`statx` disk I/O; the output thread only writes stdout), via the raw syscall since libc exposes no wrapper; (3) the **nice value** — `sched.rs::lower_nice()` (`setpriority(PRIO_PROCESS, 0, 19)`) is called once in `main.rs` before `run()`, process-wide (workers inherit it). `--idle` also flips the `--threads` default from the CPU count to **2** (resolved in `args.rs` via a `threads: Option<usize>` that stays `None` until an explicit `--threads`, so an explicit value always wins). All three syscalls are best-effort: `set_idle_cpu`/`set_idle_io` failures are ignored per worker (the walk proceeds at normal priority), `lower_nice` failure prints a warning. De-escalation needs no privilege, so none normally fails. Verified via `strace -f`: one `setpriority` + exactly `threads - 1` `sched_setscheduler(…, SCHED_IDLE, …)` and `threads - 1` `ioprio_set(…, IOPRIO_CLASS_IDLE, …)` calls.
- `Cargo.toml` declares a `[lints.clippy]` table (`all = deny`, `redundant_clone = deny`), so these are enforced on every `cargo clippy`/`build`, not just via CLI flags.

### Testing

- **Unit tests** live in each module's `#[cfg(test)] mod tests` and exercise private helpers. The `walk/unix.rs` absolute-open fallback is tested via a `#[cfg(test)]` seam (`set_force_abs_fallback` + `ABS_FALLBACK_COUNT`): one test asserts a healthy walk takes *zero* fallbacks (every open is anchored), another forces the fallback for every open and asserts the emitted set is unchanged. A separate test hammers `statat` through a shared dir fd while iterating it to lock the "anchor is never read" invariant. These seam tests share a `Mutex` since the toggle/counter are process-global.
- **Integration tests** in `tests/pipeline.rs` drive the real `minifind::run()` end-to-end, capturing output via a shared in-memory `Write` sink rather than re-implementing the pipeline.
- **Doc-tests** run because of the library crate (binary crates skip them); e.g. `interrupt::setup_interrupt_handler`'s example is a live, compiled test.
- **Benchmarks** (Criterion, `harness = false`):
  - `benches/filetype.rs` compares the bitmask `ignore_filetype` against the historical seven-term `||` chain over pre-collected `DirEntry`s. The selection is made runtime-opaque with `black_box` *outside* the timed loop on both sides so the comparison is fair (hardcoded flags would let LLVM fold the chain away).
  - `benches/walk.rs` is an end-to-end `minifind` vs GNU `find` comparison over a shallow Linux-kernel clone, mirroring the [bench_walk](https://github.com/dkorunic/bench_walk) methodology. Both contenders run as subprocesses (minifind via `CARGO_BIN_EXE_minifind`) so each pays process startup. The clone is cached under `benches/linux_root/` (gitignored); set `BENCH_WALK_DIR` to reuse an existing checkout. It uses bench_walk's long 80 s / 400 s windows — override with `cargo bench --bench walk -- --warm-up-time <s> --measurement-time <s>` for a quick run.

### Release profile

`Cargo.toml` [profile.release] uses fat LTO, `codegen-units = 1`, `strip = "symbols"`, and `panic = "abort"` — intentional for a single-purpose CLI binary where binary size and throughput matter more than debug ergonomics.