What?

A build configuration that makes generated Rust readable at a glance, and the principles behind it, short enough to paste into AGENTS.md.

Rust needs few tools for this. The formatter ships with the compiler, the linter ships with both, and the crate graph enforces the architecture:

CategoryToolOwns
Compilerrustc, -D warnings in CINothing gets through unnoticed
Formatterrustfmt, pinned with the toolchainLayout
LinterClippyCorrectness you didn’t think to check
LinterClippy thresholds (clippy.toml)The counting a formatter can’t do
ArchitectureWorkspace crates + private modulesBoundaries the compiler enforces
Coveragecargo-llvm-covWhat the tests never ran
HistorycommittedCommit messages a glance can read

Why?

  • We have always read more than we wrote. Now we barely write at all. With an agent writing most of the code, you are almost purely a reader, and reading code you didn’t write is the more expensive of the two.
  • Even when you stop reading, you still glance. A snippet, an article, a diff. You won’t review every generated line, but you will glance, and a glance is where a defect hides. The shape of the code has to carry its meaning.
  • Formatting is for you. Names and structure are for you and the model. Strip the formatting and models do just as well on fewer tokens,1 but scramble the names and structure and they do worse.2
  • A config ends the conversation. An agent that doesn’t know your conventions invents plausible ones, and you correct them every session. A rule in the build is enforced; a rule in AGENTS.md is one the model can plan for. You want both, and you want them to agree.

How?

Compiler: Make every warning fatal in CI, not in the source

.github/workflows/ci.yml
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings # rustc's lints and Clippy's, as errors
  • A warning nobody acts on hides the next one. The only sustainable number of ignored warnings is zero, and the only way to hold it is to make them fail.
  • Don’t put #![deny(warnings)] in the source. It ties the build to the toolchain that wrote it. The next stable release adds a lint, and a clean checkout stops building for anyone on a newer compiler. Deny warnings in CI, where you choose the toolchain.
  • One command covers both linters. cargo clippy runs rustc’s lints too, so CI doesn’t need a separate cargo build step for warnings.

Formatter: rustfmt, pinned with the toolchain

rust-toolchain.toml
[toolchain]
channel = "1.99.1" # an application: an exact release, not "stable"
components = ["rustfmt", "clippy"]
  • Pin an application; don’t pin a library. An application ships one build, so pin the toolchain it’s built with. A library is built by its users with their compilers, so the convention is CI on the current stable plus the oldest version rust-version promises, and no rust-toolchain.toml to make every contributor download yours.
rustfmt.toml
style_edition = "2024"
  • Pinning the compiler also pins the formatter. rustfmt and Clippy ship with the toolchain, so one file pins all three. A library on floating stable gets rustfmt’s stability guarantee instead: within a style edition, a new release doesn’t reformat code it already formatted.
  • The defaults are the spec: 4-space indent, 100 columns, per the Rust Style Guide. Link the guide in AGENTS.md.
  • The one setting worth writing down is style_edition. cargo fmt takes the style edition from the crate’s edition, but plain rustfmt (an editor, a snippet, a pre-commit hook) falls back to 2015. The editions differ: 2024 sorts imports by version, so u8 comes before u16. Write it down so every way of running rustfmt agrees.
  • Leave the nightly-only options alone. The ones people ask for most, imports_granularity and group_imports, don’t run on stable.
  • rustfmt doesn’t report what it can’t format. It leaves a line it can’t fit in 100 columns, or a macro body it can’t parse, exactly as it was, and --check still passes. Those are the lines to look at when you glance.

Linter: Clippy, with lints kept only if they find something

Cargo.toml
[workspace.lints.clippy]
all = { level = "warn", priority = -1 } # the default groups; -1 lets the lines below override them
# Each tried on its own across the whole tree, and kept because it found something worth fixing.
set_contains_or_insert = "warn"
allow_attributes_without_reason = "warn"
crates/report/Cargo.toml
[lints.clippy]
iter_over_hash_type = "warn" # only where order reaches a reader: summing a HashMap elsewhere is fine
#[expect(clippy::too_many_arguments, reason = "mirrors the wire format, field for field")]
fn decode(/* … */) {}
  • clippy::pedantic is where the useful extra lints are, and the noisy ones. Don’t turn on the whole group. Follow a procedure: turn on one lint, run it over everything, and keep it only if what it found was worth fixing.
  • priority = -1 is not optional. Without it, the group setting and the individual lints have the same priority, and Cargo doesn’t promise which one wins.
  • Rust has a better place for the reason than a config file: the attribute itself. #[expect(…, reason = "…")] (stable since 1.81) is an allow that warns once the lint stops firing, so an out-of-date exception shows up as a warning. allow_attributes_without_reason makes the reason required.

Linter: Count the three things a formatter can’t see

clippy.toml
too-many-lines-threshold = 60     # default 100, and off (pedantic): turn on too_many_lines in Cargo.toml
too-many-arguments-threshold = 5  # default 7
excessive-nesting-threshold = 4   # default 0, which means off
  • Rust counts length, arguments and nesting, not cyclomatic complexity. Clippy’s cognitive_complexity is in the restriction group, with a note that it can’t actually measure what its name says. The note recommends too_many_lines and excessive_nesting instead.
  • Don’t copy another language’s numbers. The same logic takes more lines in Rust: rustfmt puts each argument or field on its own line once a call is too wide, and match arms, ? and builders add more. A 25-line limit that suits a terser language is a fight in Rust. Clippy’s defaults are 100 lines and 7 arguments, and too_many_lines is pedantic because the community doesn’t agree on a number.
  • Set the limit where the code already is, then hold it. These numbers are examples, set below Clippy’s defaults. Bring the count to zero first, and only then let the lint fail the build.

Style: Write lines that read as sentences

ConventionChecked byWhat’s left to judgement
Name bindings for what they are: matching_specs, not smany_single_char_names (pedantic): four in one scopeWhether the name is right; |e| in a closure is fine
Annotate a let when the right-hand side doesn’t name the typeredundant_type_annotations (restriction), for one direction onlyWhen collect() or sum() needs it anyway
A repeated call becomes a named localNothingAll of it
A long iterator chain inside a bigger function becomes a named functionNothing (too_many_lines helps)The name: sorted_html_files_in(files)
contains then insert means you wanted a setset_contains_or_insert: insert already returns boolBTreeSet when order matters
Output follows a BTreeMap, a BTreeSet or an explicit sortiter_over_hash_type (restriction), in the output crateWhere the order reaches a reader
Name every regex group; never read one by numberdisallowed-methods on Captures::getcaps[1] gets past it; get(0), the whole match, is caught too: use caps.get_match()
A second concern is its own type: X and Caching<X>Nothing reliableAll of it
clippy.toml
disallowed-methods = [
  # allow-invalid: Clippy errors on a path it can't resolve, so this also passes in crates without regex.
  { path = "regex::Captures::get", reason = "read groups by name: caps.name(\"year\")", allow-invalid = true },
]
  • The checks find the shape, not the meaning.
  • Single letters are idiomatic in closures. .map_err(|e| e.to_string()) and .sort_by(|a, b| a.cmp(b)) are how Rust is written. min_ident_chars flags them, and its allow-list can’t tell a closure from a let, so it stays out of the build. many_single_char_names catches the real problem: a scope full of them.
  • Annotating a let is a house rule, not a Rust norm. Most Rust leans on inference and the editor’s type hints. It’s here because a snippet in an article has no editor: the annotation is the type hint.
  • Avoid lints that fight these conventions, and say why beside the expect.
  • A name is worth the time. Name a chain for what it returns, a predicate so it reads in an if, and a binding for the thing it holds.
  • AI review is advisory, not a gate.

Architecture: Make the boundaries crates

crates/shopfront/Cargo.toml
[dependencies]
kitchen = { path = "../kitchen" } # one way only: the kitchen bakes for any counter
crates/kitchen/src/lib.rs
mod proving; // private: nothing outside the kitchen can reach it, whatever is `pub` inside
pub use proving::Dough; // the one item the crate means to export
  • In Rust the compiler checks this, so you don’t need a test. A crate can only use what its Cargo.toml names. If the kitchen ever names the shopfront, Cargo rejects the build as a dependency cycle.
  • Inside a crate, boundaries are weaker. A private module hides what’s in it from other crates, but not from its siblings. If a boundary matters enough to assert, make it a crate.
  • A crate per boundary costs something: another build unit and another Cargo.toml. Large workspaces are normal in Rust, but split where the boundary matters, not everywhere.
  • Let module privacy decide reach. The common style is plain pub inside private modules, with pub use for what the crate exports, and pub(crate) only where an item must be visible beyond its parent module. The unreachable_pub lint asks for pub(crate) everywhere instead; it’s off by default, and most code doesn’t use it.

Coverage: Measure what the tests never ran

.github/workflows/ci.yml
- run: cargo llvm-cov --workspace --locked --fail-under-regions 80 # where the code already is
  • On stable, measure regions, not lines. Branch coverage (--branch) needs nightly. Each arm of a match and each side of an if is a region, so regions are closer to the decisions someone made than lines are.
  • Tests are the contract, written as examples. In Rust, doc tests are a contract too: an example in the docs that runs in cargo test breaks when it’s wrong.
  • Coverage shows what isn’t written down. An untested branch is an unstated promise.
  • It’s a floor, not a grade. Set the minimum where the code is, and only raise it.
  • Coverage says a test ran, not that it would notice a wrong line. For that, see mutation testing in for Builders.

History: Write commits a glance can read

fix(pricing): count orders of exactly 100 as large
 
discount() compared with > where the threshold is inclusive, so an
order of exactly 100 paid full price. No test called it at the
boundary; mutation testing found it.
 
Refs: #42
committed.toml
style = "conventional"
subject_length = 50         # the default: aim here
line_length = 72            # the default: the body wraps at 72
subject_capitalized = false # conventional style: lower case after the type
allowed_types = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]
.github/workflows/commits.yml
on: pull_request
jobs:
  commits:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 } # the whole branch, not one commit
      - uses: crate-ci/committed@v1.1.12
  • History is read more than it’s written, and agents now write most of it. git log, git blame and a pull request’s commit list are glances too. A message that narrates the diff wastes the glance.
  • 50/72. A subject of at most 50 characters, a blank line, then a body wrapped at 72. Fifty fits git log --oneline, GitHub’s commit list and an email subject without truncation. Seventy-two leaves room for the indent git log adds, in an 80-column terminal.
  • The type and scope count towards the 50. feat(kitchen): takes 15. A subject that won’t fit is usually a commit doing two things.
  • Conventional Commits say what kind of change it is. type(scope): description, with ! after the type, or a BREAKING CHANGE: footer, for a breaking change. A glance down git log --oneline sorts features from fixes from chores, and release tools read the same prefix to write the changelog and choose the version bump.
  • The subject says what, in the imperative. The body says why. “add”, not “added”: the subject finishes “this commit will…“. The diff already shows how, so the body is for what the diff can’t say: the reason, the alternative you rejected, the bug it fixes.
  • Check it in CI, on every commit in the pull request. committed checks the format, the 50/72 lengths and the imperative mood in one binary, with no runtime to install. A hook on your machine is a convenience; CI is the gate.
  • Trailers are footers. An agent’s Co-Authored-By: line is a Conventional Commits footer, so it fits the format as it is.

The principles, for AGENTS.md

AGENTS.md
## Code conventions
 
Formatting is rustfmt's, with its defaults: that is the spec, not taste.
 
1. Every warning fails the build. A warning nobody acts on hides the next one.
2. One formatter, rustfmt with its defaults. Layout is not a conversation.
3. A snippet must stand alone. Code is read without its imports or its file — in a review, a chat, an article. Prefer the form that survives being torn out.
4. Limits are set where the code already is, then held. A limit above the code is one you have already failed.
5. When something exceeds a limit, split it and name the pieces. Do not raise the bar.
6. A lint earns its place by finding something worth fixing. Turn it on, run it over everything, remove it if it found nothing.
7. When you allow a lint, use `#[expect(…, reason = "…")]` beside the code, not a line in a config file.
8. Name and structure carefully; let the formatter handle whitespace. Identifiers and shape change what a reader and a model understand. Indentation changes neither.
9. Boundaries that matter are crates. A crate uses only what its Cargo.toml names. Inside a crate, keep modules private and export with `pub use`.
10. A test that skips when its inputs are missing has checked nothing. Let the everyday task skip; make CI run one that fails.
11. A line should read as a sentence. Name bindings for what they hold; a single letter is fine for a short closure's parameter. Annotate a `let` when the right-hand side doesn't say the type, so a snippet reads without an editor's hints; leave it off when it does.
12. Name what you'd otherwise repeat or imply. A call made three times becomes a local. A long iterator chain inside a bigger function becomes a function named for what it returns.
13. Say what the collection is, and keep its order steady. `contains` followed by `insert` means a set. Output a reader sees follows a `BTreeMap`, a `BTreeSet` or an explicit sort, never a `HashMap`.
14. Name every regex group, including the ones nothing reads back. Never read a group by number.
15. One concern per type. Caching, prefixing or retrying wraps the thing it adds to (`X` and `Caching<X>`) instead of being folded into it.
16. Commits follow Conventional Commits and 50/72: `type(scope): subject` in at most 50 characters, imperative and lower case; a blank line; then a body wrapped at 72 that says why. The diff already says what.

The point of giving the model these is not obedience. A rule the model knows about is one it designs around. A rule it meets only as a build failure is one it works around.

What this actually buys

A function you can take in at a glance, names that say what things are, and boundaries that hold. In Rust most of it comes with the toolchain. What’s left, as in any language, is the names.

Companions: for Builders and for Operators.

Sources

Footnotes

  1. The Hidden Cost of Readability stripped formatting from code across 10 models and 4 languages: input tokens fell 24.5% on average and performance held. ↩

  2. Obfuscating identifiers and injecting dead branches degrades models. Improving Code Style for Accurate Code Generation (NeurIPS 2023) found that restructuring programs to be more readable improved a fine-tuned CodeLLaMa-7B by up to 30%, though that is about training data, not the code in your repository. ↩

BLUESKY — START THE THREAD KO-FI / RSS