Rust
Lean on the type system and the borrow checker. They are the design surface, not an obstacle. When the compiler rejects
code, the ownership model is wrong — restructure who owns and who borrows rather than reaching for .clone(), Rc, or
unsafe to silence it. Make illegal states unrepresentable: encode invariants in types so the wrong code fails to
compile instead of failing at runtime. A fight with the borrow checker is a design review the compiler is giving you for
free.
Route to Reference
Read the reference before doing focused work in its area; SKILL.md alone is sufficient for routine code.
- Idioms and ownership patterns —
${CLAUDE_SKILL_DIR}/references/idioms.mdborrowing/ownership restructuring,impl Traitvs generics vsdyn, iterator adapters,match/if let/let else, newtype and builder patterns,From/Into/TryFrom, theas_/to_/into_cost cheat-table - Error handling —
${CLAUDE_SKILL_DIR}/references/errors.mdResult/Option/?,thiserrorenum templates,anyhow.context(), the panic/unwrap/expectpolicy, error-type design (Display/Error/Send/Sync) - Anti-patterns —
${CLAUDE_SKILL_DIR}/references/anti-patterns.mdAI-produced anti-patterns mapped to the Clippy lints that catch them, with the idiomatic fix for each - API design —
${CLAUDE_SKILL_DIR}/references/api-guidelines.mdthe Rust API GuidelinesC-*checklist, RFC 430 naming,#[non_exhaustive],#[must_use], common-trait derives, sealed traits, builders - Parallelism —
${CLAUDE_SKILL_DIR}/references/parallelism.mdthreads, scoped threads, rayon,Send/Sync, channels, shared state (Arc/Mutex) — the CPU-bound multicore path - Async —
${CLAUDE_SKILL_DIR}/references/async.mdtheFuture/executor model, Tokio, the compiler-invisible footgun checklist (blocking in async, holding!Sendacross.await), "do you even need async?" - Project structure —
${CLAUDE_SKILL_DIR}/references/structure.mdmod.rs-free layout, cargo workspaces, the[lints]/[workspace.lints]table, profiles, features, MSRV - Toolchain —
${CLAUDE_SKILL_DIR}/references/toolchain.mdthe fmt/clippy/nextest/deny verification gates, rust-analyzer configuration, recommended compiler and Clippy lint sets - Edition 2024 —
${CLAUDE_SKILL_DIR}/references/edition-2024.mdRust 2024 edition specifics and migration viacargo fix --edition - Documentation —
${CLAUDE_SKILL_DIR}/references/documentation.mdrustdoc conventions, doctests,missing_docs, intra-doc links, semver-checks
Naming
- Casing follows RFC 430:
UpperCamelCasefor types/traits/enum variants,snake_casefor functions/methods/modules,SCREAMING_SNAKE_CASEfor consts/statics. - Acronyms are one word in
UpperCamelCase(Uuid,Stdin, notUUID,StdIn); lower-cased insnake_case. - Getters omit the
get_prefix:fn first(&self) -> &First, notfn get_first. Usegetonly when one obvious value is gotten (e.g.Cell::get). - Ad-hoc conversion methods signal cost through their prefix:
as_= free borrow-to-borrow view;to_= expensive (allocates or computes);into_= consuming, ownership transfer. - Collection iterators are
iter(&T),iter_mut(&mut T),into_iter(T); the iterator type name matches the method (into_iter->IntoIter). - Names use consistent verb-object word order (
ParseAddrError, matchingParseIntError). Drop weasel words —Manager,Service,Factory,Helper,Utilrarely belong in a type name; name the thing for what it is (Bookings, notBookingService), append a quality only when it does something specific (BookingDispatcher). - A
Builderis Rust's name for a factory. Acceptimpl Fn() -> Foorather than aFooBuilderparameter.
Ownership and Borrowing
- Accept the least-owning type that does the job:
&strover&String,&[T]over&Vec<T>,&ToverTwhen not consuming. Return owned values; borrow in parameters. - Treat
.clone()as a signal, not a fix. A clone to satisfy the borrow checker usually means lifetimes or ownership are structured wrong — split the borrow, narrow the scope, or restructure who owns the data. - Reach for
Rc/Arconly for genuine shared ownership (a graph, a cache, a value with no single owner), not to dodge a lifetime annotation. - Prefer adjusting a function to take
&self/&Tover cloning at the call site. Borrow at the narrowest scope so a mutable borrow ends before the next access begins (non-lexical lifetimes allow this). - Use lifetime elision wherever it applies; write explicit lifetimes only when the compiler asks or when they document a real relationship between inputs and outputs.
Error Handling
- Functions that can fail return
Result<T, E>; absence-not-error returnsOption<T>. Propagate with?, do not match-and-rewrap. - Libraries define typed errors with
thiserror(one enum,#[error("...")]per variant,#[from]for source conversions). Applications useanyhow::Resultand attach.context(...)at each layer. - Error types implement
std::error::Error,Display,Debug, and areSend + Sync + 'staticso they cross threads and compose withanyhow/Box<dyn Error>. .unwrap()and.expect()are permitted only on a provable invariant (with.expect("why this cannot fail")documenting the proof), insideconstcontexts, or in tests. They are never error-propagation.- A detected programming bug panics; it is not an
Error. A panic means "stop the program" — never use panics to communicate recoverable errors upstream, and never assume a panic will be caught. Genuinely fallible operations (parsing, I/O, user input) returnResult. - Prefer making invalid states uncompilable over validating at runtime: a
NonEmptyVecor a parsedEmailnewtype beats a runtime check on a rawVec/String.
Traits and Generics
- Static dispatch is the default: generics (
fn f<T: Trait>) orimpl Traitin argument and return position. Monomorphization keeps it allocation-free and inlinable. - Use
dyn Traitonly for genuine runtime polymorphism — heterogeneous collections (Vec<Box<dyn Draw>>), plugin boundaries, or breaking deep monomorphization. Always writedynexplicitly (Box<dyn Error>,&dyn Handler); bare-trait-object syntax has been an error since edition 2021. impl Traitin argument position is shorthand for an anonymous generic; in return position it hides a concrete type. Switch to a named generic when the caller must name the type or supply it via turbofish.- Derive the common traits eagerly where they fit:
Debugon every public type, plusClone,Copy,PartialEq,Eq,Hash,PartialOrd,Ord,Defaultas semantics allow. ImplementDisplayfor types meant to be read by humans (errors, string-like wrappers). - Implement conversions via the standard traits —
From(which givesIntofree),TryFrom,AsRef,AsMut— not ad-hoc methods, so generic code and?interoperate.
Iterators and Pattern Matching
- Express transformations as iterator adapter chains (
.iter().filter().map().collect()), not manual index loops. They are bounds-check-friendly, lazy, and clearer. - Reach for
for x in &collectionoverfor i in 0..collection.len(); index only when the index itself is the data. matcharms must be exhaustive — let the compiler enforce that every variant is handled rather than ending with a catch-all_that silently swallows new variants.- Use
if let/while letfor single-pattern matches, andlet ... else { return / continue / bail }to bind-or- bail in one line instead of nesting the happy path inside amatch. - Destructure in the pattern (function params,
let, match arms) rather than reaching into fields by.0/.fieldafterward.
Concurrency: Choose the Right Model First
Pick the concurrency model from the workload before writing any concurrent code — this decision is not interchangeable:
- CPU-bound / multicore compute (parsing, hashing, image processing, number crunching) -> OS threads or rayon
(
par_iter). Never reach for async here — async provides zero parallel speedup for CPU work; it only interleaves tasks at.awaitpoints on (often) a single logical flow. - Many concurrent I/O operations (thousands of sockets, requests, file handles) -> async / Tokio. Async wins by not blocking a thread while waiting on I/O, letting one runtime juggle huge numbers of in-flight operations.
- A handful of blocking I/O tasks -> plain threads are simpler than dragging in an async runtime; use async only when the concurrency count makes a thread-per-task model wasteful.
Further rules once the model is chosen:
- Shared state crosses threads via
Arc<Mutex<T>>/Arc<RwLock<T>>; immutable shared data viaArc<T>. Move work and ownership across threads with channels (std::sync::mpsc,crossbeam, ortokio::syncin async). - Public types should be
Send(and usuallySync) so they work under Tokio and behind runtime abstractions. A!Sendvalue held across an.awaitmakes the whole future!Send— keepRc/RefCellout of async scopes that span an await. - Never block in async: no
std::thread::sleep, no synchronous file/network I/O, no long CPU loops on the runtime. Offload CPU work withtokio::task::spawn_blockingor a rayon pool, and use the runtime's async timers/IO. - The borrow checker does not see deadlocks or blocking-in-async — those footguns are invisible at compile time. Read
references/async.mdbefore writing async code.
Project Structure
- Use the
mod.rs-free layout: a modulefoolives infoo.rs, its submodules infoo/bar.rs. Never create newmod.rsfiles. - Set all lint levels in the
[lints](or[workspace.lints]) table inCargo.toml, not in scattered#![deny(...)]attributes orRUSTFLAGS. This keeps lint policy in one auditable place and inherited across the workspace. - Override a project-global lint at a narrow scope with
#[expect(lint, reason = "...")], not#[allow]—expectwarns when the lint stops firing, preventing stale overrides. (#[allow]is acceptable on generated code/macros.) - Multi-crate projects use a cargo workspace with a shared
[workspace.lints]and[workspace.dependencies]. Split a crate when a submodule is independently useful — many small crates compile faster and prevent dependency cycles. - Keep
unsafeconfined to the smallest possible module; soundness boundaries are module boundaries. Everyunsafeblock carries a// SAFETY:comment justifying it. Never write sound-looking safe wrappers over unsound assumptions.
Toolchain Gates
Code is not done until these pass (run via Bash, surfacing each command):
cargo fmt --check— formatting is non-negotiable; never hand-format.cargo clippy --all-targets --all-features -- -D warnings— Clippy clean across the whole build graph, warnings as errors. Fix the cause; suppress a lint only with a scoped#[expect(..., reason = "...")].cargo nextest runandcargo test --doc— nextest is the fast test runner but does not execute doctests, so the separate--docrun is mandatory to keep documentation examples honest.cargo deny check(orcargo audit) — gate dependencies for advisories, license, and source policy.
Doc Comments
//!documents the crate root and modules;///documents items (functions, types, fields, variants). Write docs as part of the change, not after.- Use the standard rustdoc sections:
# Examples,# Panics(when the fn can panic),# Errors(what aResultreturns on failure),# Safety(the invariants a caller of anunsafefn must uphold). - Doctests use
?for fallible calls (wrap the body so it returnsResult), never.unwrap()— examples are also documentation of idiomatic usage. - Libraries enable
#![warn(missing_docs)]so every public item is documented. Link related items with intra-doc links ([Type],[mod::func]). - When you change an exported symbol's signature or behavior, update its doc comment in the same edit — a stale doc is worse than none.
Code Navigation (LSP Required)
A rust-analyzer LSP is configured for .rs files. For semantic navigation, use LSP tools — they understand types,
traits, and macro expansion in ways text search cannot:
- goToDefinition — jump to where a symbol is defined (through re-exports and macros).
- findReferences — every real use of a symbol, not textual name collisions.
- hover — resolved type, signature, and docs at a position.
- workspaceSymbol — locate a type/fn/trait by name across the workspace.
- goToImplementation — concrete implementors of a trait, or the trait a method satisfies.
- incomingCalls / outgoingCalls — call hierarchy for impact analysis before a change.
Use Grep/Glob only for non-semantic text: comments, string literals, config files, log messages, Cargo.toml entries.
Reaching for Grep to find a definition or all callers is a mistake when the LSP resolves it precisely.
Integration
- the-coder owns the language-agnostic workflow — discovery, planning, verification loop. This skill governs
Rust-specific conventions only; defer the workflow mechanics to
the-coderand apply these rules within it. - rust-testing owns the test ecosystem (
#[test]/integration/doctest layout, cargo-nextest, proptest, insta, criterion, mockall, rstest). When writing or reviewing tests, that skill leads; it relies on this skill for general language conventions and onreferences/async.mdfor async-test mechanics. - When this skill's conventions conflict with an established pattern already pervasive in the codebase, the codebase wins — follow it and flag the divergence once.
Application
When writing Rust:
- Apply every convention above silently — do not narrate "per RFC 430 I'll name this...". Just write idiomatic code.
- Match the surrounding codebase. If it diverges from these conventions consistently (e.g. uses
mod.rs, a different error crate), follow the codebase and note the divergence once rather than fighting it file-by-file. - Run the toolchain gates before declaring the work done.
When reviewing Rust:
- Cite the specific violation (file:line and the rule), then show the corrected code inline. Do not lecture in the abstract.
- Prioritize correctness and soundness (unsound
unsafe, blocking-in-async, panics-as-errors, data races) over style nits; letcargo fmtand Clippy carry the mechanical issues. - Confirm the concurrency model fits the workload (CPU-bound vs I/O-bound) before reviewing the concurrency details.
The borrow checker is a collaborator, not an adversary. When it pushes back, the answer is almost never .clone(),
Rc, or unsafe — it is a clearer ownership structure. Encode invariants in types so wrong code does not compile, keep
unsafe rare and justified, and let the toolchain gates (fmt, clippy, nextest + doctests, deny) be the final word on
done.