---
name: clean-rust
description: "Idiomatic Rust 2024 rules: ownership, error handling, async, unsafe, traits, iterators, clippy."
when_to_use: "Use when writing, reviewing, or linting Rust. For language-agnostic style, `clean-code`."
metadata:
  author: uwuclxdy
  version: "1.11"
---

# 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 justified `expect` carries a message naming the invariant that makes it safe.
- No sentinel returns (`-1`, `""`) for failure. `Option<T>` for absence that isn't an error.
- `thiserror` for library errors (callers can match), `anyhow`/`eyre` at the application boundary. Never `Box<dyn Error>` or `String` as a library error type.
- Bind a `Result` once with `match`/`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 `&String` or `&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`, not `jobs: 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`. Reserve `get` for fallible lookups (`get(key) -> Option<&V>`).
- Concrete names: `cmd` not `executor`, `typ` not `ty`. Single letters only in tiny scopes.
- Names must not lie about the data: no `_list` suffix on a `HashMap`, no `owned_` on a `&str`.
- Wrap semantically distinct primitives in newtypes (`UserId(u64)`, `OrderId(u64)`) so the compiler catches argument swaps.

## Control Flow & Iterators

- `match` on 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-else` for 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 the `for` loop.
- 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 early `return 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()` over `unwrap_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 mut` named variables for linear configuration sequences. When each step returns `Self`, naming the intermediate creates a variable-mixing bug class: two similar blocks in one function, copy-paste produces `first.configure()` where `second.configure()` was meant, and the compiler accepts it. Chaining makes each pipeline self-contained.

## Types & Traits

- Derive liberally where semantically correct: `Debug` on effectively every public type; `Clone`, `PartialEq`, `Eq`, `Hash` as meaning allows (if `a == b` then `hash(a) == hash(b)` must hold); `Default` when a meaningful empty value exists.
- `dyn Trait` only 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 over `serde_json::Value` indexing: field typos become compile errors instead of silent runtime `None`s.
- `impl Trait` in argument position for simple bounds; `where` clauses 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 its `mod` declaration.
- 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's `regex` they're ASCII-only either way).
- Inline format args: `debug!("found {name:?}")`, not `debug!("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 `String` at 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 when `n` lands mid-codepoint, so oversize input escapes the cap. Use `floor_char_boundary(n)` or walk `char_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/`).
- `pub` items before private helpers in a file; `use` statements 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`, `# Safety` when 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 fmt` and `cargo clippy` clean in CI, no exceptions. Fix warnings; don't blanket-suppress.
- Libraries: `#![warn(clippy::pedantic)]` and selectively allow, never a blanket `#![allow(clippy::all)]`. Consider `unwrap_used`/`expect_used` at `warn` (see `references/testing.md` for 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: users on only-old or only-new see nothing.
- Feature-detect external tools by parsing `--version` output; 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-else` over combinator gymnastics
- [ ] `impl Iterator` over premature `Vec`; no stdlib reimplementations
- [ ] Typed `Deserialize` over `Value`; `dyn` only for open sets
- [ ] `#[cfg(windows)]` short form; single-platform helpers gated for all-target CI
- [ ] `LazyLock` regexes; inline format args; `write_all` for 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
