Clean Rust
Rust-specific conventions for writing, reviewing, and refactoring. The core rules below always apply. Load the one reference file matching the task's domain; don't load them all.
If the clean-code skill is installed, its language-agnostic principles (function size, naming hygiene, comment discipline) still apply. Where generic advice conflicts with Rust idiom, this skill wins. Classic examples: "prefer exceptions over error codes" maps to Result, never panics; "replace switch with polymorphism" maps to exhaustive match, which in Rust is a feature, not a smell.
| Task touches | File |
|---|---|
| Error types, propagation helpers, failure semantics, retries | references/error-handling.md |
async/await, tokio, select!, channels, spawned child processes |
references/async.md |
| Threads, atomics, lock ordering, drop order | references/concurrency.md |
| Process-global mutable state (env vars, global overrides) across threads | references/edition-2024.md |
| Public API shape, builders, newtypes, typestate, serde | references/api-design.md |
Any unsafe block, FFI, raw pointers, exported symbols |
references/unsafe.md |
| Writing tests or debugging test infrastructure | references/testing.md |
| Hot paths, allocations, string building, hasher choice | references/performance.md |
Benchmarks, criterion, #[bench] |
references/performance.md |
| Secret files on disk, keys and tokens at rest | references/security.md |
| Logging, tracing spans, log levels | references/observability.md |
| Edition 2024 migration or 2024-specific behavior changes | references/edition-2024.md |
Errors
Result+?everywhere; no.unwrap()/.expect()outside tests and one-time init of hand-audited literals (LazyLock<Regex>on a fixed pattern). The rare justifiedexpectcarries a message naming the invariant that makes it safe.- No sentinel returns (
-1,"") for failure.Option<T>for absence that isn't an error. thiserrorfor library errors (callers can match),anyhow/eyreat the application boundary. NeverBox<dyn Error>orStringas a library error type.- Bind a
Resultonce withmatch/if let/let-else, never.is_ok()followed by separate access or a downstream.unwrap(). - Pick one failure semantics per operation: best-effort (warn per item,
Ok(())at end) or fail-fast (propagate first error). Never warn-per-item and then fail at the end; that surprises exit-code consumers.
Ownership & Borrowing
- Borrow by default:
&str,&[T],impl AsRef<Path>for read-only params. Never&Stringor&Vec<T>. - Every
.clone()is an explicit cost, never one to silence the borrow checker.Cow<'_, str>when allocation is conditional;Arc<T>for shared ownership across threads. - Unsigned types for counts, ports, sizes:
jobs: u32, notjobs: i32. The type documents the constraint. - Let lifetime elision work. A struct with 3+ lifetime params is a design smell: consider owned data.
Naming
RFC 430 casing (snake_case items, CamelCase types, SCREAMING_SNAKE_CASE consts) plus semantic method prefixes:
| Prefix | Contract |
|---|---|
into_ |
consumes self, returns owned |
as_ |
cheap borrowed view, no allocation |
to_ |
possibly expensive, may allocate |
try_ |
fallible variant returning Result |
is_ / has_ |
boolean query |
with_ |
builder-style configuration |
_mut suffix |
mutable variant |
- No
get_for plain field access:fn name(&self) -> &str. Reservegetfor fallible lookups (get(key) -> Option<&V>). - Concrete names:
cmdnotexecutor,typnotty. Single letters only in tiny scopes. - Names must not lie about the data: no
_listsuffix on aHashMap, noowned_on a&str. - Wrap semantically distinct primitives in newtypes (
UserId(u64),OrderId(u64)) so the compiler catches argument swaps.
Control Flow & Iterators
matchon enums stays exhaustive: no_ => {}arm in dispatchers. Spell out every variant: an empty arm with a comment beats a wildcard that silently swallows the next variant added.let-elsefor guard-style early returns; it reads linearly where.ok().and_then(...).is_some_and(...)chains don't.- Iterator chains for value-producing pipelines. When the result is discarded (
let _ = ...), the chain is control flow in disguise; write theforloop. - Return
impl Iterator<Item = T>when callers consume sequentially; premature.collect()allocates for nothing. - Group one logical filter into one
.filter_map()closure (using?and earlyreturn None) instead of fragmented.filter_map().filter().filter_map()chains. - Don't reimplement the standard library:
split_once('=')over.splitn(2, '=').collect(),unwrap_or_default()overunwrap_or_else(|| T::default()). - One fluent chain over three named single-use intermediates; declare variables next to first use, not at the top of the function.
- Prefer chained builder calls over
let mutnamed variables for linear configuration sequences. When each step returnsSelf, naming the intermediate creates a variable-mixing bug class: two similar blocks in one function, copy-paste producesfirst.configure()wheresecond.configure()was meant, and the compiler accepts it. Chaining makes each pipeline self-contained.
Types & Traits
- Derive liberally where semantically correct:
Debugon effectively every public type;Clone,PartialEq,Eq,Hashas meaning allows (ifa == bthenhash(a) == hash(b)must hold);Defaultwhen a meaningful empty value exists. dyn Traitonly for genuinely open sets (plugins, user extension). A closed set of a few known types is an enum: simpler and faster.- Typed
#[derive(Deserialize)]structs overserde_json::Valueindexing: field typos become compile errors instead of silent runtimeNones. impl Traitin argument position for simple bounds;whereclauses when signatures grow. Don't make a function generic when one concrete type is ever used.
Platform & cfg
- Short forms:
#[cfg(windows)],#[cfg(unix)], not#[cfg(target_os = "windows")]. - No redundant
#[cfg]inside a module that's already cfg-gated at itsmoddeclaration. - Helpers used on only one platform must be gated (or
#[allow(unused)]) so every target compiles warning-free. Cross-platform CI fails on the platform that doesn't use them.
Strings & IO
static RE: LazyLock<Regex>for compiled-once regexes; never compile inside a loop or per call.- ASCII character classes (
[a-zA-Z0-9]) over POSIX[[:alnum:]]: the explicit range reads unambiguously and ports to engines where POSIX classes are locale-sensitive (in Rust'sregexthey're ASCII-only either way). - Inline format args:
debug!("found {name:?}"), notdebug!("found {:?}", name). - Forward child-process output with
io::stdout().write_all(&output.stdout)?;println!mangles encoding and panics on broken pipes. - Long-form flags when spawning external commands (
--force, not-f): the call site is its own documentation. - Never truncate a
Stringat a raw byte index.&s[..n]panics mid-codepoint on non-ASCII; the "safe"s.get(..n).unwrap_or(s)is worse: it returns the whole string whennlands mid-codepoint, so oversize input escapes the cap. Usefloor_char_boundary(n)or walkchar_indices().
Modules & Visibility
- Private by default;
pub(crate)for internal sharing. Fields with invariants stay private behind methods. - Organize by domain (
order/,user/), not by kind (models/,services/). pubitems before private helpers in a file;usestatements at the top, never inside functions.
Comments & Docs
- Comments explain why: hidden constraints, upstream bug links, invariants the reader can't see. Never what the next line does. Delete commented-out code; git remembers.
- Public APIs get
///docs with# Examples(runnable), plus# Errors,# Panics,# Safetywhen applicable. - Every lint suppression is justified:
#[expect(lint, reason = "...")]over bare#[allow]. - Non-obvious string input formats get documented above the signature (
// accepts "name", "name:tag", or "ns/name:tag").
Tooling
cargo fmtandcargo clippyclean in CI, no exceptions. Fix warnings; don't blanket-suppress.- Libraries:
#![warn(clippy::pedantic)]and selectively allow, never a blanket#![allow(clippy::all)]. Considerunwrap_used/expect_usedatwarn(seereferences/testing.mdfor keeping test code exempt). - No formatting-only churn in feature PRs: the diff should match the description.
Unsafe
Default posture: unsafe_code = "forbid" until a concrete need exists. When it does exist, every block carries a // SAFETY: comment and stays minimal; full discipline in references/unsafe.md.
Process
- One PR = one focused change; formatting fixes, new workflows, and features travel separately.
- A technically-correct change that breaks a user contract (CLI flags, config keys, env semantics) keeps the legacy path, ships the new one alongside, and milestones the removal for the next major. Warn only on genuine old-vs-new conflicts: end users on only-old or only-new see nothing.
- Feature-detect external tools by parsing
--versionoutput; where that's unreliable, sniff for specific stderr messages. Either way, probe with plain.output()so a failed probe stays non-fatal.
Pre-Submit Checklist
- No
.unwrap()outside tests/compile-time constants; no.is_ok()+ separate access - Failure semantics consistent: best-effort or fail-fast, not mixed; cleanup always runs
- Borrows by default; no
&String/&Vec<T>; every.clone()intentional - Method prefixes honest (
into_/as_/to_/try_); newtypes for swappable primitives - No
_ => {}in enum dispatchers;let-elseover combinator gymnastics -
impl Iteratorover prematureVec; no stdlib reimplementations - Typed
DeserializeoverValue;dynonly for open sets -
#[cfg(windows)]short form; single-platform helpers gated for all-target CI -
LazyLockregexes; inline format args;write_allfor child output - Comments say why; suppressions carry
reason; no commented-out code - fmt + clippy clean; no formatting churn outside the change
- One focused change per PR; breaking changes keep the legacy path, removal milestoned