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:
| Category | Tool | Owns |
|---|---|---|
| Compiler | rustc, -D warnings in CI | Nothing gets through unnoticed |
| Formatter | rustfmt, pinned with the toolchain | Layout |
| Linter | Clippy | Correctness you didn’t think to check |
| Linter | Clippy thresholds (clippy.toml) | The counting a formatter can’t do |
| Architecture | Workspace crates + private modules | Boundaries the compiler enforces |
| Coverage | cargo-llvm-cov | What the tests never ran |
| History | committed | Commit 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.mdis 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
- 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 clippyruns rustc’s lints too, so CI doesn’t need a separatecargo buildstep for warnings.
Formatter: rustfmt, pinned with the toolchain
[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-versionpromises, and norust-toolchain.tomlto make every contributor download yours.
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 fmttakes the style edition from the crate’sedition, but plainrustfmt(an editor, a snippet, a pre-commit hook) falls back to 2015. The editions differ: 2024 sorts imports by version, sou8comes beforeu16. Write it down so every way of running rustfmt agrees. - Leave the nightly-only options alone. The ones people ask for most,
imports_granularityandgroup_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
--checkstill passes. Those are the lines to look at when you glance.
Linter: Clippy, with lints kept only if they find something
[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"[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::pedanticis 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 = -1is 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 anallowthat warns once the lint stops firing, so an out-of-date exception shows up as a warning.allow_attributes_without_reasonmakes the reason required.
Linter: Count the three things a formatter can’t see
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_complexityis in therestrictiongroup, with a note that it can’t actually measure what its name says. The note recommendstoo_many_linesandexcessive_nestinginstead. - 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
matcharms,?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, andtoo_many_linesis 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
| Convention | Checked by | What’s left to judgement |
|---|---|---|
Name bindings for what they are: matching_specs, not s | many_single_char_names (pedantic): four in one scope | Whether the name is right; |e| in a closure is fine |
Annotate a let when the right-hand side doesn’t name the type | redundant_type_annotations (restriction), for one direction only | When collect() or sum() needs it anyway |
| A repeated call becomes a named local | Nothing | All of it |
| A long iterator chain inside a bigger function becomes a named function | Nothing (too_many_lines helps) | The name: sorted_html_files_in(files) |
contains then insert means you wanted a set | set_contains_or_insert: insert already returns bool | BTreeSet when order matters |
Output follows a BTreeMap, a BTreeSet or an explicit sort | iter_over_hash_type (restriction), in the output crate | Where the order reaches a reader |
| Name every regex group; never read one by number | disallowed-methods on Captures::get | caps[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 reliable | All of it |
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_charsflags them, and its allow-list can’t tell a closure from alet, so it stays out of the build.many_single_char_namescatches the real problem: a scope full of them. - Annotating a
letis 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
[dependencies]
kitchen = { path = "../kitchen" } # one way only: the kitchen bakes for any countermod 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.tomlnames. 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
pubinside private modules, withpub usefor what the crate exports, andpub(crate)only where an item must be visible beyond its parent module. Theunreachable_publint asks forpub(crate)everywhere instead; it’s off by default, and most code doesn’t use it.
Coverage: Measure what the tests never ran
- 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 amatchand each side of anifis 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 testbreaks 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: #42style = "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"]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 blameand 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 indentgit logadds, 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 aBREAKING CHANGE:footer, for a breaking change. A glance downgit log --onelinesorts 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
## 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
- The Rust Style Guide, the spec rustfmt’s defaults implement, and rustfmt’s configuration, which marks which options are stable.
- Clippy’s lint list, with each lint’s group and
clippy.tomlsettings, and rust-clippy#3793, on whycognitive_complexityisn’t a measure. - The
[lints]table in the Cargo reference, includingpriority. #![deny(warnings)]as an anti-pattern, from Rust Design Patterns.- cargo-llvm-cov, for
--fail-under-*and nightly-only--branch. - The Hidden Cost of Readability and Improving Code Style for Accurate Code Generation: the papers behind formatting versus names and structure.
Footnotes
-
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. ↩
-
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. ↩