Claude Code with Rust: Cargo, Ownership, Async, Tests
Using Claude Code with Rust without fighting the compiler
Rust is the language where the gap between "looks plausible" and "actually compiles" is widest. The borrow checker is unmoved by intent, the type system rejects vibes, and fixing one compiler error often surfaces three more.
This is exactly why Claude Code is a strong fit for Rust, once configured properly. The compile-test-iterate loop is fast and deterministic. Claude can run cargo build, read the diagnostic, and adjust. The catch is that Claude's training data spans a decade of Rust, including pre-1.0 code, pre-async/await syntax, and deprecated crate APIs. Without a project-specific CLAUDE.md, Claude mixes Box<dyn Error> with anyhow, defaults to cloning to dodge the borrow checker, spawns tokio tasks without thinking about the Send bound, and writes tests with #[test] on async functions, which does not work.
This guide is the configuration that fixes those defaults. If you have not yet installed Claude Code, the Claude Code setup guide covers installation.
The Rust CLAUDE.md template
Place this CLAUDE.md at the root of your Cargo project or workspace. Claude reads it before every session and uses it as the source of truth for project conventions. This template assumes tokio for async and anyhow plus thiserror for error handling, which covers the majority of modern Rust applications.
# Rust project rules
## Stack
- Rust edition: 2024
- MSRV: 1.85
- Async runtime: tokio (full features)
- Error handling: anyhow at application boundaries, thiserror for library errors
- Serialization: serde with serde_json
- Logging: tracing with tracing-subscriber
- Testing: cargo test with tokio::test for async tests
## Project layout
- Single crate: src/main.rs or src/lib.rs, modules in src/<name>.rs or src/<name>/mod.rs
- Workspace: top-level Cargo.toml with [workspace], members in crates/
- Integration tests: tests/<name>.rs (each file is a separate binary)
- Unit tests: #[cfg(test)] mod tests {} at the bottom of each module
- Examples: examples/<name>.rs, run with cargo run --example <name>
- Benchmarks: benches/<name>.rs (criterion if added)
## Cargo conventions
- Pin direct dependencies to a minor version (e.g. tokio = "1.40", not "1")
- Dev dependencies stay in [dev-dependencies], never in [dependencies]
- Use [features] for optional integrations, default = []
- Run cargo fmt before every commit, cargo clippy --all-targets -- -D warnings as a gate
## Ownership defaults
- Take &str over String in function arguments unless ownership is required
- Take &[T] over Vec<T> in function arguments unless ownership is required
- Return owned types from constructors and builders
- Use Cow<'_, str> when a function sometimes allocates and sometimes does not
- Avoid .clone() to silence the borrow checker, restructure the function instead
## Error handling rules
- Library code: define a thiserror enum, return Result<T, MyError>
- Application code: anyhow::Result<T> at main and binary entry points
- Convert errors at the boundary using ? with #[from] thiserror variants
- NEVER use unwrap() or expect() outside of tests, examples, and Cargo.toml-validated code
- Use anyhow::Context::context for human-readable error chains in app code
## Async rules
- All async functions return Result<T, E> unless they truly cannot fail
- Use tokio::spawn for fire-and-forget tasks, JoinHandle for awaited tasks
- Pass owned values into spawned tasks, not references
- Use tokio::sync::mpsc for task-to-task channels, oneshot for single-response
- Hold no .await across a Mutex guard, use tokio::sync::Mutex if you must
## Hard rules
- No unwrap() or expect() in non-test code
- No panic! in library code, return errors
- No unsafe without a SAFETY comment explaining the invariants
- No #[allow(...)] without a comment explaining why
- All public items have doc comments with /// (rustdoc)
The template is deliberately specific. "Take &str over String" sounds obvious but is exactly the rule Claude needs in writing, because the training data contains plenty of code that takes String arguments unnecessarily. The "no .clone() to silence the borrow checker" rule is even more important. Without it, Claude's path of least resistance is to call .clone() on every value the borrow checker complains about, producing code that compiles but allocates for no reason.
Cargo workflow that Claude can drive
Cargo is one of Rust's biggest wins for AI-assisted development. The commands are stable, the output is structured, and the success criteria are unambiguous. The CLAUDE.md should make the workflow explicit so Claude does not invent its own.
# Starting a new binary crate
cargo new my-app
cd my-app
# Starting a new library crate
cargo new --lib my-lib
# Workspace setup (top-level Cargo.toml)
mkdir my-workspace && cd my-workspace
cat > Cargo.toml <<'EOF'
[workspace]
members = ["crates/*"]
resolver = "2"
EOF
mkdir crates
cargo new --lib crates/core
cargo new --bin crates/cli
The distinction between runtime and dev dependencies is one of the patterns Claude gets wrong without an explicit rule. A typical async application Cargo.toml:
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[dependencies]
tokio = { version = "1.40", features = ["full"] }
anyhow = "1.0"
thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tokio = { version = "1.40", features = ["full", "test-util"] }
mockall = "0.12"
proptest = "1.5"
criterion = { version = "0.5", features = ["async_tokio"] }
[features]
default = []
postgres = ["dep:sqlx", "sqlx/postgres"]
[[bin]]
name = "my-app"
path = "src/main.rs"
Two things to flag. The tokio dev-dependencies entry adds test-util on top of runtime features, which enables tokio::time::pause(). Claude tends to add test-util to main dependencies (costs binary size) or skip it (breaks deterministic time tests). Optional dependencies under [features] use the dep: prefix in 2024 edition, which Claude sometimes misses.
The development loop Claude runs in is short:
cargo check # fast type-check, no codegen
cargo build # full debug build
cargo test # all unit and integration tests
cargo clippy --all-targets -- -D warnings # treat lints as errors
cargo fmt --check # verify formatting in CI
cargo doc --no-deps --open # generate docs locally
Claude will run cargo check aggressively as a fast feedback signal, only running cargo build and cargo test when checking compiles cleanly. Allowing all of these via the Claude Code permissions configuration means the iteration loop runs without asking for approval each time.
Ownership and borrow patterns
The borrow checker is where Claude Code goes off the rails most often without guidance. Cloning everything, wrapping in Arc<Mutex<...>>, using unsafe, holding everything in Rc. None of these are the idiomatic answer for a normal application.
Add to CLAUDE.md:
## Borrow patterns to use
### Function arguments: prefer borrows
fn parse_config(input: &str) -> Result<Config, ConfigError> { ... }
fn count_items(items: &[Item]) -> usize { ... }
fn update_record(record: &mut Record, value: i32) { ... }
### Builder pattern for owned construction
impl ConfigBuilder {
pub fn new() -> Self { ... }
pub fn timeout(mut self, t: Duration) -> Self { self.timeout = Some(t); self }
pub fn build(self) -> Result<Config, ConfigError> { ... }
}
### Cow for sometimes-allocating returns
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains('A') {
Cow::Owned(input.to_lowercase())
} else {
Cow::Borrowed(input)
}
}
### Iterators over collecting
items.iter().filter(|i| i.active).map(|i| i.id).collect::<Vec<_>>()
// Prefer returning impl Iterator from internal functions when the caller will iterate
### Lifetime elision: let the compiler infer
fn first_word(s: &str) -> &str { ... } // good
fn first_word<'a>(s: &'a str) -> &'a str { ... } // unnecessary
## Borrow patterns to avoid
- Arc<Mutex<T>> for single-threaded code, use RefCell<T> or restructure
- .clone() to dodge the borrow checker, restructure the function
- 'static lifetimes on struct fields unless data is genuinely static
- Box<dyn Trait> when generics work, monomorphize where possible
These rules tell Claude what the right answer looks like before the borrow checker forces a decision. With "prefer borrows" in CLAUDE.md, Claude writes &str from the start. When the borrow checker complains at a call site, Claude restructures rather than reaching for .clone().
The Cow<'_, str> pattern is idiomatic Rust and chronically underused by Claude without prompting. Functions that sometimes allocate (lowercase, escape, normalize) and sometimes do not benefit from Cow over String, but the training data is full of examples that always return String. The "let the compiler infer lifetimes" rule prevents a separate category of clutter from over-annotation.
Error handling: anyhow vs thiserror
Rust has no single consensus error-handling crate. The de facto standard for modern code is anyhow for application-level errors and thiserror for library-level errors, but Claude Code without configuration will produce a mix of Box<dyn std::error::Error>, hand-rolled enum errors, Result<T, String>, and occasional panic! calls where errors should be returned.
Library code (a crate that other crates depend on) defines a domain-specific error type so callers can match on it:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config file not found: {0}")]
NotFound(String),
#[error("invalid syntax in config: {message}")]
InvalidSyntax { message: String },
#[error("io error reading config")]
Io(#[from] std::io::Error),
#[error("json parse error")]
Json(#[from] serde_json::Error),
}
pub fn load_config(path: &str) -> Result<Config, ConfigError> {
let raw = std::fs::read_to_string(path)
.map_err(|_| ConfigError::NotFound(path.to_string()))?;
let config: Config = serde_json::from_str(&raw)?;
Ok(config)
}
The #[from] attributes let ? automatically convert std::io::Error and serde_json::Error into ConfigError. Callers can match on specific variants to handle different failures.
Application code (a binary that nobody depends on) uses anyhow because the consumer of the error is a human:
use anyhow::{Context, Result};
#[tokio::main]
async fn main() -> Result<()> {
let config = load_config("config.json")
.context("loading config.json at startup")?;
let client = build_client(&config)
.await
.context("building http client from config")?;
run_server(client, &config)
.await
.context("running server loop")?;
Ok(())
}
Result<T> from anyhow is Result<T, anyhow::Error>, which type-erases the underlying error and adds a context chain. When a failure occurs deep in the call stack, the printed error includes every .context(...) annotation, making the failure path obvious without a debugger.
The CLAUDE.md rule is simple: in lib.rs-style code use thiserror, in main.rs-style code use anyhow, and #[from] handles the boundary. The "never unwrap()" rule is the other half. unwrap() is fine in tests, but it is the wrong default in production. With the rule in place, Claude reaches for ?. For more on surfacing mistakes early, see the Claude Code debugging guide.
Async with tokio
Async Rust has specific footguns that Claude falls into without configuration. The training data contains both async-std and tokio examples, pre-async/await Future combinators, and tokio 0.2 patterns that differ from current tokio 1.x. The rule is tokio 1.x with async/await syntax, full stop. Add to CLAUDE.md:
## Async conventions
### Entry point: tokio::main
use tokio;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// application code
Ok(())
}
### Spawning tasks
let handle = tokio::spawn(async move {
// owned data only - move keyword required
do_work(owned_data).await
});
let result = handle.await?;
### Channels for task communication
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel::<Event>(32);
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
process(event).await;
}
});
tx.send(Event::new()).await?;
### Oneshot for single-response
use tokio::sync::oneshot;
let (tx, rx) = oneshot::channel::<Result<Response>>();
tokio::spawn(async move { tx.send(do_request().await).ok(); });
let response = rx.await??;
### Concurrent execution: join, try_join, select
use tokio::try_join;
let (a, b) = try_join!(fetch_a(), fetch_b())?;
use tokio::select;
select! {
res = long_request() => handle(res),
_ = tokio::time::sleep(Duration::from_secs(5)) => timeout(),
}
### Mutex rules
- Prefer message passing over shared state
- If shared state required: tokio::sync::Mutex (not std::sync::Mutex)
- NEVER hold a Mutex guard across an .await point
- For read-heavy: tokio::sync::RwLock
The move keyword on tokio::spawn closures is one of the most common things Claude gets subtly wrong. Without move, the closure tries to borrow from the surrounding scope, which fails because the spawned task can outlive the borrow. The "pass owned values into spawned tasks" rule addresses this.
The "no Mutex guard across .await" rule prevents a category of deadlock and Send-bound bug. A std::sync::MutexGuard is !Send, so holding it across an .await point makes the entire async block !Send, which cannot be spawned on the multi-threaded runtime. The error message is famously confusing. try_join! is the idiomatic way to run multiple futures concurrently with error short-circuiting, but Claude often generates sequential .await chains where parallel would work.
For how this translates to other systems languages, the Claude Code with Go guide covers equivalent rules for Go.
Testing Rust with Claude Code
Rust's built-in test framework via cargo test is a strong fit for AI-assisted development. The test runner is fast, the output is structured, and tests live next to the code they test. The conventions Claude needs are about which kind of test to write and how to handle async.
Add to CLAUDE.md:
## Testing conventions
### Unit tests at the bottom of the module
// src/parser.rs
pub fn parse(input: &str) -> Result<Ast, ParseError> { ... }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_expression() {
let result = parse("1 + 2");
assert!(result.is_ok());
}
#[test]
fn rejects_invalid_syntax() {
assert!(matches!(parse("1 +"), Err(ParseError::Unexpected(_))));
}
}
### Async tests use tokio::test, not test
#[tokio::test]
async fn fetches_user_by_id() {
let user = fetch_user(42).await.unwrap();
assert_eq!(user.id, 42);
}
### Integration tests in tests/ directory
// tests/api_integration.rs
use my_app::client::Client;
#[tokio::test]
async fn full_request_lifecycle() {
let client = Client::new("http://localhost:8080");
let resp = client.get("/health").await.unwrap();
assert_eq!(resp.status(), 200);
}
### Test helpers in a common module
// tests/common/mod.rs
pub fn setup_test_db() -> TestDb { ... }
// tests/api_integration.rs
mod common;
use common::setup_test_db;
### Property tests with proptest
use proptest::prelude::*;
proptest! {
#[test]
fn parse_then_render_is_identity(s in "[a-z]{1,10}") {
let parsed = parse(&s).unwrap();
let rendered = render(&parsed);
prop_assert_eq!(rendered, s);
}
}
The #[tokio::test] versus #[test] rule is the most common test-related thing Claude gets wrong. #[test] does not run an async function, the test simply does not execute the future. The compiler does not catch this because returning () from an async function compiles fine, so the test silently becomes a no-op. With the rule in CLAUDE.md, Claude uses #[tokio::test] for any async test.
Integration tests in the tests/ directory differ from unit tests in two ways. Each file compiles to a separate binary, which is slower but provides isolation, and they can only test the public API of the crate. Claude tends to put everything in unit tests by default, which gets messy at scale. For broader patterns on getting Claude to write tests that verify behavior rather than just exercising the happy path, the Claude Code testing guide covers test-design principles that apply across languages.
Hard rules and where Claude needs review
Some rules need to be explicit because they prevent failure modes a code review would otherwise catch.
## Hard rules
- No unwrap() or expect() in non-test code (use ? or proper error handling)
- No panic! in library code (return errors)
- No unsafe without a SAFETY comment explaining the invariants
- No #[allow(...)] without a comment explaining why
- All public items have rustdoc /// comments
- cargo clippy --all-targets -- -D warnings must pass before commit
- cargo fmt must pass before commit
- No new dependencies without checking the audit status (cargo audit)
These are non-negotiable. Without them, Claude generates code that passes the type checker but fails clippy, public functions with no documentation, and #[allow(unused_imports)] sprinkled around to silence warnings rather than removing the unused imports.
Two areas warrant manual review even with the configuration above. The first is unsafe blocks. If Claude reaches for unsafe, review it carefully. The SAFETY comment requirement alone does not validate that the invariants are actually upheld. The second is generic bounds on async functions. Async lifetime and Send/Sync bounds are some of the trickiest type-system territory in Rust, and Claude can produce signatures that compile in isolation but fail when used in a multi-threaded runtime. If Claude generates a generic async function, run it through a representative caller and confirm it compiles in context.
For the configuration principles that apply across all language-specific setups, the CLAUDE.md explained guide covers structure and Claude Code best practices covers workflow habits.
Building Rust with confidence
The Rust CLAUDE.md template above produces a development environment where Claude takes borrows over owned values, returns proper Result types, uses tokio patterns correctly, writes async tests with #[tokio::test], and runs cargo clippy and cargo fmt as part of the loop. The compile-test-iterate cycle is fast enough that Claude can fix its own mistakes before they compound.
The principle is the same as in any language-specific configuration: Claude operates at the level of the context it has been given. A Rust project without CLAUDE.md produces Claude that clones aggressively, mixes error crates, and writes tests that do not run. A project with the configuration above produces Claude that compiles first time more often than not. Claudify ships with a Rust-specific CLAUDE.md template pre-configured for tokio, anyhow, thiserror, and the cargo conventions above.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify