| name | rust-idioms |
| description | Rust ownership, tokio, thiserror/anyhow, Clippy pedantic, unsafe, lifetimes. |
| paths | ["**/*.rs","**/Cargo.toml"] |
Rust Idioms and Patterns
Core Philosophy
Rust's type system and ownership model are your primary tools for correctness. Lean into the compiler — it is your strongest ally. Write code that is idiomatic, safe, and expressive.
Scope: This file covers Rust-specific coding idioms. For file layout, see references/project-structure.md (in this skill). For detailed safety, SAST security invariants, and performance anti-patterns, see references/rust-patterns-and-anti-patterns.md (in this skill). For Rust test naming and conventions, see §Testing below; for universal testing principles, see @.agents/rules/testing-strategy.md. For logging library choice and setup, see @.agents/skills/logging-implementation/SKILL.md.
Toolchain and Minimum Supported Rust Version
Default to the latest stable Rust. As of July 2026, this is Rust 1.97. All guidance in this skill assumes latest stable features. When creating new projects, set rust-version in Cargo.toml to prevent builds on outdated toolchains.
[package]
edition = "2024"
rust-version = "1.97"
Key version milestones that affect this skill:
- 1.75+ — Native
async fn in traits (no async_trait crate needed for static dispatch). This milestone is the single source of truth for the async-trait crate policy:
- Prefer static dispatch —
impl MyTrait return params or generic T: MyTrait bounds use native async fn in traits; no crate required and zero dispatch overhead.
- Use the
async-trait crate only when dynamic dispatch via dyn Trait is explicitly required — e.g. Box<dyn MyTrait>, Arc<dyn MyTrait> for runtime polymorphism, object-safe trait objects, or storing heterogeneous trait impls in a collection.
- Never add
async-trait as a default dependency just for ergonomics — it adds a heap allocation and dynamic dispatch cost. Add it only for the specific crates/traits that need dyn dispatch.
- 1.74+ — Workspace lint inheritance (
[workspace.lints])
- 1.63+ —
Mutex::new() in const context (no OnceCell wrapper needed)
For recommended crate versions and starter Cargo.toml, see references/recommended-dependencies.md.
Ownership and Borrowing
-
Prefer borrowing (&T, &mut T) over cloning
- Never
.clone() to silence the borrow checker without a // CLONE: comment explaining why
- Use
Cow<'_, T> when a function may or may not need ownership
- Prefer
&str over String in function parameters, &[T] over Vec<T>
-
Minimize owned data in structs
- Use references with explicit lifetimes when the struct is short-lived
- Use owned types (
String, Vec<T>) when the struct must outlive its inputs
-
Avoid unnecessary Arc<Mutex<T>>
- If data flows one direction, use channels (
tokio::sync::mpsc)
- If data is read-heavy, consider
RwLock over Mutex
- If data is immutable after init, use
Arc<T> without a lock
-
Respect the Copy / Clone boundary:
- Never call
.clone() on types that implement Copy (e.g., i32, f64, bool, char, usize, Option<CopyType>)
- Copy types are implicitly copied on assignment —
.clone() is misleading and suggests heap allocation
- When unsure, check:
Copy = bitwise copy (stack only); Clone = potentially expensive deep copy
let count = other_count.clone();
let count = other_count;
Error Handling
-
Use the ? operator for propagation — never unwrap() in production code
unwrap() and expect() are acceptable only in:
- Tests (
#[test], #[tokio::test])
- Infallible operations where the invariant is proven (document with
// SAFETY: comment)
- CLI
main() function with clear error messages via expect("reason")
-
Choose error crates by context:
| Context | Crate | Reason |
|---|
| Library crates | thiserror | Typed, matchable errors. Callers need to handle specific variants. |
| Web service HTTP errors | thiserror | AppError enum must implement IntoResponse — typed variants required. |
| Service/domain layer errors | thiserror | Domain errors need structured variants for logging and client responses. |
| Application glue / scripts / CLI | anyhow | Error type doesn't matter; ergonomic propagation is all you need. |
Web service rule: Use thiserror for AppError (HTTP handler errors) and domain errors. anyhow::Error does NOT implement IntoResponse and cannot be returned from Axum handlers. Use anyhow only in non-HTTP utility code (scripts, migration runners, CLI entrypoints) where errors are printed, not sent over the wire.
The idiomatic pattern is thiserror for typed variants + #[from] anyhow::Error as the catch-all Internal variant in AppError. See axum-idioms/SKILL.md §Error Handling for the complete pattern.
Never add anyhow as a dependency to library crates — it leaks a concrete error type into your public API.
-
Error type design:
#[derive(Debug, thiserror::Error)]
pub enum PathfinderError {
#[error("file not found: {path}")]
FileNotFound { path: PathBuf },
#[error("AST parse failed: {0}")]
ParseError(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
fn do_thing() -> Result<(), String> { ... }
#[must_use]
pub fn compute_checksum(data: &[u8]) -> u64 { ... }
pub fn create_task(req: CreateTaskRequest) -> Result<Task, TaskError> { ... }
-
Use lazy evaluation for fallback values:
unwrap_or_else(|| expr) instead of unwrap_or(expr) when the fallback involves a function call
expect messages should be string literals, not format!() calls
map_or_else instead of map_or when either branch involves computation
let val = result.unwrap_or(default_value());
let msg = result.expect(&format!("failed for {id}"));
let val = result.unwrap_or_else(|_| default_value());
let msg = result.unwrap_or_else(|e| panic!("failed for {id}: {e}"));
Async and Concurrency
-
Use tokio as the async runtime
- Mark async entry points with
#[tokio::main] or #[tokio::test]
- Prefer
tokio::spawn for concurrent tasks, not std::thread::spawn
- Use
tokio::select! for racing futures, not manual polling
-
Cancellation safety:
- Prefer
tokio::sync::mpsc over tokio::sync::broadcast unless fan-out is needed
- Document cancellation behavior on any
async fn that holds resources across .await
- Use
tokio_util::sync::CancellationToken for graceful shutdown
-
Blocking operations:
- Never call blocking I/O inside async context
- Use
tokio::task::spawn_blocking for CPU-heavy or blocking work
- Use
tokio::fs instead of std::fs inside async functions
-
Use tracing instead of log for all structured diagnostics in async applications:
tracing is span-aware — log entries inherit context from parent spans (correlation IDs, request metadata)
log is fire-and-forget with no span concept — unsuitable for async where context flows across .await boundaries
- Use
#[tracing::instrument] on async functions to automatically create spans with function arguments
- See
@.agents/skills/logging-implementation/SKILL.md §Rust for the full setup
Unsafe Code
-
Zero unsafe blocks unless in FFI boundaries
- Tree-sitter C bindings and similar FFI are the only valid use case
- Every
unsafe block must have a // SAFETY: comment explaining the invariant
-
Minimize unsafe surface area:
- Encapsulate
unsafe in a safe wrapper function
- The wrapper's public API must be safe to call from any context
- Write tests that exercise the boundary conditions of
unsafe wrappers
-
Never use unsafe to bypass the borrow checker — restructure the code instead
Lifetimes and Generics
-
Prefer '_ lifetime elision when possible
- Only introduce named lifetimes when the compiler requires them or when they clarify intent
- Use
'a for single lifetime parameters, descriptive names ('input, 'query) for multiple
-
Keep generic bounds simple:
- Prefer concrete types for prototyping, introduce generics when the pattern stabilizes
- Use
impl Trait in argument position for simple cases
- Use
where clauses for complex bounds — never inline complex bounds in <...>
-
Avoid lifetime gymnastics:
- If lifetime annotations become complex, restructure to use owned data or
Arc
- Consider the "split borrow" pattern to avoid borrow checker issues in struct methods
Idiomatic Patterns
-
Builder pattern for types with many optional fields:
- Return
Self from builder methods for chaining
build() returns Result<T, BuildError>, not T
-
Newtype pattern for domain types:
- Wrap primitives:
struct UserId(u64), not bare u64
- Implement
Deref only when the newtype truly "is-a" the inner type
-
Typestate pattern for state machines:
- Different states = different types — invalid transitions are compile errors
- Use this for protocol implementations and lifecycle management
-
From/Into conversions:
- Implement
From<A> for B (never Into directly)
- Use
impl From<X> for Error with thiserror's #[from] attribute
-
Prefer T::new() over Default::default() for known types:
- Use
Vec::new(), String::new(), HashMap::new() — explicit, readable, idiomatic
- Use
Default::default() in generic contexts where T: Default bounds are needed
- Use
Default::default() in struct update syntax: MyStruct { field: value, ..Default::default() }
let items: Vec<String> = Vec::new();
let name = String::new();
let map: HashMap<, > = HashMap::();
= ::();
<T: >() T {
T::()
}
= ServerConfig {
port: ,
..::()
};
Testing
-
Test organization (Rust-specific — differs from Go/TS):
For the authoritative test layout rules (unit vs integration vs e2e placement, #[cfg(test)] conventions, tests/common/mod.rs pattern, #[tokio::test] usage), see references/project-structure.md §Testing Layout. The rules are co-located there to stay in sync with the directory layout they describe.
-
Test naming: fn test_<function>_<scenario>_<expected>() (snake_case)
-
Assertions:
- Use
assert_eq! / assert_ne! over assert!(a == b) — better error messages
- Use
assert!(matches!(result, Ok(_))) for enum variant checking
- Never use
assert!(true) or assert!(false):
assert!(false) / debug_assert!(false) → use unreachable!("reason") or panic!("reason")
assert!(true) → remove entirely (it tests nothing)
- These are dead-code signals that should use proper constructs
-
Property testing: Use proptest (preferred) or quickcheck for functions with wide input spaces. proptest is preferred for its superior strategy composability, automatic shrinking, and more expressive generators.
-
Test coverage is non-negotiable for new code:
- Every new
pub fn, pub struct method, and impl block MUST have at least one test
- Every new branch (
if/else, match arm, error path) MUST be exercised by a test
- When modifying existing code, add tests for the modified paths if none exist
- Never leave a function untested with the intent to "add tests later"
- Use
cargo tarpaulin or cargo llvm-cov to verify coverage locally before committing
cargo tarpaulin --workspace --skip-clean --out stdout
cargo llvm-cov --workspace --lcov --output-path lcov.info
Clippy and Formatting
-
cargo check for fast iteration during development
cargo check: type-checks without producing a binary — fastest feedback loop
cargo clippy: includes cargo check plus lint rules — use before committing
cargo build: only when you need the actual binary/library artifact
- Never run
cargo build during TDD cycles — it is significantly slower than cargo check
-
cargo clippy must pass with zero warnings before any commit
-
Clippy suppression policy — fix the code, don't silence the lint:
NEVER suppress these lints — they signal structural problems that must be fixed:
| Lint | What It Signals | What To Do Instead |
|---|
too_many_lines | Function is monolithic | Decompose into smaller functions (see Idiomatic Patterns §7) |
cognitive_complexity | Too many branches/nesting | Flatten with early returns, extract match arms |
too_many_arguments | Function has too many params | Introduce a params/config struct or builder |
type_complexity | Nested generics are unreadable | Create a type alias or newtype wrapper |
struct_excessive_bools | Struct has too many boolean fields | Replace with an enum, bitflags, or config sub-struct |
large_enum_variant | Enum variant is disproportionately large | Box the large variant's payload |
Decomposition strategies (use INSTEAD of #[allow]):
#[allow(clippy::too_many_lines)]
fn process_request(req: &Request) -> Result<Response> {
}
(req: &Request) <Response> {
= (req)?;
= (&validated)?;
(&enriched)
}
(host: &, port: , tls: , timeout: ,
max_conn: , log_level: &, cert: &Path) Server { ... }
{
host: ,
port: ,
tls: ,
timeout: Duration,
max_connections: ,
log_level: Level,
cert_path: PathBuf,
}
(config: ServerConfig) Server { ... }
() HashMap<, < (&Request) Pin<< Future<Output = Response>>>>> { ... }
= < (&Request) Pin<< Future<Output = Response>>>>;
() HashMap<, HandlerFn> { ... }
Dependency Management
- Minimize dependency count — each dependency is an attack surface and compile-time cost
- Pin major versions in
Cargo.toml — use dep = "1" not dep = "*"
- Audit regularly — run
cargo audit to check for known vulnerabilities
- Prefer well-maintained crates — check download count, last commit date, and issue tracker
Cargo Features
-
Features must be additive — enabling a feature must only add functionality, never change or remove existing behavior
-
Use dep: syntax for optional dependencies to keep the feature namespace clean:
[features]
default = ["json"]
json = ["dep:serde_json"]
grpc = ["dep:tonic"]
-
Guard feature-gated code with #[cfg(feature = "...")]:
#[cfg(feature = "grpc")]
pub mod grpc_handler;
-
Test feature combinations in CI using cargo-hack:
cargo hack test --feature-powerset --depth 2
-
Never use features for mutually exclusive backends — use traits and runtime selection instead
Configuration and Environment
-
Never use string literals directly in std::env::var():
- Define all environment variable names as constants in a central module
- This prevents typos (caught at compile time) and enables grep-ability
let port = std::env::var("PATHFINDER_PORT").unwrap_or("3000".into());
let host = std::env::var("PATHFNDER_HOST").unwrap_or("localhost".into());
mod env_keys {
pub const PORT: &str = "PATHFINDER_PORT";
pub const HOST: &str = "PATHFINDER_HOST";
}
let port = std::env::var(env_keys::PORT).unwrap_or_else(|_| "3000".into());
let host = std::env::var(env_keys::HOST).unwrap_or_else(|_| "localhost".into());
-
Prefer structured config parsing over scattered env::var calls:
- Parse all config at startup into a typed struct
Safety, Security, and Performance
Key safety rules (non-negotiable):
- Never use
unsafe without a // SAFETY: comment documenting the invariant
- Never
transmute across types of different sizes or with different validity invariants
- Validate all
as casts with explicit bounds checks — as silently truncates
- Never block the async runtime — use
tokio::task::spawn_blocking for CPU-heavy or synchronous I/O work inside async contexts
For the full catalog of safety invariants, SAST patterns, concurrency rules (lock guards, atomics, async), memory safety (double indirection, transmute, pointer casts), collection best practices (retain, deterministic iteration), and security (TOCTOU, path traversal, cookie flags, CSP), see references/rust-patterns-and-anti-patterns.md. Load it before writing any unsafe code, concurrent code, or I/O handling code.
For performance patterns (arena allocation, SmallVec, zero-copy parsing, Cow, pre-sized collections, benchmarking), see perf-optimization/languages/rust.md.
Related
- Error Handling Principles @.agents/rules/error-handling-principles.md
- Security Principles @.agents/rules/security-principles.md
- Architectural Patterns — Testability-First Design @.agents/rules/architectural-pattern.md
- Concurrency and Threading Principles @.agents/rules/concurrency-and-threading-principles.md
- Core Design Principles @.agents/rules/core-design-principles.md
- Performance Optimization Principles @.agents/rules/performance-optimization-principles.md
- Resource and Memory Management Principles @.agents/rules/resources-and-memory-management-principles.md
- Security Mandate @.agents/rules/security-mandate.md
- Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md
- Testing Strategy @.agents/rules/testing-strategy.md
- Logging and Observability Mandate @.agents/rules/logging-and-observability-mandate.md
- Dependency Management Principles @.agents/rules/dependency-management-principles.md
- Logging Implementation @.agents/skills/logging-implementation/SKILL.md
- Axum Idioms @.agents/skills/axum-idioms/SKILL.md
- Testability Patterns @.agents/skills/testability-patterns/SKILL.md
- Performance (Rust) @.agents/skills/perf-optimization/languages/rust.md