DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Rust: 12 Rules That Make AI Write Memory-Safe, Idiomatic Code

You ask Claude to "add a Subscription service that calls Stripe" inside your Rust crate, and you get back code that compiles cleanly and is still wrong:

  • A function returning Box<dyn std::error::Error> — callers can't match on the variant.
  • Three .unwrap() calls in the happy path "because this can't fail."
  • An unsafe block with no comment justifying why the invariant holds.
  • A tokio::spawn whose JoinHandle is dropped on the floor — fire-and-forget.
  • serde::Deserialize with no #[serde(deny_unknown_fields)], silently swallowing typoed config keys.
  • A Cargo.toml with tokio = "*" that will break the build on a future Tuesday.

The model isn't lazy. It doesn't know your conventions yet. A CLAUDE.md at the root of your Rust repo fixes that. Claude Code reads it on every task, Cursor and Aider read it too, and you stop re-explaining thiserror vs anyhow in every PR review.

Here are 12 rules I drop into every Rust project. Each one closes a class of bug AI assistants generate by default.


Rule 1 — Ownership and Borrowing: Owned In, Borrowed Through

Why: AI models often write fn greet(name: &String) because the variable in the example was a String. That signature rejects every &str literal at the call site and forces callers into pointless allocations. The convention is the opposite: take the cheapest sufficient borrow at the boundary, and only take ownership when you need to store the value.

Bad:

fn greet(name: &String, friends: &Vec<User>) -> String { /* ... */ }
greet(&"Olivia".to_string(), &vec_of_friends);   // forced allocation
Enter fullscreen mode Exit fullscreen mode

Good:

fn greet(name: &str, friends: &[User]) -> String { /* ... */ }
greet("Olivia", &friends);   // works for &str literals AND owned Strings
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Public read-only string args take &str, not &String.
Read-only slice args take &[T], not &Vec<T>.
Functions that store the value take owned (String, Vec<T>) or impl Into<String>.
Never reach for .clone() to silence the borrow checker — fix ownership first.
Enter fullscreen mode Exit fullscreen mode

Rule 2 — Forbid .unwrap() and .expect() Outside Tests

Why: Every .unwrap() is a hidden panic. Claude scatters them by default because the Rust Book uses them in introductory snippets — the message "this is just a temporary panic" never gets removed in the rewrite. Lint them out at the workspace level and the model produces error-handling code on the first try.

Bad:

let port: u16 = std::env::var("PORT").unwrap().parse().unwrap();
Enter fullscreen mode Exit fullscreen mode

Good:

let port: u16 = std::env::var("PORT")
    .context("PORT not set")?
    .parse()
    .map_err(|e| anyhow!("PORT is not a valid u16: {e}"))?;
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

.unwrap() and .expect() are forbidden in non-test code.
Workspace lints: #![warn(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::todo)]
Use ? on Result, ok_or / let-else on Option, map_err to attach context.
Enter fullscreen mode Exit fullscreen mode

Rule 3 — Clippy Compliance Is Non-Negotiable: -D warnings in CI

Why: Rust's compiler and clippy catch genuine bugs. Treating warnings as warnings means they pile up until nobody reads them. The only sustainable policy is: every warning fails CI. Pin the toolchain so a Rust release can't suddenly add a lint that bricks your build.

Bad — warnings ignored, lints not enabled:

fn main() {
    let unused = compute();   // warning ignored forever
    real_work();
}
Enter fullscreen mode Exit fullscreen mode

Good — workspace lib.rs / main.rs:

#![warn(
    clippy::all,
    clippy::pedantic,
    clippy::nursery,
    clippy::cargo,
    clippy::unwrap_used,
    clippy::expect_used,
    missing_docs,
)]
Enter fullscreen mode Exit fullscreen mode
# .github/workflows/ci.yml
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- run: cargo test --workspace
Enter fullscreen mode Exit fullscreen mode
# rust-toolchain.toml — pin compiler so a release can't break you
[toolchain]
channel = "1.78.0"
components = ["rustfmt", "clippy"]
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

CI runs cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test.
Pin the toolchain in rust-toolchain.toml.
Never silence a clippy lint without a comment + reason on the #[allow(...)].
Enter fullscreen mode Exit fullscreen mode

Rule 4 — No unsafe Without a // SAFETY: Comment

Why: AI-generated unsafe blocks are a leading source of UB in Rust codebases. The model copies an example, drops the surrounding invariants, and suddenly you're transmuting between types with different alignments. The rule is simple: every unsafe block must be justified in writing, and the default is to forbid unsafe at the crate level.

Bad:

