rust-idioms
SkillDev toolsRust ownership, tokio, thiserror/anyhow, Clippy pedantic, unsafe, lifetimes.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the rust-idioms skill
What this skill tells your AI
The instructions your AI receives, as published by irahardianto/awesome-agv in .agents/skills/rust-idioms/SKILL.md and read by ahel’s review.
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, seereferences/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-versioninCargo.tomlto prevent builds on outdated toolchains.
[package]
edition = "2024"
rust-version = "1.97"
Key version milestones that affect this skill:
- 1.75+ — Native
async fnin traits (noasync_traitcrate needed for static dispatch). This milestone is the single source of truth for theasync-traitcrate policy:- Prefer static dispatch —
impl MyTraitreturn params or genericT: MyTraitbounds use nativeasync fnin traits; no crate required and zero dispatch overhead. - Use the
async-traitcrate only when dynamic dispatch viadyn Traitis 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-traitas a default dependency just for ergonomics — it adds a heap allocation and dynamic dispatch cost. Add it only for the specific crates/traits that needdyndispatch.
- Prefer static dispatch —
- 1.74+ — Workspace lint inheritance (
[workspace.lints]) - 1.63+ —
Mutex::new()inconstcontext (noOnceCellwrapper needed)
For recommended crate versions and starter
Cargo.toml, seereferences/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
&stroverStringin function parameters,&[T]overVec<T>
- Never
-
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
RwLockoverMutex - If data is immutable after init, use
Arc<T>without a lock
- If data flows one direction, use channels (
-
Respect the
Copy/Cloneboundary:- Never call
.clone()on types that implementCopy(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
// ❌ Misleading — usize implements Copy let count = other_count.clone(); // ✅ Implicit copy — clear and correct let count = other_count; - Never call
Error Handling
-
Use the
?operator for propagation — neverunwrap()in production codeunwrap()andexpect()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 viaexpect("reason")
- Tests (
-
Choose error crates by context:
Context Crate Reason Library crates thiserrorTyped, matchable errors. Callers need to handle specific variants. Web service HTTP errors thiserrorAppErrorenum must implementIntoResponse— typed variants required.Service/domain layer errors thiserrorDomain errors need structured variants for logging and client responses. Application glue / scripts / CLI anyhowError type doesn't matter; ergonomic propagation is all you need. Web service rule: Use
thiserrorforAppError(HTTP handler errors) and domain errors.anyhow::Errordoes NOT implementIntoResponseand cannot be returned from Axum handlers. Useanyhowonly in non-HTTP utility code (scripts, migration runners, CLI entrypoints) where errors are printed, not sent over the wire.The idiomatic pattern is
thiserrorfor typed variants +#[from] anyhow::Erroras the catch-allInternalvariant inAppError. Seeaxum-idioms/SKILL.md§Error Handling for the complete pattern.Never add
anyhowas a dependency to library crates — it leaks a concrete error type into your public API. -
Error type design:
// ✅ Good — typed, matchable errors
#[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),
}
// ❌ Bad — stringly-typed, unmatchable
fn do_thing() -> Result<(), String> { ... }
// ✅ Use #[must_use] on functions returning non-Result types that callers must handle
#[must_use]
pub fn compute_checksum(data: &[u8]) -> u64 { ... }
// ℹ️ Result<T, E> already has #[must_use] in std — adding it to Result-returning
// functions is redundant. The compiler warns on unused Result values automatically.
pub fn create_task(req: CreateTaskRequest) -> Result<Task, TaskError> { ... }
-
Use lazy evaluation for fallback values:
unwrap_or_else(|| expr)instead ofunwrap_or(expr)when the fallback involves a function callexpectmessages should be string literals, notformat!()callsmap_or_elseinstead ofmap_orwhen either branch involves computation
// ❌ Eager — default_value() is called even when result is Ok let val = result.unwrap_or(default_value()); let msg = result.expect(&format!("failed for {id}")); // ✅ Lazy — default_value() only called when needed let val = result.unwrap_or_else(|_| default_value()); let msg = result.unwrap_or_else(|e| panic!("failed for {id}: {e}"));
Async and Concurrency
-
Use
tokioas the async runtime- Mark async entry points with
#[tokio::main]or#[tokio::test] - Prefer
tokio::spawnfor concurrent tasks, notstd::thread::spawn - Use
tokio::select!for racing futures, not manual polling
- Mark async entry points with
-
Cancellation safety:
- Prefer
tokio::sync::mpscovertokio::sync::broadcastunless fan-out is needed - Document cancellation behavior on any
async fnthat holds resources across.await - Use
tokio_util::sync::CancellationTokenfor graceful shutdown
- Prefer
-
Blocking operations:
- Never call blocking I/O inside async context
- Use
tokio::task::spawn_blockingfor CPU-heavy or blocking work - Use
tokio::fsinstead ofstd::fsinside async functions
-
Use
tracinginstead oflogfor all structured diagnostics in async applications:tracingis span-aware — log entries inherit context from parent spans (correlation IDs, request metadata)logis fire-and-forget with no span concept — unsuitable for async where context flows across.awaitboundaries- 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
unsafeblocks unless in FFI boundaries- Tree-sitter C bindings and similar FFI are the only valid use case
- Every
unsafeblock must have a// SAFETY:comment explaining the invariant
-
Minimize unsafe surface area:
- Encapsulate
unsafein 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
unsafewrappers
- Encapsulate
-
Never use
unsafeto 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
'afor 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 Traitin argument position for simple cases - Use
whereclauses 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
- If lifetime annotations become complex, restructure to use owned data or
Idiomatic Patterns
-
Builder pattern for types with many optional fields:
- Return
Selffrom builder methods for chaining build()returnsResult<T, BuildError>, notT
- Return
-
Newtype pattern for domain types:
- Wrap primitives:
struct UserId(u64), not bareu64 - Implement
Derefonly when the newtype truly "is-a" the inner type
- Wrap primitives:
-
Typestate pattern for state machines:
- Different states = different types — invalid transitions are compile errors
- Use this for protocol implementations and lifecycle management
-
From/Intoconversions:- Implement
From<A> for B(neverIntodirectly) - Use
impl From<X> for Errorwiththiserror's#[from]attribute
- Implement
-
Prefer
T::new()overDefault::default()for known types:- Use
Vec::new(),String::new(),HashMap::new()— explicit, readable, idiomatic - Use
Default::default()in generic contexts whereT: Defaultbounds are needed - Use
Default::default()in struct update syntax:MyStruct { field: value, ..Default::default() }
// ✅ Idiomatic — explicit constructor for known types let items: Vec<String> = Vec::new(); let name = String::new(); let map: HashMap<String, i32> = HashMap::new(); // ✅ Also good — capacity hint is valuable let items = Vec::with_capacity(100); // ✅ Default::default() in generic code — correct usage fn create_collection<T: Default>() -> T { T::default() } // ✅ Default::default() in struct update syntax let config = ServerConfig { port: 8080, ..Default::default() }; - Use
-
Use stdlib convenience methods — avoid manual reimplementations:
str.split_once(pat)instead of manualsplitn(2, pat)+ indexinga.min(b)/a.max(b)/a.clamp(lo, hi)instead ofmatch a.cmp(&b) { ... }- Return expressions directly — don't bind to a variable and immediately return it
- Use
Ordering::then()/Ordering::then_with()for multi-field comparisons
// ❌ Manual reimplementation let parts: Vec<&str> = s.splitn(2, ':').collect(); let key = parts[0]; let value = parts.get(1).unwrap_or(&""); // ✅ Idiomatic — clearer intent, less code let (key, value) = s.split_once(':').unwrap_or((s, "")); // ❌ Redundant match over Ordering match a.cmp(&b) { Ordering::Less | Ordering::Equal => a, Ordering::Greater => b, } // ✅ Direct a.min(b) // ❌ Redundant let-binding let result = compute_something(); result // ✅ Return directly compute_something() -
Keep function complexity low (cyclomatic complexity < 10):
- Functions exceeding this threshold must be decomposed
- Common decomposition patterns for complex Rust functions:
- Extract
matcharms into named helper functions - Use early returns (
if !condition { return Err(...) }) to flatten nesting - Extract iterator chains with complex closures into named functions
- Use the "parse, don't validate" pattern — convert unstructured data into typed structs early
- Extract
// ❌ High complexity — nested match + conditionals fn process(input: &Input) -> Result<Output> { match input.kind { Kind::A => { if input.flag { // 20 lines... } else { // 20 lines... } } Kind::B => { /* another 30 lines */ } } } // ✅ Decomposed — each function has single responsibility fn process(input: &Input) -> Result<Output> { match input.kind { Kind::A => process_kind_a(input), Kind::B => process_kind_b(input), } }
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.rspattern,#[tokio::test]usage), seereferences/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!overassert!(a == b)— better error messages - Use
assert!(matches!(result, Ok(_)))for enum variant checking - Never use
assert!(true)orassert!(false):assert!(false)/debug_assert!(false)→ useunreachable!("reason")orpanic!("reason")assert!(true)→ remove entirely (it tests nothing)- These are dead-code signals that should use proper constructs
- Use
-
Property testing: Use
proptest(preferred) orquickcheckfor functions with wide input spaces.proptestis 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 structmethod, andimplblock MUST have at least one test - Every new branch (
if/else,matcharm, 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 tarpaulinorcargo llvm-covto verify coverage locally before committing
# Quick coverage check during development cargo tarpaulin --workspace --skip-clean --out stdout # Generate detailed report cargo llvm-cov --workspace --lcov --output-path lcov.info - Every new
-
Test double selection — choose the right tool:
Approach When to Use Crate Hand-written fake Simple trait, few methods, test needs custom stateful behavior None (implement trait directly) mockallComplex trait, need to verify call counts, argument matching, or call ordering mockallParameterized tests Same logic, multiple input/output pairs (like Go table-driven tests) rstestSnapshot testing Large outputs (JSON responses, CLI output, error messages) insta// ✅ Hand-written fake — simple, debuggable, no macro magic struct FakeTaskStorage { tasks: HashMap<String, Task>, } impl TaskStorage for FakeTaskStorage { async fn get_by_id(&self, id: &str) -> Result<Task, StorageError> { self.tasks.get(id).cloned().ok_or(StorageError::NotFound) } } // ✅ mockall — when you need interaction verification #[cfg(test)] mock! { pub TaskStore {} impl TaskStorage for TaskStore { async fn get_by_id(&self, id: &str) -> Result<Task, StorageError>; async fn create(&self, task: &Task) -> Result<(), StorageError>; } } #[tokio::test] async fn test_service_calls_storage_once() { let mut mock = MockTaskStore::new(); mock.expect_create() .times(1) .returning(|_| Ok(())); let service = TaskService::new(mock); service.create_task(request).await.unwrap(); } // ✅ rstest — parameterized test cases use rstest::rstest; #[rstest] #[case("valid@email.com", true)] #[case("no-at-sign", false)] #[case("", false)] fn test_email_validation(#[case] input: &str, #[case] expected: bool) { assert_eq!(is_valid_email(input), expected); } // ✅ insta — snapshot testing for complex outputs use insta::assert_json_snapshot; #[test] fn test_task_response_shape() { let response = TaskResponse::from(sample_task()); assert_json_snapshot!(response); }Prefer hand-written fakes for core domain traits — they are easier to debug and don't couple tests to implementation details. Use
mockallonly when the trait has many methods or you genuinely need interaction verification (call counts, argument matching, call ordering). Over-mocking withmockallleads to brittle tests that break on implementation changes.
Clippy and Formatting
-
cargo checkfor fast iteration during developmentcargo check: type-checks without producing a binary — fastest feedback loopcargo clippy: includescargo checkplus lint rules — use before committingcargo build: only when you need the actual binary/library artifact- Never run
cargo buildduring TDD cycles — it is significantly slower thancargo check
-
cargo clippymust 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_linesFunction is monolithic Decompose into smaller functions (see Idiomatic Patterns §7) cognitive_complexityToo many branches/nesting Flatten with early returns, extract match arms too_many_argumentsFunction has too many params Introduce a params/config struct or builder type_complexityNested generics are unreadable Create a type alias or newtype wrapper struct_excessive_boolsStruct has too many boolean fields Replace with an enum, bitflags, or config sub-struct large_enum_variantEnum variant is disproportionately large Box the large variant's payload Decomposition strategies (use INSTEAD of
#[allow]):// ❌ FORBIDDEN — agent took the lazy path #[allow(clippy::too_many_lines)] fn process_request(req: &Request) -> Result<Response> { // 200 lines of code... } // ✅ REQUIRED — decompose the function fn process_request(req: &Request) -> Result<Response> { let validated = validate_request(req)?; let enriched = enrich_with_context(&validated)?; build_response(&enriched) } // ❌ FORBIDDEN — too many arguments #[allow(clippy::too_many_arguments)] fn create_server(host: &str, port: u16, tls: bool, timeout: u64, max_conn: usize, log_level: &str, cert: &Path) -> Server { ... } // ✅ REQUIRED — params struct struct ServerConfig { host: String, port: u16, tls: bool, timeout: Duration, max_connections: usize, log_level: Level, cert_path: PathBuf, } fn create_server(config: ServerConfig) -> Server { ... } // ❌ FORBIDDEN — hiding type complexity #[allow(clippy::type_complexity)] fn get_handlers() -> HashMap<String, Box<dyn Fn(&Request) -> Pin<Box<dyn Future<Output = Response>>>>> { ... } // ✅ REQUIRED — type alias type HandlerFn = Box<dyn Fn(&Request) -> Pin<Box<dyn Future<Output = Response>>>>; fn get_handlers() -> HashMap<String, HandlerFn> { ... }Acceptable suppressions (with mandatory
// ALLOW:comment):Lint When Acceptable unwrap_usedIn #[cfg(test)]modules onlyexpect_usedIn #[cfg(test)]modules, OR with a// SAFETY:comment proving infallibility, OR in a CLImain()that owns the process exit (clear message + exit code). This reconciles with theexpect_used = "warn"lint level inrecommended-dependencies.md—warnpermits these uses while still surfacing every otherexpect()for review.module_name_repetitionsWhen the repetition is intentional API design must_use_candidateOn internal functions where the caller pattern is known missing_errors_docTemporarily during development (must be resolved before merge) needless_pass_by_valueWhen API stability requires it (with comment explaining why) items_after_statementsWhen locality of helper functions improves readability cast_possible_truncationWith bounds check or range validation immediately preceding the cast Rule of thumb: If you're about to write
#[allow(clippy::...)], stop and ask: "Am I suppressing a real design problem?" If yes, fix the design. If the lint is genuinely a false positive for this specific context, suppress with a// ALLOW:comment explaining the rationale. -
cargo fmtis non-negotiable — all code must be formatted -
Recommended project-level Clippy configuration:
For the standard
[lints.clippy]and[lints.rust]blocks (single-crate and workspace variants), and the version pinning policy, seereferences/recommended-dependencies.md§Workspace Lint Configuration and §Starter Cargo.toml Template. Do not duplicate those blocks here — treatrecommended-dependencies.mdas the single source of truth. -
Document all public items:
- Every
pub fn,pub struct,pub enum,pub trait, andpub typeMUST have a///doc comment - At minimum: one-line summary. For complex items: summary + parameters + errors + examples
- Enable the
missing_docslint in library crates:
# In Cargo.toml [lints.rust] missing_docs = "warn"// ❌ Undocumented public item pub fn resolve_symbols(path: &Path) -> Result<Vec<Symbol>> { ... } // ✅ Documented /// Resolves all exported symbols from the file at `path`. /// /// Returns parsed symbol definitions including their span information. /// /// # Errors /// Returns `ParseError` if the file cannot be parsed by tree-sitter. pub fn resolve_symbols(path: &Path) -> Result<Vec<Symbol>> { ... } - Every
Dependency Management
- Minimize dependency count — each dependency is an attack surface and compile-time cost
- Pin major versions in
Cargo.toml— usedep = "1"notdep = "*" - Audit regularly — run
cargo auditto 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"] # ✅ Uses dep: prefix grpc = ["dep:tonic"] # ✅ Feature doesn't auto-expose dep as feature -
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
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 156
- Forks
- 53
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
rust-idioms- Source
- github.com/irahardianto/awesome-agv