Rust rules for AI coding agents
When an agent fights the borrow checker, it tends to win by adding .clone() or unwrap() until the code compiles. The Rust rules cover crates and Tokio services and try to stop those shortcuts: take borrowed, general parameter types, return Result and propagate with ?, and keep unwrap() and expect() for tests and truly impossible states.
They also cover the design side, such as enums with data instead of a struct full of Options and newtypes instead of bare primitives that are easy to swap, plus the async traps that only show up under load. Any unsafe block needs a // SAFETY: comment that states the invariant, and the agent is told not to use language or std features newer than rust-version in Cargo.toml.
Folder: rules/rust/ · rust.mdc, CLAUDE.md, AGENTS.md
What the Rust rules tell your agent
- ToolchainRead
editionandrust-versionin Cargo.toml. Do not use language or std features newer thanrust-version. - Ownership and borrowingTake borrowed, general parameter types:
&strnot&String,&[T]not&Vec<T>,impl AsRef<Path>for paths. Return owned values. - ErrorsReturn
Resultand propagate with?.unwrap()andexpect()belong in tests, examples and truly impossible states; anexpectmessage states the invariant ("config validated at startup"). - Types and APIsMake invalid states unrepresentable: enums with data instead of a struct of
Options and akindstring; newtypes (struct UserId(Uuid)) instead of bare primitives that are easy to swap. - UnsafeNo
unsafewithout a// SAFETY:comment that states the invariant and why it holds. Prefer a safe abstraction from a well-reviewed crate.
First rule of each section in rust.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- Never block inside async code: no
std::thread::sleep, blocking file or network I/O, or long CPU loops. Usetokio::time::sleep, async I/O, ortokio::task::spawn_blocking. - Do not hold a
std::sync::Mutexguard across.await. Drop it before awaiting, or usetokio::sync::Mutexwhen the lock must span an await. - Spawned tasks need
'static + Senddata. Move owned values orArcclones in; do not try to borrow from the parent stack.
Source: “Async (Tokio)”.
Checks the agent runs before it says done
Example commands from the Rust file (swap in your repo's own):
- Format:
cargo fmt --check - Lint:
cargo clippy --all-targets --all-features -- -D warnings - Tests:
cargo test --all-features
Run cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings and cargo test --all-features. Report each result, including any warnings you left in place.
Before and after: Rust: locks held across .await and other async traps
Prompt card 21 · Stack: Rust with Tokio · Type: Concurrency review · Best model: a frontier reasoning model
What the card is for
Find MutexGuards and RefCell borrows that live across an .await, blocking calls inside async functions, and spawned tasks whose errors and panics vanish. These cause deadlocks, "future is not Send" compile errors fixed the wrong way, and stalled runtimes.
Worked example from the card
Before
struct AppState { cache: std::sync::Mutex<HashMap<String, Rate>>, http: reqwest::Client }
async fn rate(state: Arc<AppState>, pair: String) -> Result<Rate, Error> {
let mut cache = state.cache.lock().unwrap();
if let Some(r) = cache.get(&pair) { return Ok(*r); }
let r: Rate = state.http.get(format!("{RATES}/{pair}")).send().await?.json().await?;
cache.insert(pair, r);
Ok(r)
}
Expected AI output (abridged)
- The std MutexGuard lives across two awaits (send, json). The future is not Send, so
tokio::spawn rejects it; on a current-thread runtime it would compile and every other
request needing the cache would wait for this HTTP call.
- Switching to tokio::sync::Mutex would compile but still serialize all lookups behind
one network call. Instead: check the cache, drop the guard, fetch, re-lock to insert.
- Concurrent misses for the same pair now fetch twice; acceptable for a rate cache.
If not, use a per-key OnceCell or a singleflight crate.
After
async fn rate(state: Arc<AppState>, pair: String) -> Result<Rate, Error> {
if let Some(r) = state.cache.lock().expect("cache lock poisoned").get(&pair).copied() {
return Ok(r);
} // guard dropped at the end of this statement
let r: Rate = state.http
.get(format!("{RATES}/{pair}"))
.timeout(Duration::from_secs(3))
.send().await?
.error_for_status()?
.json().await?;
state.cache.lock().expect("cache lock poisoned").insert(pair, r);
Ok(r)
}
Excerpt from card 21; the prompt itself is in the pack.
More Rust prompt cards in the pack
- Card 19: Rust: replace unwraps with a real error type
- Card 20: Rust: remove clones added to satisfy the borrow checker
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and rust.mdc in .cursor/rules/. Globs: Cargo.toml, **/*.rs.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/rust.md for **/*.rs, Cargo.toml; PostToolUse hook: cargo fmt.
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the crate or workspace root with Cargo.toml. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.