Skip to content

chore: rustfmt + clippy, enforced in CI - #150

Merged
navbytes merged 3 commits into
mainfrom
chore/lint-and-format
Aug 21, 2026
Merged

chore: rustfmt + clippy, enforced in CI#150
navbytes merged 3 commits into
mainfrom
chore/lint-and-format

Conversation

@navbytes

Copy link
Copy Markdown
Owner

Two commits, deliberately separate so the mechanical one can be read as
mechanical:

  1. chore: rustfmt and clippy, configured to the style the tree already had
    rustfmt.toml, Cargo.toml's [lints] tables, a CI lint job,
    .git-blame-ignore-revs, and the 43 lint fixes the new rules turned up.
  2. chore: apply rustfmtcargo fmt and nothing else. 52 files,
    +1991/-1299.

rustfmt, tuned rather than imposed

roost was hand-formatted for its whole life. The config was chosen by
measuring the churn of each candidate against the existing tree, not by
taste:

config hunks rewritten
max_width = 100 1,632
max_width = 100 + use_small_heuristics = "Max" 556
max_width = 110 + use_small_heuristics = "Max" 773

So the tree was already written to ~100 columns with everything that fits
kept on one line. rustfmt.toml is that finding written down. Stable
options only — this crate pins rust-version = "1.89" and CI runs stable,
where a nightly-only key is silently ignored, which is worse than no rule
because it reads like one.

Lints in Cargo.toml, not only in CI

[lints.rust] / [lints.clippy] apply to every local cargo check, so a
rule is visible in the editor the moment it is broken rather than twenty
minutes later on a runner. -D warnings in CI is what makes them binding.

Every lint enabled was verified to already hold. Three candidates were
measured and rejected, which is most of the value here:

  • unreachable_pub — 582 hits. roost is a binary, so every pub is
    unreachable by that lint's definition, and almost all of them are in
    tests/harness/mod.rs where pub is how a shared test module offers
    anything at all. A lint that is right 0% of the time trains people to
    ignore the ones that are right.
  • clippy::str_to_string — 184 hits, all asking "x".to_string() to
    become .to_owned(). Same allocation, same meaning, 184-line diff.
  • trivial_casts — fires only on the FFI reference-to-pointer casts
    that make those call sites read like the C prototypes they are calling.
    Its numeric sibling has no such false-positive class, so that half stays.

What the survivors found

  • clippy::undocumented_unsafe_blocks: 10 unsafe blocks with no
    SAFETY: comment.
    Every other one in the tree has one — this was a
    convention and it had lapsed in ten places (kill(2) in the pane sweep,
    geteuid, two mem::zeroed FFI structs, localtime_r, and the counting
    allocator in tests/scrollback_memory.rs). Now a rule, and the ten are
    written.
  • elided_lifetimes_in_paths (via rust_2018_idioms): 32 sites, all
    &mut Frame / Vec<Line> / Vec<Span> in the renderer.
  • trivial_numeric_casts: one 1 as PaneId where PaneId is u64.

Verification

1,047 tests and a clean clippy against the reformatted tree. Not a
formality: three of roost's gates read its own source text rather than its
behaviour — theme.rs's §2 colour ban, C34's chord-literal ban, and
srcscan::production's test-module cut — and all three depend on where a
line ends and which trailing comment sits on it (// chrome-gate-exempt is
load-bearing punctuation). A reformat is exactly the change that could have
moved a marker off its line. It did not.

Two follow-ups for the maintainer

  • The lint job is not in main's required checks (those are the two
    matrix jobs). Add it in repo settings to make it blocking.
  • .git-blame-ignore-revs needs the reformat's post-squash hash, which
    cannot be known before merge. I'll push that one-liner immediately after
    this lands.

vendor/vt100 is untouched throughout — it is a path dependency, not a
workspace member, so neither tool reaches it. Deliberate: reformatting
vendored code would bury roost's own patches the next time it is diffed
against upstream. CLAUDE.md's old "never run cargo fmt here" rule is
reversed in commit 1, since following it would now fail CI.

roost was hand-formatted for its whole life and enforced nothing
mechanically, so both were one careless commit away from drifting. This
adds the two tools, tuned rather than imposed, and a CI job that makes
them binding.

**rustfmt.** The naive `max_width = 100` rewrites 1,632 hunks; adding
`use_small_heuristics = "Max"` cuts that to 556, and `max_width = 110`
is worse again at 773. So the tree was already written to ~100 columns
with everything that fits kept on one line — `rustfmt.toml` is that
finding written down, not a new house style. Stable options only: this
crate pins `rust-version = "1.89"` and CI runs stable, where a
nightly-only key is *silently ignored*, which is worse than no rule
because it reads like one. The reformat itself is the next commit, and
is listed in `.git-blame-ignore-revs`.

**Lints in `Cargo.toml`, not only in CI.** `[lints.rust]`/`[lints.clippy]`
apply to every local `cargo check`, so a rule is visible in the editor
the moment it is broken rather than twenty minutes later on a runner.
Every lint enabled was verified to already hold — this pins discipline
the codebase has, it does not schedule a cleanup.

