Ask Claude Code to "add a small async cache to this crate" and the default output looks fine: a tokio::spawn, an Arc<Mutex<HashMap<...>>>, a .unwrap() on the lock, a ? that flattens five error types into Box<dyn Error>. It compiles. It passes cargo test. Then it deadlocks across .await, panics on the first miss in prod, and the error reads Custom { kind: Other, error: "..." }.
The borrow checker stops a lot, but it doesn't stop unwrap(), fire-and-forget tasks, or unsafe blocks with no // SAFETY: comment. A CLAUDE.md next to Cargo.toml is the cheapest leverage you have on a Rust codebase.
Get the full CLAUDE.md Rules Pack — oliviacraftlat.gumroad.com/l/skdgt. The 13 rules below are a free preview.
1. Own at the API boundary, borrow inside
The most common AI mistake in Rust public APIs is leaking lifetimes: pub fn parse<'a>(s: &'a str) -> Doc<'a> when the caller wants an owned Doc. Public signatures take and return owned types (String, Vec<T>, PathBuf); borrowing stays inside the impl.
// ✅ owned in, owned out — caller lifetimes don't leak in
pub fn parse(input: &str) -> Result<Doc, ParseError> { ... }
// ❌ AI default — every caller now juggles 'a
pub fn parse<'a>(input: &'a str) -> Result<Doc<'a>, ParseError<'a>> { ... }
Use Cow<'_, str> only when the zero-copy path actually matters and you've measured. Default: String.
2. Errors: thiserror for libraries, anyhow for binaries
Libraries return a typed enum deriving thiserror::Error — #[from] for transparent conversion, #[source] for chains. Binaries return anyhow::Result<T> with .with_context(|| ...)? at every I/O boundary. Mixing them — anyhow in lib.rs, Box<dyn Error> elsewhere — strips callers of any way to branch on the error.
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("user {0} not found")]
NotFound(i64),
#[error("database error")]
Db(#[from] sqlx::Error),
}
fn main() -> anyhow::Result<()> {
Store::open(&path).with_context(|| format!("opening {path:?}"))?;
Ok(())
}
No Box<dyn Error> in pub signatures.
3. unsafe is forbidden by default — opt in with // SAFETY:
#![deny(unsafe_code)] lives at every crate root. When a module genuinely needs unsafe, scope it with #![allow(unsafe_code)] at the top of that file only. Every unsafe { ... } block has a // SAFETY: comment explaining which invariants hold; every unsafe fn documents its preconditions in a # Safety doc section.
// SAFETY: `ptr` came from `Box::into_raw` above, untouched since; non-null,
// properly aligned, and we own the allocation.
let value = unsafe { Box::from_raw(ptr) };
Hard to write the comment? The unsafe is wrong.
4. No unwrap() or expect() in production paths
unwrap() is a panic with no message. CI greps for \.unwrap\(\) and \.expect\( on *.rs outside tests/, examples/, benches/ and fails the build. expect("...") is allowed only when the message documents an invariant the type system can't express — "checked above by the regex match," not "should never fail."
// ❌
let port: u16 = std::env::var("PORT").unwrap().parse().unwrap();
// ✅
let port: u16 = std::env::var("PORT")
.context("PORT must be set")?
.parse().context("PORT must be a u16")?;
Library code: return a typed error. Binaries: anyhow it.
5. No panic!, todo!, unimplemented! reachable from pub APIs
Library code returns Result; it does not panic for runtime conditions. todo!() and unimplemented!() are scaffolding — CI fails if any reach a pub item. Indexing user-controlled offsets panics on miss — prefer .get(i).
fn first_word(s: &str) -> &str { s.split_whitespace().next().unwrap() } // ❌
fn first_word(s: &str) -> Option<&str> { s.split_whitespace().next() } // ✅
Untrusted integer math uses checked_*/saturating_*/wrapping_* explicitly — debug-only overflow checks are not a security boundary.
6. Async: one runtime, no Mutex guard across .await
One async runtime per process — tokio, multi-thread for servers, current_thread for CLIs and tests. Never hold a std::sync::Mutex guard across .await: the compiler may not catch it; the runtime deadlock will. Use tokio::sync::Mutex when the guard must span an await, or restructure to drop it first.
// ❌ blocks the runtime, can deadlock
let g = state.lock().unwrap();
let row = db.fetch(g.id).await?;
// ✅ scope the std lock, then await
let id = { state.lock().unwrap().id };
let row = db.fetch(id).await?;
Spawned tasks keep their JoinHandle and are awaited or aborted on shutdown. Long loops honor cancellation via tokio::select!.
7. Logging is tracing, structured, never println!
println! and eprintln! are forbidden in library code. Use tracing with structured fields and #[tracing::instrument(skip(secrets))] on functions whose arguments include credentials or large payloads. Levels: debug opt-in, info steady state, warn needs human follow-up, error pages someone.
#[tracing::instrument(skip(pool), fields(user_id = %id))]
async fn charge(pool: &PgPool, id: i64, amount: Money) -> Result<(), ChargeError> {
tracing::info!(?amount, "charging user");
Ok(())
}
JSON output in production. Never log secrets or PII — #[serde(skip)] secret fields, redact at the boundary.
8. Module visibility is intentional — pub is a contract
pub items are part of the crate's semver contract. Default everything to private; promote to pub(crate) when another module needs it; mark pub only what you commit to keeping. AI defaults to pub on every new fn and struct — every refactor becomes a breaking change.
pub(crate) fn normalize_path(p: &Path) -> PathBuf { ... } // ✅ scoped
pub struct Config { ... } // ✅ semver applies
pub fn internal_helper(x: &str) -> String { ... } // ❌ accidental commitment
Re-export the public surface from lib.rs. Module trees are implementation detail.
9. Custom error types — Display reads like a sentence
A typed error enum lives close to the type that produces it. Its Display is one short lowercase sentence — no trailing period, no leading "Error:" — tracing and anyhow frame it. Never match on error strings; the type system is right there.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config file not found at {0}")]
Missing(PathBuf),
#[error("invalid TOML in {path}: {source}")]
Parse { path: PathBuf, #[source] source: toml::de::Error },
}
Branch with if let ConfigError::Missing(p) = err, never err.to_string().contains(...).
10. Tests: unit inside the module, integration in tests/
Unit tests live in a #[cfg(test)] mod tests { use super::*; ... } block — they touch private items. Integration tests live in tests/; each tests/foo.rs is a separate crate that imports only the public API. That split catches "I made it pub(crate) but the integration test can't see it" before the user does.
// src/parse.rs
#[cfg(test)]
mod tests {
use super::*;
#[test] fn empty_input() { assert!(tokenize("").is_empty()); }
}
// tests/end_to_end.rs — only public API
use mycrate::parse;
#[test] fn parses_a_real_doc() { parse(SAMPLE).unwrap(); }
Async tests use #[tokio::test]. Repository tests run against a real database via testcontainers — mocked queries pass while broken SQL ships. CI runs cargo test with and without --all-features so feature-gated code compiles both ways.
11. clippy::pedantic clean, no blanket #[allow]
Crate root: #![warn(clippy::pedantic, clippy::nursery, missing_docs, rust_2018_idioms)]. CI runs cargo clippy --all-targets --all-features -- -D warnings and cargo fmt -- --check. Warnings are errors. No blanket #[allow] — narrow allows on the offending item only, with a comment explaining why.
// ✅ scoped, justified
#[allow(clippy::cast_possible_truncation)] // bounded above by MAX_FRAME (u32::MAX)
fn frame_index(offset: u64) -> u32 { (offset / FRAME) as u32 }
Can't justify it in a sentence? Fix the code.
12. Workspace structure: thin binary, fat library, one Cargo.lock
An 800-line src/main.rs is an AI smell. The library does the work and exposes a typed API; the binary parses arguments, builds config, and calls the library. Multi-crate workspaces share one Cargo.lock at the root and pin shared deps under [workspace.dependencies] so members can't drift.
# Cargo.toml (workspace root)
[workspace]
members = ["crates/core", "crates/cli", "crates/wasm"]
resolver = "2"
[workspace.dependencies]
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
tracing = "0.1"
Library crates set default-features = false on every dep. WASM targets gate Instant, threads, and blocking I/O behind #[cfg(not(target_arch = "wasm32"))].
13. Docs with examples that compile
Every pub item carries a /// summary on the first line. Functions returning Result document # Errors; functions that can panic document # Panics. Every pub fn has a # Examples block — cargo test --doc runs it. Doc-tests are the canary for API drift.
/// Parses a TOML config file from `path`.
///
/// # Errors
/// [`ConfigError::Missing`] if `path` doesn't exist;
/// [`ConfigError::Parse`] if the file is not valid TOML.
///
/// # Examples
/// ```
{% endraw %}
/// # use mycrate::{load_config, ConfigError};
/// let cfg = load_config("examples/sample.toml")?;
/// assert_eq!(cfg.port, 8080);
/// # Ok::<(), ConfigError>(())
///
{% raw %}
pub fn load_config(path: impl AsRef
CI runs `cargo doc --no-deps -- -D warnings`. Missing docs and broken intra-doc links fail the build.
## A starter `CLAUDE.md` snippet
```markdown
# CLAUDE.md — Rust crate
## Stack
- Rust stable, edition 2021, MSRV pinned in `Cargo.toml`
- thiserror, anyhow, tokio, tracing, sqlx, testcontainers
## Hard rules
- Public APIs take/return owned types. Borrow inside the impl.
- Libs: `thiserror` enum errors. Bins: `anyhow::Result` + `.context(...)`.
- `#![deny(unsafe_code)]` at crate root. `unsafe` blocks need `// SAFETY:`.
- No `unwrap()`/`expect()` outside tests. CI greps and fails.
- No `panic!`/`todo!`/`unimplemented!` reachable from `pub` APIs.
- One async runtime. Never hold `std::sync::Mutex` across `.await`.
- `tracing` for logging, JSON in prod. No `println!` in libs.
- Default-private. `pub(crate)` before `pub`. `pub` = semver commitment.
- Unit tests in-module, integration in `tests/`. Real DB via testcontainers.
- `cargo clippy --all-targets --all-features -- -D warnings` clean.
- Thin binary, fat library. One `Cargo.lock` at workspace root.
- Every `pub` item: `///` summary, `# Errors`/`# Panics`/`# Examples`.
What Claude gets wrong without these rules
-
unwrap()andBox<dyn Error>everywhere — every error becomes a string. -
std::sync::Mutexguard held across.await— runtime deadlock. -
tokio::spawnwith noJoinHandle— tasks outlive the request. - Every new fn marked
pub— every refactor breaks semver. -
unsafeblocks with no// SAFETY:and stale assumptions.
Drop the 13 rules above into CLAUDE.md and the next AI PR looks like the codebase, not a tutorial. cargo clippy -- -D warnings stays green.
Want this for 20+ stacks with 200+ rules ready to paste? Grab the CLAUDE.md Rules Pack at oliviacraftlat.gumroad.com/l/skdgt.
— Olivia (@OliviaCraftLat)
Top comments (0)