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'tmatchon the variant. - Three
.unwrap()calls in the happy path "because this can't fail." - An
unsafeblock with no comment justifying why the invariant holds. - A
tokio::spawnwhoseJoinHandleis dropped on the floor — fire-and-forget. -
serde::Deserializewith no#[serde(deny_unknown_fields)], silently swallowing typoed config keys. - A
Cargo.tomlwithtokio = "*"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
Good:
fn greet(name: &str, friends: &[User]) -> String { /* ... */ }
greet("Olivia", &friends); // works for &str literals AND owned Strings
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.
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();
Good:
let port: u16 = std::env::var("PORT")
.context("PORT not set")?
.parse()
.map_err(|e| anyhow!("PORT is not a valid u16: {e}"))?;
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.
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();
}
Good — workspace lib.rs / main.rs:
#![warn(
clippy::all,
clippy::pedantic,
clippy::nursery,
clippy::cargo,
clippy::unwrap_used,
clippy::expect_used,
missing_docs,
)]
# .github/workflows/ci.yml
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- run: cargo test --workspace
# rust-toolchain.toml — pin compiler so a release can't break you
[toolchain]
channel = "1.78.0"
components = ["rustfmt", "clippy"]
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(...)].
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) };
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) };
// In lib.rs
#![deny(unsafe_code)] // forbid unsafe entirely in this crate
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.
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)?)
}
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>;
Good — binary:
use anyhow::{Context, Result};
fn main() -> Result<()> {
let cfg = load_config().context("loading config")?;
run(cfg).context("running CLI")?;
Ok(())
}
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.
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
}
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(())
}
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 */ }
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.
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
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 }
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.
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>> }
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..] }
}
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.
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"
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"
# deny.toml — cargo deny check
[advisories]
vulnerability = "deny"
unmaintained = "warn"
yanked = "deny"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"]
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.
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
}
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());
}
}
// 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);
}
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.
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))
}
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))
}
// In the binary, install a subscriber once
fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.json()
.init();
}
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.
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
}
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();
}
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.
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 Pack → https://oliviacraftlat.gumroad.com/l/skdgt
Top comments (0)