Three candidates were measured and rejected, which is most of the value
here:

- `unreachable_pub` — 582 hits. roost is a *binary*, so every `pub` is
  unreachable by definition, and almost all of them are in
  `tests/harness/mod.rs` where `pub` is how a shared test module offers
  anything at all. A lint that is right 0% of the time trains people to
  ignore the ones that are right.
- `clippy::str_to_string` — 184 hits, all asking `"x".to_string()` to
  become `.to_owned()`. Same allocation, same meaning, 184-line diff.
- `trivial_casts` — fires only on the FFI reference-to-pointer casts
  (`&mut info as *mut _ as *mut c_void`, a fn item to a fn pointer for
  `libc::signal`) that make those call sites read like the C prototypes
  they are calling. Its numeric sibling has no such false-positive
  class, so that half stays.

What the survivors found, all fixed here:

- **`clippy::undocumented_unsafe_blocks`: 10 unsafe blocks with no
  `SAFETY:` comment.** Every other one in the tree has one — this was a
  convention, and it had lapsed in ten places (`kill(2)` in the pane
  sweep, `geteuid`, two `mem::zeroed` FFI structs, `localtime_r`, and
  the counting allocator in `tests/scrollback_memory.rs`). Now a rule,
  and the ten are written.
- `elided_lifetimes_in_paths` (via `rust_2018_idioms`): 32 sites, all
  `&mut Frame` / `Vec<Line>` / `Vec<Span>` in the renderer, now spelled
  `<'_>` so the borrow is visible.
- `trivial_numeric_casts`: one `1 as PaneId` where `PaneId` is `u64`.

**CI** gets a `lint` job — once, on ubuntu, not inside the OS matrix:
formatting and lints are properties of the source, not of the platform,
and a run that will fail `cargo fmt --check` should say so in twenty
seconds rather than after two full test matrices. It is deliberately not
added to `main`'s required checks by this commit; that is a repo-settings
change for whoever administers branch protection.

`vendor/vt100` is untouched throughout: it is a path dependency rather
than a workspace member, so neither tool reaches it. That is the intent —
reformatting vendored code would bury roost's own patches to it the next
time it is diffed against upstream.

CLAUDE.md's "roost is NOT rustfmt-formatted — never run `cargo fmt`"
rule is reversed in the same commit, since following it would now fail
CI.
Mechanical. `cargo fmt` with the `rustfmt.toml` added in the previous
commit, and nothing else — no logic, no renames, no comment rewrites.
Verified by re-running the whole suite (1,047 tests) and clippy (clean)
against the reformatted tree.

That verification is not a formality here. Three of roost's gates read
its own source text rather than its behaviour — theme.rs's §2 colour
ban, C34's chord-literal ban, and `srcscan::production`'s test-module
cut — and all three depend on where a line ends and which trailing
comment sits on it (`// chrome-gate-exempt: program output` is
load-bearing punctuation). A reformat is exactly the change that could
have moved a marker off its line. It did not.

Add this commit's *post-squash* hash to `.git-blame-ignore-revs` once it
lands, then `git config blame.ignoreRevsFile .git-blame-ignore-revs`
locally; GitHub reads the file on its own.
The first CI run of the new `lint` job failed, and the failure is the
argument for this commit: four findings that do not exist in the clippy
this branch was developed against (0.1.89 — a drifted local `stable`),
so they were unreproducible locally and would have been equally
unreproducible for anyone whose toolchain sat still.

`-D warnings` against a moving `stable` means a clippy release can turn
a green branch red with no code change. So the lint job pins its
toolchain, and **only** the lint job: a repo-wide `rust-toolchain.toml`
would also pin what `cargo build`, the test matrix and — worst —
`release.yml`'s four-target build compile with, and the release path is
the one thing here that cannot be rehearsed. Lint determinism is worth a
pin; build determinism is not worth that risk.

The four, all real:

- `clippy::sort_by_key` x2 — `sort_by(|a, b| b.0.cmp(&a.0))` is a
  hand-rolled reverse sort; `sort_by_key(|(mtime, _)| Reverse(*mtime))`
  says "newest first" in the code instead of in the comment beside it.
- `clippy::needless_return` x2, **Linux only** — `infra::qos`'s two
  promote functions guard with `if !enabled() { return; }` and then run
  a `#[cfg(target_os = "macos")]` block. Off macOS that block vanishes
  and the whole body is a bare `return;`. Both are now gated as a whole
  (`#[cfg(target_os = "macos")] if enabled()`), which is the same
  condition — `enabled()` is `cfg!(macos) && ...` — spelled where it can
  be read.

Verified against the pinned toolchain on both of CI's platforms:
`cargo +1.96.1 clippy --all-targets` clean on aarch64-apple-darwin *and*
on x86_64-unknown-linux-gnu (the half of `qos.rs` a macOS-only check can
never see), `cargo +1.96.1 fmt --check` clean, 1,047 tests green.

rustfmt 1.96.1 produces a zero-line diff against the tree formatted by
1.89 — worth recording, since it means the reformat in this PR is not
version-sensitive the way the lints turned out to be.
@navbytes
navbytes merged commit 7744cf4 into main Aug 21, 2026
3 checks passed
@navbytes
navbytes deleted the chore/lint-and-format branch August 21, 2026 05:36
navbytes added a commit that referenced this pull request Aug 21, 2026
#150 reformatted 52 files. Without this, `git blame` on ~3,300 lines
answers "the formatting commit" instead of whoever actually wrote them,
which on a codebase whose comments carry most of its reasoning is the
real cost of adopting rustfmt at all.

The hash could not be filled in before the merge: this repository
squash-merges, so the SHA on the PR branch is not the SHA in history.

It is also not a *pure* formatting commit, for the same reason — the
squash collapsed the reformat together with the config and the 43 lint
fixes the new rules turned up. The file says so rather than pretending
otherwise, and the rule it states is relaxed to match what a
squash-merging repo can actually produce: "overwhelmingly mechanical,
and say plainly what else is in it".

Verified: `git blame` over `src/core/app.rs` with the file configured
attributes lines to their 2026-07-20 authors again instead of to #150.

GitHub reads this file on its own; locally it needs one command, which
the file's own header gives:

    git config blame.ignoreRevsFile .git-blame-ignore-revs
navbytes added a commit that referenced this pull request Aug 21, 2026
…e request (#155)

v0.1.10 shipped at 01:48 UTC today, before any of this session's work
landed, so everything below is unreleased. Touching
`.github/release-request` dispatches Release, which builds four targets,
creates the `v0.1.11` tag from Cargo.toml itself, and publishes with
SHA256SUMS.txt.

**Two silent data-loss bugs.**

- `Alt+w` on the last pane — whose own prompt calls it "quit roost" —
  removed the pane and *then* quit, so `shutdown` saved a tab whose
  layout named a pane its map no longer had. The repair minted a blank
  `shell` spec on next launch, and the session id, cwd, title and note
  of the pane you quit on were gone. `Alt+q` on the same pane kept all
  of them. (#147)
- A recycled pane id inherited the dead pane's session-detection clock.
  `last_shell_seen` and `pending_detect` were the only two
  `PaneId`-keyed maps `close_pane_id` did not prune, so a pane taking
  that id could scan hours back and resume a stranger's conversation,
  permanently. (#141)

**Idle cost down ~13x.** 2.75% CPU and 888 B/s of terminal traffic, to
0.2–0.4% and ~62 B/s, flat in the pane count at every step because it
was all fixed per-frame chrome: a `String` allocated per cell per frame
(#140); the quick-launch picker's `$PATH` probe running ~1,500 `stat(2)`
a second with no dialog on screen, plus the whole help table, every
frame (#144); an unconditional 30 fps repaint (#146); and 12 fps even
with nothing animating (#149).

**Desktop notifications changed channel, and this one is user-visible.**
They used to fork `osascript`, which macOS attributes to **Script
Editor** — there is no flag for that; a CLI cannot post under its own
name. roost now asks the terminal instead, with a bell and an `OSC 9`,
which is the mechanism SPEC-parity P2 already named. Ghostty, iTerm2,
WezTerm and kitty raise a real notification from it, attributed to
themselves, and it survives ssh. **A host that ignores OSC 9 (Apple
Terminal, Alacritty) now gets only the bell.** (#152)

And a pane's notification no longer arrives twice: there were two
emitters, invisible as a duplicate only while one of them was the
`osascript` fork. The named one won, so one banner, saying which pane,
and silence for the pane you are already looking at. (#153)

**Three new fuzzers** over input surfaces nothing reached before — the
mouse router, the key router, and paste — each walking a layout being
reshaped underneath it, drawing a real frame and re-checking the layout
fuzzer's own invariants after every step. The mouse one found #147 on
seed 15. (#147, #148)

**rustfmt and clippy, enforced in CI** (#150), tuned to the style the
tree already had rather than imposed: the config was picked by measuring
churn (556 hunks vs 1,632 for the naive setting). The lint set is in
`Cargo.toml`'s `[lints]` tables so it binds locally too, and three
candidate lints were measured and rejected. It found ten `unsafe` blocks
missing the `SAFETY:` comment every other one in the tree has.

Plus: the reformat kept out of `git blame` (#151), and three test gates
that blamed roost for the machine being out of ptys (#154).

Verified before cutting: 1,048 tests, clippy clean at `-D warnings`,
`fmt --check` clean. Both agent adapters checked end to end against live
binaries (`pi --session …`, `claude --resume …`), the session-resolution
table across all five adapters, the 45s status decays timed against the
clock, and two 15-minute soaks (~78k random keystrokes each, ~1,800
panes) with no orphan, no leak and no unreadable workspace.

Note for after the merge: `HOMEBREW_TAP_TOKEN` is still unset, so
release.yml will skip the tap sync again (it did for v0.1.10). The
formula needs the usual hand-sync.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant