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

First rule of each section in rust.mdc, quoted as shipped.

Mistakes these rules are written to prevent

Source: “Async (Tokio)”.

Checks the agent runs before it says done

Example commands from the Rust file (swap in your repo's own):

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

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.

Related stacks

← All stacks