let s = unsafe { std::str::from_utf8_unchecked(bytes) };
let val: u32 = unsafe { std::mem::transmute(some_bytes) };
Enter fullscreen mode Exit fullscreen mode

Good:

// SAFETY: `bytes` was just produced by `String::into_bytes` two lines above,
// so it is guaranteed to be valid UTF-8.
let s = unsafe { std::str::from_utf8_unchecked(&bytes) };
Enter fullscreen mode Exit fullscreen mode
// In lib.rs
#![deny(unsafe_code)]   // forbid unsafe entirely in this crate
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Default to #![forbid(unsafe_code)] (or #![deny(unsafe_code)]) at the crate root.
If unsafe is required (FFI, perf-critical hot path), every block must have a
// SAFETY: comment explaining which invariants the caller upholds.
PRs adding unsafe without justification are rejected.
Enter fullscreen mode Exit fullscreen mode

Rule 5 — thiserror for Libraries, anyhow for Binaries — Never Mix

Why: AI tools love Box<dyn std::error::Error> because it's the lowest-friction return type. The cost: callers can't match on the variant, can't add structured context, can't trust the error is Send. The split — thiserror enums in libraries, anyhow::Result in binaries — keeps the error surface inspectable where it matters and ergonomic where it doesn't.

Bad:

pub fn fetch_user(id: u64) -> Result<User, Box<dyn std::error::Error>> {
    let row = db.query("SELECT ...")?;
    Ok(parse(row)?)
}
Enter fullscreen mode Exit fullscreen mode

Good — library:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("user not found: {0}")]
    NotFound(UserId),
    #[error("database: {0}")]
    Db(#[from] sqlx::Error),
    #[error("parse: {0}")]
    Parse(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
Enter fullscreen mode Exit fullscreen mode

Good — binary:

use anyhow::{Context, Result};

fn main() -> Result<()> {
    let cfg = load_config().context("loading config")?;
    run(cfg).context("running CLI")?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Library crates expose typed errors via thiserror. Binaries use anyhow::Result with .context().
Never return Box<dyn Error> from a public library function.
Every error path includes context — never just `?` without `.with_context(...)` at boundaries.
Enter fullscreen mode Exit fullscreen mode

Rule 6 — Async: No Blocking Calls, No Locks Across .await

Why: Two of the most common AI mistakes in Tokio code: calling synchronous I/O in an async fn (blocks the runtime worker), and holding a std::sync::Mutex guard across .await (deadlock waiting to happen — the task can be parked on a different thread when it resumes, while the lock is still held).

Bad:

async fn read_config() -> String {
    std::fs::read_to_string("config.toml").unwrap()   // blocks the runtime!
}

async fn handle(state: Arc<Mutex<State>>) {
    let guard = state.lock().unwrap();
    let user = fetch_user(guard.user_id).await.unwrap();   // lock held across await
}
Enter fullscreen mode Exit fullscreen mode

Good:

async fn read_config() -> anyhow::Result<String> {
    Ok(tokio::fs::read_to_string("config.toml").await?)
}

async fn handle(state: Arc<Mutex<State>>) -> anyhow::Result<()> {
    let user_id = {
        let guard = state.lock().expect("poisoned");
        guard.user_id
    }; // guard dropped here
    let user = fetch_user(user_id).await?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

For tasks: use JoinSet so handles aren't dropped on the floor.

let mut set = tokio::task::JoinSet::new();
for url in urls { set.spawn(fetch(url)); }
while let Some(res) = set.join_next().await { /* handle */ }
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

In async code: never call blocking I/O (use tokio::fs, tokio::net, sqlx, reqwest).
Wrap CPU-bound work in tokio::task::spawn_blocking.
Never hold std::sync::Mutex across .await — drop the guard or use tokio::sync::Mutex.
Every spawned task: .await its JoinHandle, store it in a JoinSet, or attach to a supervisor.
Enable clippy::await_holding_lock at the workspace level.
Enter fullscreen mode Exit fullscreen mode

Rule 7 — serde: Always deny_unknown_fields, Always rename_all

Why: AI-generated Deserialize structs silently accept typos. A user writes database_ulr in their config, serde happily produces a struct with the default URL, and you ship the bug to production. deny_unknown_fields catches the typo at parse time. Combined with rename_all, you get a stable wire format that doesn't drift when someone refactors a field name.

Bad:

#[derive(Deserialize)]
struct Config {
    database_url: String,
    pool_size: u32,
}
// User typoes "database_ulr" in YAML → silent default → prod incident
Enter fullscreen mode Exit fullscreen mode

Good:

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
struct Config {
    #[serde(default = "default_pool_size")]
    pool_size: u32,
    database_url: String,
}

fn default_pool_size() -> u32 { 10 }
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Every Deserialize struct uses #[serde(deny_unknown_fields)] unless we explicitly
need forward compatibility (document the reason).
Use #[serde(rename_all = "...")] for stable wire formats — never rely on field names
matching the wire format by accident. Always #[derive(Debug)] alongside serde derives.
For fallible parsing of untrusted input, prefer serde_path_to_error for diagnostics.
Enter fullscreen mode Exit fullscreen mode

Rule 8 — Lifetime Annotations: Elide When You Can, Name When You Must

Why: AI assistants oscillate between two anti-patterns: sprinkling '_ and 'a everywhere "to make the compiler happy," or wrapping everything in Arc<T> to dodge lifetimes entirely. The right answer is in the middle. Use elision rules where they apply, name lifetimes only when the relationship between input and output borrows isn't obvious, and pick Arc only when ownership is genuinely shared across threads.

Bad:

// Over-annotated — every elidable lifetime explicit
fn first_word<'a>(s: &'a str) -> &'a str {
    s.split_whitespace().next().unwrap_or("")
}

// Or the opposite — Arc to avoid thinking about lifetimes
struct Parser { input: Arc<String>, pos: Arc<Mutex<usize>> }
Enter fullscreen mode Exit fullscreen mode

Good:

// Lifetime elided — the rule is "single input lifetime → output borrows from it"
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

// Named only when the relationship matters
struct Parser<'src> {
    input: &'src str,
    pos: usize,
}

impl<'src> Parser<'src> {
    fn remaining(&self) -> &'src str { &self.input[self.pos..] }
}
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Apply lifetime elision wherever it works. Name lifetimes only when:
  (a) a struct holds borrowed data, or
  (b) the relationship between input/output borrows isn't obvious from elision rules.
Don't reach for Arc<T> as a lifetime escape hatch — Arc is for shared ownership
across threads, not for avoiding the borrow checker.
Enter fullscreen mode Exit fullscreen mode

Rule 9 — Cargo.toml: Pin Major Versions, Forbid Wildcards

Why: tokio = "*" is a time bomb. Caret requirements (tokio = "1") are usually fine but allow a 1.0 → 1.99 jump that can introduce performance regressions or subtle behavior changes. Pin to at least the minor version, commit Cargo.lock (yes, even for libraries — for reproducible CI), and use cargo deny to catch yanked or vulnerable crates before they reach production.

Bad:

[dependencies]
tokio = "*"
serde = "*"
sqlx = "0"
Enter fullscreen mode Exit fullscreen mode

Good:

[dependencies]
tokio = { version = "1.38", features = ["rt-multi-thread", "macros", "fs", "net"] }
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "macros"] }
thiserror = "1.0"
anyhow = "1.0"

[workspace.lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
Enter fullscreen mode Exit fullscreen mode
# deny.toml — cargo deny check
[advisories]
vulnerability = "deny"
unmaintained = "warn"
yanked = "deny"

[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"]
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Cargo.toml pins major+minor (`tokio = "1.38"`), never wildcards.
Commit Cargo.lock for binaries AND libraries (we rely on it for reproducible CI).
Specify only the features we use — do not enable default features blindly.
CI runs `cargo deny check` for vulnerabilities, yanked crates, and license policy.
Enter fullscreen mode Exit fullscreen mode

Rule 10 — Test Structure: Unit in mod tests, Integration in tests/

Why: Rust gives you two test locations and a clear convention for what goes where. Unit tests live in #[cfg(test)] mod tests next to the code they test — they can poke at private items. Integration tests live in tests/ and only see the public API, so they catch the "I forgot to make this pub" class of bug. AI assistants tend to dump everything in one place; the split keeps fast tests fast and the public surface tested.

Bad — every test in one massive tests/all.rs, mocking everything:

// tests/all.rs
#[test]
fn list_users_works() {
    let mock = MockPool::new();
    mock.expect_query().returning(|_| Ok(vec![fake_row()]));
    // passes even if real SQL is broken
}
Enter fullscreen mode Exit fullscreen mode

Good — split, real DB for repository tests:

// src/users.rs
pub fn parse_email(s: &str) -> Result<Email, ParseError> { /* ... */ }

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_email_rejects_missing_at() {
        assert!(parse_email("nope").is_err());
    }
}
Enter fullscreen mode Exit fullscreen mode
// tests/users_integration.rs — sees only the public API
use mycrate::{User, list_users};

#[tokio::test]
async fn list_users_returns_active_only() {
    let pool = test_pool().await;   // testcontainers-rs Postgres
    sqlx::query!("INSERT INTO users (email, active) VALUES ($1, $2)",
                 "x@y.com", true).execute(&pool).await.unwrap();
    let users = list_users(&pool).await.unwrap();
    assert_eq!(users.len(), 1);
}
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Unit tests: #[cfg(test)] mod tests inside the file they cover, exercising private fns.
Integration tests: tests/<feature>.rs, exercising only the public crate API.
Repository / DB tests use a real Postgres via sqlx + testcontainers-rs — never mock sqlx.
Use `cargo sqlx prepare` to generate offline data so CI can build without a DB.
Enter fullscreen mode Exit fullscreen mode

Rule 11 — No println! / eprintln! in Library Code — Use tracing

Why: Library code can't assume there's a TTY, and structured logs are non-negotiable in production. AI-generated debug output is full of println!("got user {}", user) — fine in a binary, a disaster in a library that's pulled into someone else's service. tracing gives you structured fields, span context, and pluggable subscribers so callers control formatting and routing.

Bad:

pub fn handle_request(req: &Request) -> Result<Response> {
    println!("got request {:?}", req);   // pollutes downstream stdout
    let user = lookup(req.user_id)?;
    println!("user is {:?}", user);
    Ok(respond(user))
}
Enter fullscreen mode Exit fullscreen mode

Good:

use tracing::{info, instrument};

#[instrument(skip(req), fields(user_id = req.user_id))]
pub fn handle_request(req: &Request) -> Result<Response> {
    info!("handling request");
    let user = lookup(req.user_id)?;
    info!(?user, "looked up user");
    Ok(respond(user))
}
Enter fullscreen mode Exit fullscreen mode
// In the binary, install a subscriber once
fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .json()
        .init();
}
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Library code uses `tracing` — never println!, eprintln!, or dbg!.
Binaries install a tracing subscriber in main(); pick JSON or pretty based on TTY.
#[instrument] async fns and methods that cross logical boundaries (HTTP, DB).
Use structured fields (`info!(user_id, "..."`) — never format!() into the message.
Enter fullscreen mode Exit fullscreen mode

