Rust Expert
Apply idiomatic Rust patterns with strong safety, performance, and maintainability guarantees.
Core Principles
-
Model correctness through the type system; make invalid states unrepresentable.
-
Prefer explicit over implicit; surface ownership intent in every signature.
-
Write minimal, zero-cost abstractions — no allocation or indirection that serves no purpose.
-
Test behavior, not implementation; favor integration tests at crate boundaries.
- Ownership, Borrowing, and Lifetimes
Ownership Patterns
// Prefer moves when value is consumed; prefer borrows when value is shared. fn process(data: Vec<u8>) -> Vec<u8> { /* consumes / } fn inspect(data: &[u8]) -> usize { data.len() / borrows */ }
// Clone only when necessary and explicitly documented. // Anti-pattern: clone() to silence borrow checker errors — fix the design instead.
Borrowing Rules (enforce at design time)
-
Only one mutable reference OR any number of immutable references at a time.
-
References must not outlive the value they point to.
-
Use Cow<'a, T> when you need borrow-or-own semantics without forcing allocation.
use std::borrow::Cow;
fn normalize(s: &str) -> Cow<str> { if s.contains(' ') { Cow::Owned(s.replace(' ', "_")) } else { Cow::Borrowed(s) } }
Lifetime Patterns
// Explicit lifetime annotation: only when compiler cannot infer. struct Parser<'input> { source: &'input str, pos: usize, }
impl<'input> Parser<'input> { fn next_token(&mut self) -> &'input str { // Returns a slice of the original input — lifetime ties result to source. &self.source[self.pos..] } }
// RPIT (Return Position Impl Trait) avoids lifetime noise in many cases. fn words(s: &str) -> impl Iterator<Item = &str> { s.split_whitespace() }
Anti-Patterns to Avoid
-
clone() inside hot loops to avoid borrow checker.
-
unsafe to bypass lifetime checks — redesign instead.
-
'static bounds that force heap allocation when borrowing suffices.
-
Storing &mut T in structs — prefer owned data or RefCell<T> .
- Error Handling
Decision Matrix
Scenario Tool
Library crate errors thiserror — typed, composable
Application / binary errors anyhow — ergonomic ? chaining
Domain-specific context Custom enum via thiserror
Infallible conversions From / Into — zero overhead
thiserror (Library Crates)
use thiserror::Error;
#[derive(Debug, Error)] pub enum ConfigError { #[error("missing required field: {field}")] MissingField { field: &'static str }, #[error("invalid value for {field}: {source}")] ParseError { field: &'static str, #[source] source: std::num::ParseIntError }, #[error(transparent)] Io(#[from] std::io::Error), }
anyhow (Application / Binary)
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading config from {path}"))?; toml::from_str(&raw).context("parsing config TOML") }
Error Propagation Rules
-
Never use .unwrap() in library code; use .expect() only in tests and main() where the invariant is self-evident.
-
Add .context() / .with_context() at every error boundary to preserve the call chain.
-
Map errors at crate boundaries — do not leak internal error types in public APIs.
- Trait System
Generics vs Trait Objects
// Prefer generics (monomorphized, zero-cost) when types are known at compile time. fn serialize<S: Serialize>(value: &S) -> Vec<u8> { /* ... */ }
// Use dyn Trait only when you need heterogeneous collections or runtime dispatch. fn handlers() -> Vec<Box<dyn EventHandler>> { /* ... */ }
Where Clauses
// Prefer where clauses for readability when bounds are complex. fn merge<K, V>(a: HashMap<K, V>, b: HashMap<K, V>) -> HashMap<K, V> where K: Eq + Hash, V: Clone, { /* ... */ }
Blanket Implementations and Orphan Rules
-
Implement standard traits (Display , From , Iterator ) for your types.
-
Respect the orphan rule: you may only implement a foreign trait for a local type.
-
Use the newtype pattern to work around orphan restrictions.
struct Wrapper(Vec<u8>);
impl fmt::Display for Wrapper { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self.0) } }
Rust 1.75+ Async in Traits (RPITIT)
// Stable as of Rust 1.75 — no more async-trait macro needed. trait DataSource { async fn fetch(&self, id: u64) -> anyhow::Result<Record>; }
// Return-Position Impl Trait in Traits (RPITIT) — also stable in 1.75. trait Transformer { fn transform(&self, input: &str) -> impl Iterator<Item = String>; }
- Async / Await with Tokio
Tokio Runtime Setup
#[tokio::main] async fn main() -> anyhow::Result<()> { // Default: multi-thread runtime (all CPU cores). Ok(()) }
// Single-thread runtime for I/O-only workloads. #[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { Ok(()) }
Task Spawning
use tokio::task;
// Spawn a non-blocking async task. let handle = task::spawn(async move { fetch_data(url).await }); let result = handle.await?; // propagate JoinError
// CPU-bound work must go to the blocking thread pool — never block the async runtime. let result = task::spawn_blocking(|| { expensive_cpu_computation() }).await?;
Channels
use tokio::sync::{mpsc, oneshot, broadcast, watch};
// mpsc: producer → consumer pipeline. let (tx, mut rx) = mpsc::channel::<Bytes>(1024);
// oneshot: request / response pattern. let (resp_tx, resp_rx) = oneshot::channel::<Result<Record>>();
// broadcast: fan-out to multiple subscribers. let (tx, _rx) = broadcast::channel::<Event>(16);
// watch: latest-value semantics (config reload, health state). let (tx, rx) = watch::channel(Config::default());
select! and Cancellation
use tokio::select; use tokio_util::sync::CancellationToken;
async fn worker(token: CancellationToken) { select! { _ = token.cancelled() => { // Structured cancellation — always handle shutdown path. } result = do_work() => { // Normal completion. } } }
Structured Concurrency
use tokio::task::JoinSet;
async fn fetch_all(urls: Vec<String>) -> Vec<anyhow::Result<Bytes>> { let mut set = JoinSet::new(); for url in urls { set.spawn(fetch(url)); } let mut results = Vec::new(); while let Some(res) = set.join_next().await { results.push(res.expect("task panicked")); } results }
Async Anti-Patterns
-
std::thread::sleep inside async context — use tokio::time::sleep .
-
Holding MutexGuard across .await — use tokio::sync::Mutex instead.
-
Blocking I/O (file read, DNS) on async thread — use spawn_blocking or tokio::fs .
-
Unbounded channels — always set a capacity bound.
- Common Crates
serde — Serialization
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { pub id: u64, pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option<String>, }
axum — HTTP Servers
use axum::{extract::{Path, State}, routing::get, Json, Router};
async fn get_user( State(db): State<DbPool>, Path(id): Path<u64>, ) -> Result<Json<User>, AppError> { let user = db.find_user(id).await?; Ok(Json(user)) }
let app = Router::new() .route("/users/:id", get(get_user)) .with_state(db_pool);
sqlx — Async Database
use sqlx::PgPool;
async fn insert_user(pool: &PgPool, name: &str) -> sqlx::Result<i64> { let row = sqlx::query!("INSERT INTO users (name) VALUES ($1) RETURNING id", name) .fetch_one(pool) .await?; Ok(row.id) }
reqwest — HTTP Client
use reqwest::Client;
async fn fetch_json<T: for<'de> serde::Deserialize<'de>>( client: &Client, url: &str, ) -> anyhow::Result<T> { Ok(client.get(url).send().await?.error_for_status()?.json().await?) }
rayon — Data Parallelism
use rayon::prelude::*;
fn parallel_sum(data: &[f64]) -> f64 { data.par_iter().sum() }
clap — CLI
use clap::Parser;
#[derive(Parser, Debug)] #[command(version, about)] struct Args { #[arg(short, long, default_value = "8080")] port: u16,
#[arg(short, long, env = "CONFIG_PATH")]
config: std::path::PathBuf,
}
- Performance Optimization
Zero-Cost Abstractions
-
Prefer iterators over manual index loops — they compile to identical machine code.
-
Use #[inline] for hot, small functions that cross crate boundaries.
-
Prefer stack allocation; move to heap (Box , Vec , Arc ) only when necessary.
SIMD
// Use std::simd (nightly) or portable-simd crate for explicit vectorization. // Profile first — LLVM auto-vectorizes most iterator chains. use std::simd::{f32x8, SimdFloat};
fn dot_product_simd(a: &[f32], b: &[f32]) -> f32 { a.chunks_exact(8) .zip(b.chunks_exact(8)) .map(|(a_chunk, b_chunk)| { let va = f32x8::from_slice(a_chunk); let vb = f32x8::from_slice(b_chunk); (va * vb).reduce_sum() }) .sum() }
Profiling
flamegraph (install: cargo install flamegraph)
cargo flamegraph --bin my-app
perf stat for CPU counters (Linux)
perf stat cargo run --release
heaptrack for heap allocation analysis (Linux)
heaptrack cargo run --release
criterion for micro-benchmarks
cargo bench
Allocation Awareness
// Preallocate when size is known. let mut v = Vec::with_capacity(expected_len);
// String building: use write! into a pre-allocated String. use std::fmt::Write; let mut s = String::with_capacity(256); write!(s, "id={}", id)?;
// Avoid format!() in hot paths — prefer direct write!() or push_str().
- Testing
Unit Tests
#[cfg(test)] mod tests { use super::*;
#[test]
fn parse_valid_config() {
let cfg = Config::from_str("[server]\nport = 9000").unwrap();
assert_eq!(cfg.port, 9000);
}
#[test]
fn parse_invalid_port_returns_error() {
let result = Config::from_str("[server]\nport = -1");
assert!(result.is_err());
}
}
Integration Tests
// tests/integration/server.rs — tests the full public API. #[tokio::test] async fn health_endpoint_returns_200() { let addr = spawn_test_server().await; let resp = reqwest::get(format!("http://{addr}/health")).await.unwrap(); assert_eq!(resp.status(), 200); }
Property-Based Testing (proptest)
use proptest::prelude::*;
proptest! { #[test] fn encode_decode_roundtrip(data: Vec<u8>) { let encoded = encode(&data); prop_assert_eq!(decode(&encoded).unwrap(), data); } }
Async Tests
#[tokio::test] async fn fetch_returns_data() { let mock = MockServer::start().await; Mock::given(method("GET")).respond_with(ResponseTemplate::new(200)).mount(&mock).await; let result = fetch(&mock.uri()).await; assert!(result.is_ok()); }
Test Conventions
-
Test one behavior per test function.
-
Use descriptive names: <function><scenario><expected> .
-
Mock only external I/O boundaries (HTTP, filesystem, database).
-
Run cargo test -- --nocapture for diagnostic output during development.
-
Run cargo nextest run for faster parallel test execution in CI.
- Unsafe Rust
When to Use
-
FFI boundaries (calling C functions, exporting to C).
-
Low-level memory mapping or SIMD intrinsics when safe abstractions cannot reach.
-
Performance-critical code where the safe alternative has measurable overhead (proved by profiling).
Guidelines
/// # Safety
///
/// - ptr must be non-null and properly aligned for T.
/// - The memory at ptr must be valid for len elements of type T.
/// - The caller must ensure no other mutable references to the memory exist.
pub unsafe fn from_raw_parts<T>(ptr: *const T, len: usize) -> &'static [T] {
std::slice::from_raw_parts(ptr, len)
}
-
Every unsafe block must have a // SAFETY: comment explaining why it is sound.
-
Minimize the scope of unsafe — wrap in a safe abstraction immediately.
-
Prefer unsafe fn over unsafe block inside a safe fn when the entire function is unsafe.
-
Use Miri (cargo +nightly miri test ) to detect undefined behavior in unsafe code.
- FFI and Interop
Exporting to C
#[no_mangle] pub extern "C" fn my_add(a: i32, b: i32) -> i32 { a + b }
Calling C from Rust
extern "C" { fn strlen(s: *const std::os::raw::c_char) -> usize; }
fn rust_strlen(s: &str) -> usize { let cstr = std::ffi::CString::new(s).expect("no null bytes"); // SAFETY: cstr is a valid, null-terminated C string. unsafe { strlen(cstr.as_ptr()) } }
cbindgen / bindgen
-
Use cbindgen to generate C headers from Rust public API.
-
Use bindgen to generate Rust bindings from C headers.
-
Always run through CI to detect API drift.
- Build System
Cargo Workspaces
Cargo.toml (workspace root)
[workspace] members = ["crates/core", "crates/server", "crates/cli"] resolver = "2"
[workspace.dependencies] tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] }
crates/core/Cargo.toml
[dependencies] tokio.workspace = true serde.workspace = true
Feature Flags
[features] default = ["std"] std = [] async = ["dep:tokio"] metrics = ["dep:prometheus"]
[dependencies] tokio = { version = "1", optional = true } prometheus = { version = "0.13", optional = true }
#[cfg(feature = "metrics")] fn record_latency(ms: u64) { /* ... */ }
#[cfg(not(feature = "metrics"))] fn record_latency(_ms: u64) {}
Build Scripts
// build.rs — runs before crate compilation. fn main() { // Rerun when proto files change. println!("cargo:rerun-if-changed=proto/"); // Link a system library. println!("cargo:rustc-link-lib=ssl"); }
Useful Cargo Commands
cargo build --release # optimized build cargo clippy -- -D warnings # lint (treat warnings as errors) cargo fmt --check # format check (CI) cargo doc --no-deps --open # generate and open docs cargo audit # check dependencies for CVEs cargo deny check # license + advisory checks cargo expand # show macro expansion
- Rust 1.75+ Features
Async fn in Traits (Stable 1.75)
// No longer requires #[async_trait] crate. trait Fetcher { async fn fetch(&self, url: &str) -> anyhow::Result<Bytes>; }
RPITIT — Return-Position Impl Trait in Traits (Stable 1.75)
trait Source { fn items(&self) -> impl Iterator<Item = &str>; }
let-else (Stable 1.65)
let Ok(value) = parse_value(raw) else { return Err(ConfigError::InvalidValue); };
std::io::ErrorKind — richer variants (ongoing)
use std::io::ErrorKind; match err.kind() { ErrorKind::NotFound => { /* ... / } ErrorKind::PermissionDenied => { / ... / } _ => { / ... */ } }
- Code Quality Checklist
Before marking Rust work complete:
-
cargo clippy -- -D warnings passes with zero warnings.
-
cargo fmt --check produces no changes.
-
cargo test (or cargo nextest run ) passes — all tests green.
-
All unsafe blocks have // SAFETY: comments.
-
Public API items have /// doc comments.
-
No .unwrap() in library code except tests.
-
Error types derive Debug
- implement std::error::Error .
-
Async code does not block the executor thread.
-
Performance-critical paths benchmarked before and after changes.
Assigned Agents
This skill is used by:
-
developer — Rust feature implementation and bug fixes
-
code-reviewer — Rust-specific code review patterns
-
qa — Rust testing strategies (proptest, criterion, nextest)
Memory Protocol (MANDATORY)
Before starting: Read .claude/context/memory/learnings.md
After completing:
-
New Rust pattern discovered -> .claude/context/memory/learnings.md
-
Rust-specific issue or gotcha -> .claude/context/memory/issues.md
-
Architecture or crate selection decision -> .claude/context/memory/decisions.md
ASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.