Rule 12 — build.rs: Treat It as Code, Not Magic

Why: A build.rs runs on every cargo build and on every developer machine. AI assistants often add one to "just generate this file" and bake in absolute paths, network calls, or flaky assumptions about the environment. Build scripts should be deterministic, hermetic, and emit the right cargo:rerun-if-* directives so they don't trigger unnecessary rebuilds — or fail to trigger when they should.

Bad:

// build.rs — non-deterministic, non-hermetic
fn main() {
    let resp = reqwest::blocking::get("https://api.internal/schema.json")
        .unwrap().text().unwrap();
    std::fs::write("/Users/me/code/proj/src/schema.rs", resp).unwrap();
    // No rerun-if-* — runs once and "works", then mysteriously stops updating
}
Enter fullscreen mode Exit fullscreen mode

Good:

// build.rs — deterministic, declares its inputs
use std::{env, path::PathBuf};

fn main() {
    println!("cargo:rerun-if-changed=schema/api.json");
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-env-changed=PROTOC");

    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    let schema = std::fs::read_to_string("schema/api.json")
        .expect("schema/api.json must exist — run `make sync-schema` first");

    let generated = generate(&schema);
    std::fs::write(out_dir.join("api.rs"), generated).unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

build.rs is deterministic and hermetic — no network, no absolute paths, no system clocks.
Every build script declares its inputs:
  cargo:rerun-if-changed=<file>, cargo:rerun-if-env-changed=<var>.
Generated code lives under OUT_DIR (env var), included via include!(concat!(env!("OUT_DIR"), "/file.rs")).
Avoid build.rs when a const_fn, macro, or vendored file would do the job.
Enter fullscreen mode Exit fullscreen mode

Why This Is Worth Doing Once

Every rule above traces to a real production bug from an AI-generated PR. A Box<dyn Error> that took two hours to refactor across 14 callsites. An unwrap() that brought down an API at 3 AM because a config key was misspelled. A spawned task that never finished because its JoinHandle was dropped, leaking a connection pool every minute. A serde struct that silently accepted database_ulr and shipped the typo to staging.

You can keep catching these in review forever. Or you can write a CLAUDE.md, drop it at the repo root, and stop seeing 80% of them.

The 12 rules above are a starting point — the full pack has 50+ production-tested rules covering Rust, Go, TypeScript, React, Vue, Django, FastAPI, Postgres, Kubernetes, Docker, and more.

Free Rust gist with all the rules → https://gist.github.com/oliviacraft/dd30e539053d6f2b6ddc7ee529db95e1

Full CLAUDE.md Rules Packhttps://oliviacraftlat.gumroad.com/l/skdgt

Top comments (0)