Rust Style Guide
This book publishes the Rust style guide skill as browsable documentation.
The guide gives AI coding agents concrete, opinionated defaults for writing, reviewing, refactoring, and configuring Rust code. It is not a Rust tutorial.
Start Here
- Skill router - choose the right guideline or workflow for a task.
- Guidelines index - find focused Rust style policy pages.
- Workflows - follow procedures for larger Rust tasks.
The book includes packaged skill content only: the router, guideline pages, and workflow pages. Drafting notes, decision records, and source research remain repository-maintenance material.
Rust Style Guide
Apply the loaded policy pages directly.
Routing
Workflows
| Task | Load |
|---|---|
| Create or configure a Rust project | new project workflow |
| Verify a reusable library release | library release workflow |
| Investigate performance | performance workflow |
| Review or refactor Rust code | review workflow |
Policy Fast Paths
| Task | Load |
|---|---|
| Define a public library error | library/application errors, error taxonomy, public API evolution |
| Handle top-level application errors | library/application errors, error propagation, panics |
| Choose enum, generic, or trait object | enum/trait selection, trait design |
| Add a domain ID or validated value | newtypes, constructors, validation |
| Write async service code | async runtime, task lifecycle, shutdown, logging |
| Add instrumentation | logging and observability |
| Configure formatting, lints, or tests | rustfmt, Clippy, CI |
| Review unsafe code or macros | unsafe code and macros, public API evolution |
| Other Rust policy work | guideline index |
Core Behavior
- Workflows own multi-step procedures and route to their policy pages.
- Fast paths load only the directly linked owner pages.
- Use the guideline index only when no workflow or fast path matches.
- Select conditional pages using task descriptions in this router, the guideline index, or workflow routing.
- After loading a conditional page, apply it only when its
Activationsection matches. - Prefer concrete Rust guidance over language tutorials.
- Ask one focused question only when required project context is missing.
Guidelines
Use page descriptions to select pages before loading them. After loading a conditional page, apply it only when its Activation section matches.
Foundations
- House style and Rust philosophy - load for overall code shape, OO-leaning defaults, and Rust idiom tradeoffs.
- Library vs application conventions - load before choosing policies that differ for libraries, apps, CLIs, tests, or services.
- Rust edition and MSRV - load when setting edition,
rust-version, stable/nightly posture, or checking MSRV impact.
Tooling and Project Shape
- rustfmt and formatting - load when configuring rustfmt or handling formatting exceptions.
- rustc and Clippy lints - load when configuring lints, fixing Clippy, or justifying lint exceptions.
- Cargo, workspaces, features, and dependencies - load for workspace layout, features, dependency choices, and MSRV-aware dependency changes.
- Application workspace architecture - load when deciding application crate boundaries, dependency direction, component and adapter roles, or composition roots.
- Modules, visibility, and re-exports - load when changing
mod,pub, facades, re-exports, or canonical public paths. - Naming, imports, and prelude policy - load for item names, acronym casing, imports, globs, and preludes.
- Documentation and rustdoc examples - load when writing rustdoc, public docs, examples, or
Errors/Panics/Safetysections.
Type and API Design
- Struct design and encapsulation - load when designing structs, fields, invariants, receivers, or encapsulation boundaries.
- Constructors and builders - load when choosing
new,try_new,Default, builders, or typestate builders. - Newtype pattern and semantic wrappers - load when adding IDs, units, validated strings, value objects, or orphan-rule wrappers.
- Enums vs traits vs generics vs trait objects - load when choosing closed sets, open extension, static dispatch, or runtime heterogeneity.
- Trait design - load when designing traits, bounds, associated types, blanket impls, sealed traits, or object-safe APIs.
- Deriving and common trait implementations - load when adding derives or manual impls for standard traits.
- Conversions, getters, and method naming - load for
From,TryFrom,AsRef,Deref, accessors, andas_/to_/into_names. - Typestate and state machines - load for ordered workflow states, data-bearing enums,
PhantomData, or compile-time transitions. - Public API evolution - load for externally consumed APIs, semver, deprecation,
#[non_exhaustive], or#[must_use].
Ownership and Data Flow
- Ownership, borrowing, and clone policy - load when choosing borrowed inputs, owned outputs,
String/&str,Pathparameters,IntoIterator,AsRef,Cow, accessors, snapshots, or clone tradeoffs. - Lifetimes - load when explicit lifetimes, borrowed structs, or lifetime-heavy APIs appear.
- Smart pointers and interior mutability - load when choosing
Box,Rc,Cell,RefCell,Weak, or one-time initialization. - Collections and data structures - load when choosing
Vec, maps, sets, deterministic ordering, capacity, or specialized collection crates.
Errors, Safety, and Diagnostics
- Error taxonomy and layer boundaries - load when defining domain, infrastructure, boundary, or branch-oriented error layers.
- Library errors vs application errors - load before choosing
thiserror,anyhow,miette, or public error stability. - Error propagation, context, and messages - load when adding
?, context, source chains, or error message text. - Panics, unwrap, expect, and assertions - load when using panic,
unwrap,expect, assertions,unreachable!,todo!, or public panic docs. - Validation and invariants - load when parsing inputs, enforcing constructors, encoding invariants, or re-checking stale state.
- Logging and observability - load when adding
tracing, spans, fields, levels, error logs, or redaction.
Async and Concurrency
- Async runtime and when to use async - load when deciding sync vs async posture, Tokio use, or runtime boundaries.
- Async API design and task lifecycle - load when adding async APIs, async traits, spawning, task owners,
Send, or shutdown handles. - Cancellation, shutdown, and blocking work - load for cancellation tokens,
select!, timeouts,spawn_blocking, CPU work, or graceful shutdown. - Concurrency primitives - load when adding channels, locks, atomics,
Arcshared state, or coordination primitives.
Everyday Implementation
- Control flow - load when choosing
match,if let,let else, guards, early returns, mutable locals, or in-place updates. - Option and Result idioms - load when transforming
Option/Result, usingok_or_else,transpose,map, or explicit branching. - Iterators, closures, and loops - load when choosing iterator chains, loops, closure capture,
collect,fold, ortry_fold.
Testing and Release
- Testing and doctests - load when writing unit tests, integration tests, doctests, fixtures, or test helpers.
- Property tests, snapshots, benchmarks, and CI - load when configuring test commands, snapshots, property tests, benchmarks, or CI gates.
- Unsafe code and macros - load when touching
unsafe, FFI, raw pointers,macro_rules!, proc macros, or generated APIs.
House Style and Rust Philosophy
Rule
Write idiomatic Rust with an OO-leaning default: model domain concepts as structs with methods, protect invariants, and compose behavior explicitly.
Why
Rust supports data with behavior without inheritance. Clear ownership and composition provide structure without forcing object-oriented patterns that do not fit the language.
Do
- Start with domain types when values carry distinct meaning.
- Put behavior on the type that owns the relevant data or invariant.
- Prefer direct composition with explicit fields and methods.
- Use free functions for algorithms or cross-type operations with no natural receiver.
- Use functional pipelines when they are clearer than stateful updates.
- Follow the owner pages for fields, traits, and iteration.
Avoid
- Do not emulate inheritance hierarchies with traits, enums, or nested wrappers.
- Do not move natural type behavior into stateless helper modules.
- Do not expose helpers publicly only to make private behavior testable.
- Do not create pass-through wrappers or delegation machinery around a confused boundary.
- Do not choose a design-pattern name over simpler Rust types, modules, and ownership.
Example
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Money {
cents: u64,
}
impl Money {
pub const ZERO: Self = Self { cents: 0 };
pub fn checked_add(self, other: Self) -> Option<Self> {
self.cents
.checked_add(other.cents)
.map(|cents| Self { cents })
}
}
}
Exceptions
- Use plain data structs when their fields are the complete API and carry no invariants.
- Introduce a trait before a second implementation only when callers already need substitution or a testing seam.
Library vs Application Conventions
Rule
Classify code before applying policy: published or independently consumed library, shared in-repo crate, application or service, CLI, test support, or tests.
Why
External callers update independently, while application code can change with its callers. The classification determines which half of context-sensitive policy applies.
Do
- Treat published crates and independently consumed APIs as long-lived contracts.
- Treat shared in-repo crates as application code until an outside consumer updates independently.
- Treat applications, services, and CLIs as owners of process-wide setup and concrete integrations.
- Route context-specific choices to their owner pages:
Avoid
- Do not impose published-library abstraction on private application internals.
- Do not use application conveniences in reusable library signatures or global setup.
- Do not assume every
pubitem in an internal crate needs external semver treatment. - Do not classify by crate type alone; classify by who consumes and updates the API.
Example
#![allow(unused)]
fn main() {
// Reusable boundary: callers inspect a stable error type.
pub fn parse_manifest(source: &str) -> Result<Manifest, ManifestError> {
parse_manifest_source(source).map_err(ManifestError::from)
}
// Application boundary: the application owns reporting and context.
pub async fn run_deploy(args: DeployArgs) -> anyhow::Result<()> {
execute_deploy(args).await
}
}
Exceptions
- Keep application internals typed when callers branch on failures.
- Treat an internal API as external when another team, plugin, service, or generated client consumes it independently.
Rust Edition and MSRV
Rule
Use Rust 2024 for new code and declare rust-version; default new Rust 2024 crates to 1.85 unless project constraints require otherwise.
Why
The edition controls language compatibility, while rust-version makes the supported compiler floor visible to Cargo, CI, and downstream users.
Do
- Set
edition = "2024"andrust-version = "1.85"for new crates. - Keep workspace members aligned unless a crate documents a different constraint.
- Use stable language features by default.
- Check reusable libraries, including feature-gated code, with the declared MSRV.
- Treat a published library MSRV bump as a deliberate compatibility change.
- Check dependency changes against the MSRV policy on Cargo and dependencies.
Avoid
- Do not omit
rust-versionor set it below the selected edition’s minimum. - Do not use APIs stabilized after the declared MSRV without updating the declaration.
- Do not let a dependency silently raise a library’s practical MSRV.
- Do not use nightly language features as house style.
Example
[package]
name = "example-crate"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
Verify reusable code with the declared toolchain:
cargo +1.85.0 check --workspace --all-targets --all-features
Exceptions
- Use Rust 2021 when downstream users, targets, dependencies, or tooling require it.
- Use a higher MSRV when the project deliberately depends on newer stable features.
- Migrate existing crates as a focused mechanical change when practical.
rustfmt and Formatting
Rule
Use the checked-in rustfmt.toml with pinned nightly-2026-04-14 as the formatting authority.
Why
A pinned formatter keeps editor, agent, local, and CI output reproducible when the project uses unstable rustfmt options.
Do
- Keep
rustfmt.tomlat the workspace root. - Run
cargo +nightly-2026-04-14 fmt --allbefore handing off Rust changes. - Run the same toolchain with
--checkin CI. - Let rustfmt decide layout.
- Change formatter settings only in a focused formatting change.
Avoid
- Do not mix stable, unpinned nightly, and the project pin.
- Do not hand-format around rustfmt output.
- Do not use
#[rustfmt::skip]except for generated code or literals whose structure would become less readable.
Example
cargo +nightly-2026-04-14 fmt --all
cargo +nightly-2026-04-14 fmt --check --all
Exceptions
- Existing projects may retain their current pin until a focused update.
- Generated code may preserve generator-controlled layout.
- Documentation snippets may use manual line breaks when rustfmt does not process them.
rustc and Clippy Lints
Rule
Use the checked-in curated workspace lint baseline, enforce warnings in CI, and justify narrow exceptions with #[expect(..., reason = "...")].
Why
Central policy keeps the baseline consistent, while local expectations make intentional exceptions visible and auditable.
Do
- Put shared lint levels in the workspace
Cargo.tomland opt member crates into them. - Run
cargo clippy --locked --workspace --all-targets --all-features -- -D warningsin CI. - Enable
clippy::pedanticatwarn, then centrally allow rejected noisy lints. - Deny correctness and project-boundary violations deliberately.
- Put architecture-specific disallowed items in
clippy.toml. - Use local
#[expect]at the narrowest scope for justified deviations.
Avoid
- Do not enable all restriction lints or deny all pedantic lints.
- Do not add unexplained
#[allow]attributes. - Do not hide a one-off exception in workspace-wide configuration.
- Do not copy architecture-specific guardrails without checking the target project.
- Do not weaken policy merely to unblock one implementation.
Lint Levels and CI
denyfails where the lint runs.warnbecomes an error under CI’s-D warnings.allowis a real exemption and belongs in central policy only when broadly intended.
Example
#![allow(unused)]
fn main() {
#[expect(
clippy::print_stdout,
reason = "curated command help is written directly to stdout"
)]
fn print_help() {
println!("usage: app <command>");
}
}
Exceptions
- Use
#[allow]when#[expect]is unavailable or generated code intentionally disables a lint. - Move an exception to workspace configuration only after adopting it as project policy.
- Lower
unsafe_code = "deny"only for a crate whose documented purpose requires unsafe code.
Cargo, Workspaces, Features, and Dependencies
Rule
Keep Cargo configuration explicit: share policy through workspaces, add dependencies only for identified needs, and keep reusable-library features additive and minimal.
Why
Cargo choices affect compilation, public API, binary size, MSRV, and downstream compatibility long after the immediate change.
Do
- Use a workspace when multiple crates share package, dependency, lint, or profile policy.
- Put shared dependency versions in
[workspace.dependencies]only after a member needs them. - Add mature domain crates when they remove real complexity or improve correctness.
- Be conservative with library dependencies and pragmatic with application dependencies.
- Prefer
clapderive for nontrivial application CLIs; hand-parse only tiny private interfaces. - Keep reusable-library features additive and opt-in.
- Make serialization optional unless it is core to the crate, and treat published formats as contracts.
- Verify changed reusable libraries with all supported features and the declared MSRV.
Avoid
- Do not prepopulate a new project with optional ecosystem dependencies.
- Do not add a crate for a trivial wrapper around
std. - Do not expose dependency types publicly unless they are an intended contract.
- Do not add mutually exclusive or speculative feature flags.
- Do not make default library features pull in heavy optional integrations.
- Do not derive public serialization without deciding compatibility behavior.
Library vs Application
Libraries minimize defaults and public dependency exposure. Applications depend directly on concrete integrations and normally do not feature-gate internal implementation details.
Example
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
[features]
serde = ["dep:serde"]
The feature exists because serialization is a real optional integration, not because the crate might need it later.
Exceptions
- Use a heavier dependency when it is the mature ecosystem standard for the domain.
- Use default features for an intentionally batteries-included library.
- Pin exact versions when reproducibility, upstream breakage, or security response requires it.
- Document a feature matrix when unavoidable integrations cannot be enabled together.
Application Workspace Architecture
Rule
For greenfield multi-crate applications, start each cohesive capability as one crate with private modules, extract crates only for meaningful dependency, contract, compilation, or ownership boundaries, keep crate dependencies acyclic, and let application crates assemble concrete implementations.
Activation
Apply when designing a new application workspace or deliberately restructuring an application with multiple business capabilities. Skip single-crate applications, standalone libraries, and maintenance work that does not include architecture changes.
Why
Crates enforce dependency and compilation boundaries but turn internal collaboration into cross-crate APIs. Modules preserve finer visibility and let cohesive code change together with less ceremony.
Do
- Treat components, adapters, applications, and narrow foundation capabilities as useful roles, not mandatory top-level directories.
- Begin a component as one crate, organize it with private modules, and expose only its intended surface.
- Extract an API crate when consumers need contracts without implementation dependencies, a separate contract prevents a bad dependency edge, or the contract is independently useful.
- Extract an adapter crate for a substantial external integration or an independently useful implementation.
- Add a facade crate only when several real component crates need one intended consumer entry point; applications still select external adapters and own process setup.
- Put contracts with the code that owns the abstraction: caller-facing services belong to the component surface, while outbound ports belong with the business logic that consumes them.
- Let components use another component’s intended public surface when the dependency graph remains acyclic; never reach into another component’s implementation internals.
- Keep business logic independent of database, HTTP, cloud SDK, and process setup. Adapters depend on the contracts they implement; applications select adapters and wire the system.
- Keep foundation crates lower-level than components and give each one a narrow responsibility; they do not depend on components, adapters, or applications. Domain types stay with the component that owns their meaning.
- Use component-prefixed crate names such as
accounts-apiandaccounts-postgres; names must remain clear without directory context. - Follow Cargo policy for workspace mechanics and module policy inside each crate.
Avoid
- Do not create
api,core, and facade crates for every component by default. - Do not create broad
common,shared,utils,helpers, orcommon-typescrates. - Do not extract crates merely for directory organization or speculative future reuse.
- Do not let a technology-only adapter crate become the coupling point for unrelated components.
- Do not use a facade crate only to re-export every implementation detail.
Example
Start with the smallest graph that expresses the real boundaries:
crates/
accounts/
accounts-postgres/
server/
Here A -> B means A depends on B:
server -> accounts
server -> accounts-postgres
accounts-postgres -> accounts
accounts owns its business logic and repository contract, accounts-postgres implements that contract, and server is the composition root. Split an accounts-api crate only when a real consumer or dependency boundary needs it.
Exceptions
- Preserve an existing workspace architecture unless changing it is part of the task.
- Let one infrastructure crate serve multiple components when the integration is cohesive and its shared ownership is intentional.
- Use a different crate taxonomy for tooling, test support, proc macros, protocols, migrations, or other roles that do not fit application components.
Modules, Visibility, and Re-exports
Rule
Keep modules private by default, expose focused facades, and give each local item one intended public path.
Why
Small public surfaces protect invariants and let implementations move without breaking callers.
Do
- Keep implementation modules private unless their path is part of the API.
- Use
pub(crate)for real internal boundaries andpub(super)for tight parent-child collaboration. - Re-export the public concepts callers should name from the crate root or a focused facade.
- Choose either a facade path or a public module path as canonical for each local item.
- Use
#[doc(inline)]when a re-export from a public module or another crate should appear at the facade path. - Follow struct design for field visibility and naming/imports for preludes.
Avoid
- Do not expose deep module paths accidentally.
- Do not use
pubwhenpub(crate)is enough. - Do not re-export every internal type from the crate root.
- Do not expose the same local item through multiple accidental public paths.
- Do not let implementation churn define public module structure.
Public API Notes
Published libraries treat every intended pub path as compatibility surface. Binary crates normally need only private or pub(crate) boundaries.
Example
#![allow(unused)]
fn main() {
// lib.rs
mod client;
mod error;
mod request;
pub use client::Client;
pub use error::ClientError;
pub use request::Request;
}
Exceptions
- Use
pub modwhen the module is an intentional stable namespace. - Keep an additional public path only for documented compatibility or a deliberate ergonomic alias.
Naming, Imports, and Prelude Policy
Rule
Use idiomatic Rust casing, explicit module-level imports grouped by rustfmt, and no broad crate prelude by default.
Why
Predictable names and imports make code easy to scan without hiding dependencies behind unusual casing or broad glob imports.
Do
- Use Rust-style acronym casing:
HttpClient,UrlParser,JsonBody,ApiToken. - Use
SCREAMING_SNAKE_CASEfor constants and statics. - Keep imports at module scope and let rustfmt group them.
- Use
as _for extension traits imported only to enable methods. - Follow conversions and getters for accessor and conversion names.
- Add a prelude only when a broad ecosystem crate makes users import many commonly paired traits and types.
Avoid
- Do not use names such as
HTTPClientorURLParser. - Do not use broad production glob imports.
- Do not add a prelude to a small crate or ordinary application.
- Do not preserve external naming conventions inside Rust identifiers when a boundary conversion is clearer.
Example
#![allow(unused)]
fn main() {
use std::path::Path;
use anyhow::{Context as _, Result};
use crate::{Config, RunId};
pub fn load_config(path: &Path, run_id: RunId) -> Result<Config> {
Config::load(path).with_context(|| format!("loading config for run {run_id}"))
}
}
Exceptions
- Use wildcard imports in tests, test support, or intentional third-party prelude APIs.
- Preserve uppercase spellings required by generated code, protocols, or wire formats.
Documentation and Rustdoc Examples
Rule
Document non-obvious public behavior; when a project maintains rustdoc examples, make them compile and use ? for ordinary failures.
Why
Useful rustdoc explains contracts and caveats that names and types cannot express. Obvious restatement and unmaintained examples add noise.
Do
- Document public invariants, caveats, side effects, and important concepts.
- Use
//!for module-level concepts and///for item contracts. - Add
# Errorsfor caller-relevant failure modes. - Add
# Panicsfor public panic behavior and# Safetyfor unsafe contracts. - Add examples only when they materially clarify use and the project opts into maintaining them.
- Prefer compiling examples with a fallible
mainand hidden boilerplate.
Avoid
- Do not require
#![deny(missing_docs)]as house style. - Do not restate an item’s name or signature in prose.
- Do not use doctests as default coverage.
- Do not use bare
unwrapin public examples. - Do not mark examples
ignoremerely to avoid maintenance.
Public API Notes
Prioritize constructors, fallible operations, trait contracts, and behavior that callers can misuse. Internal application docs may stay sparse outside shared boundaries.
Example
/// Loads configuration and applies environment overrides.
///
/// # Errors
///
/// Returns an error when the file is unreadable or invalid.
///
/// # Examples
///
/// ```no_run
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let config = example_config::Config::load("app.toml")?;
/// assert_eq!(config.profile(), "default");
/// # Ok(())
/// # }
/// ```
pub fn load(path: &str) -> Result<Config, ConfigError> {
Config::read(path)
}
Exceptions
- Use
no_runfor examples that compile but would access real services or state. - Use
ignoreonly when portability is impossible. - Use
expectfor fixed setup invariants when a fallible wrapper would obscure the API.
Struct Design and Encapsulation
Rule
Model meaningful concepts as structs with private fields and behavior-bearing methods; use public fields only for plain data without invariants.
Why
Private fields let a type control construction and mutation, while methods make ownership and behavior explicit.
Do
- Keep invariant-bearing fields private.
- Put behavior on the type that owns the required data.
- Use
&selffor observation,&mut selffor mutation, andselffor consuming transitions. - Expose only the read accessors callers need.
- Use
pub(crate)only for real internal module boundaries. - Use public fields for DTOs, configuration records, snapshots, and other intentional plain data.
Avoid
- Do not make fields public to avoid constructors or accessors.
- Do not put methods on wrappers that do not own the relevant data or invariant.
- Do not generate getters and setters for every field.
- Do not expose production mutation hooks solely for tests.
- Do not split natural behavior into unrelated helper modules.
Public API Notes
Public fields prevent constructors and destructuring from evolving independently. Published invariant-bearing types therefore keep fields private.
Example
#![allow(unused)]
fn main() {
pub struct UserAccount {
email: EmailAddress,
active: bool,
}
impl UserAccount {
pub fn email(&self) -> &EmailAddress {
&self.email
}
pub fn deactivate(&mut self) {
self.active = false;
}
}
pub struct UserSummary {
pub email: EmailAddress,
pub active: bool,
}
}
Exceptions
- Use tuple structs for small semantic wrappers as described by newtypes.
- Use free functions for algorithms with no natural owner type.
Constructors and Builders
Rule
Use new or try_new for required fields, add a builder for substantial optional configuration, and use typestate builders only for important required steps.
Why
Constructors keep invariants close to a type. Builders improve named optional configuration but add API surface and should earn that cost.
Do
- Use
newfor infallible construction from required values. - Use
try_newwhen construction validates or can fail; reserveparseforFromStr-backed text parsing. - Follow ownership policy for constructor parameters.
- Keep validation inside the constructor or
buildmethod. - Implement
Defaultonly for an obvious, useful value. - Use a builder when defaults or optional fields would otherwise produce boolean flags and repeated
Nonearguments. - Prefer consuming setters for owned configuration builders.
- Use typestate only when a missing or out-of-order step would violate an important invariant.
Avoid
- Do not add a builder for every struct.
- Do not let
newpanic on caller-provided input. - Do not use long positional constructors with boolean flags.
- Do not encode ordinary optional settings with typestate.
- Do not provide a surprising, invalid, or environment-dependent
Default.
Public API Notes
Adding a required constructor parameter is normally breaking. Optional builder methods are easier to add compatibly to published APIs.
Example
#![allow(unused)]
fn main() {
#[derive(Clone, Debug)]
pub struct ClientOptions {
endpoint: String,
timeout: Duration,
}
impl ClientOptions {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
timeout: Duration::from_secs(30),
}
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
}
Exceptions
- Use struct literals for intentional plain data.
- Use
&mut selfsetters when callers must reuse a builder or an existing API establishes that style. - Use a builder crate only when builder-heavy code justifies the dependency.
Newtype Pattern and Semantic Wrappers
Rule
Use newtypes for IDs, units, validated values, and public API meaning; do not wrap primitives when no safety, validation, or clarity is gained.
Why
Newtypes prevent argument swaps, attach validation to values, and give APIs stable domain names independent of primitive representation.
Do
- Keep meaningful or validated inner fields private.
- Use
newfor infallible wrappers andtry_newfor validated values. - Borrow textual input when validation or normalization derives the stored value.
- Expose only focused views and consuming conversions.
- Follow deriving policy for standard traits.
- Follow conversion naming for
as_*andinto_*methods. - Use
#[repr(transparent)]only for a documented layout requirement.
Avoid
- Do not wrap every primitive.
- Do not expose an invariant-bearing inner field publicly.
- Do not implement
Derefmerely to inherit primitive methods. - Do not use
Fromwhen conversion validates or can fail. - Do not choose vague names such as
ValueorIdoutside a narrow domain module.
Public API Notes
Published APIs should use newtypes where primitive arguments can be confused or representation may evolve. Application internals need them mainly at boundaries and for real domain meaning.
Example
#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EmailAddress(String);
impl EmailAddress {
pub fn try_new(value: &str) -> Result<Self, EmailAddressError> {
let value = value.trim();
if !value.contains('@') {
return Err(EmailAddressError::MissingAt);
}
Ok(Self(value.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
}
Exceptions
- Use a public tuple field for an intentionally transparent wrapper without invariants or evolution pressure.
- Use
Dereffor a genuinely pointer-like abstraction. - Use a primitive when the value is local, obvious, and does not cross an API boundary.
Enums vs Traits vs Generics vs Trait Objects
Rule
Use enums for closed sets, traits for open extension points, generics for static dispatch, and dyn Trait for runtime heterogeneity.
Why
Each choice encodes a different extension and dispatch model, so the API should make that model deliberate.
Do
- Use an enum when this crate controls every variant.
- Put shared behavior on a closed enum when callers should not extend it.
- Use a trait when callers or another layer must supply new behavior.
- Use
impl Traitor a type parameter when one implementor type flows through the call. - Use trait objects for runtime selection or heterogeneous collections.
- Prefer returning concrete types or
impl Traitunless runtime polymorphism is required. - Follow trait design for contracts, object safety, and sealing.
Avoid
- Do not create a trait only because closed variants share method names.
- Do not use a closed enum when downstream users must add variants.
- Do not spread generics through many layers when a trait object localizes the choice.
- Do not use dynamic dispatch merely to avoid a type parameter.
- Do not expose a trait-object API before confirming the trait is object-safe.
Public API Notes
Adding an enum variant can break exhaustive downstream matches. An open public trait commits the crate to supporting external implementors.
Example
#![allow(unused)]
fn main() {
pub enum DeliveryTarget {
Email(EmailAddress),
Webhook(WebhookUrl),
}
pub trait Notifier {
fn notify(&self, message: &Message) -> Result<(), NotifyError>;
}
pub fn notify_once(notifier: &impl Notifier, message: &Message) -> Result<(), NotifyError> {
notifier.notify(message)
}
pub struct Broadcast {
notifiers: Vec<Box<dyn Notifier>>,
}
}
Exceptions
- Use a trait for a closed set when existing generic infrastructure requires it.
- Use an enum wrapper around trait objects when the public categories are closed but implementations are dynamic.
- Use generics publicly only when caller flexibility outweighs type complexity.
Trait Design
Rule
Write small behavior-focused traits, document their contracts, and seal public traits when external implementations are not intended.
Why
Traits are extension contracts. Their required methods and semantics constrain implementors, object users, and future API evolution.
Do
- Keep required methods cohesive and minimal.
- Name traits after behavior or capability.
- Document implementor guarantees, errors, blocking behavior, and concurrency expectations.
- Build convenience methods from the required core as provided methods.
- Use associated types when each implementor chooses a related type.
- Use generic methods when each caller chooses the type for a call.
- Keep bounds near the function that needs them.
- Make traits object-safe when intended for
dyn Trait; put generic provided methods behindSelf: Sized. - Seal a public trait when callers may use it but only the crate may implement it.
Avoid
- Do not create a trait merely to organize one concrete type’s methods.
- Do not combine unrelated capabilities into one trait.
- Do not add required methods casually to an open public trait.
- Do not add blanket implementations that block plausible future impls.
- Do not encode inheritance with supertraits unless every supertrait is a real contract.
Public API Notes
An unsealed public trait is a semver commitment to external implementors. See public API evolution for compatibility policy.
Example
#![allow(unused)]
fn main() {
pub trait Notifier {
fn notify(&self, message: &Message) -> Result<(), NotifyError>;
fn notify_text(&self, body: impl Into<String>) -> Result<(), NotifyError>
where
Self: Sized,
{
self.notify(&Message::new(body))
}
}
}
Exceptions
- Match a broader mature ecosystem trait when interoperability is the purpose.
- Use a marker trait only for a real compile-time contract.
- Leave a public trait open when downstream implementations are intentional.
Deriving and Common Trait Implementations
Rule
Derive standard traits when their semantics are obvious, hand-write Display, and avoid semantics-heavy derives by habit.
Why
Derived impls are correct when structural behavior matches domain behavior; otherwise they can expose secrets or promise misleading equality, ordering, defaults, or clone semantics.
Do
- Derive
Debugfor ordinary data and redact secret-bearing types manually. - Derive
Clonefor value semantics with acceptable cost. - Derive
Copyonly for small scalar-like values with unsurprising duplication. - Derive equality and hashing only when the same fields define both.
- Derive ordering only when one total order is obvious.
- Implement
Defaultonly for a valid, useful, unsurprising value. - Hand-write
Displayfor stable user-facing text. - Keep manual equality, hashing, and ordering implementations coherent.
Avoid
- Do not derive traits solely to satisfy a temporary test or log statement.
- Do not derive
Debugfor credentials or tokens. - Do not derive
Copyfor resources or types likely to gain owned data. - Do not derive arbitrary ordering or invalid defaults.
- Do not use
Displayfor programmer-only diagnostics. - Do not derive serialization unless a wire format is intentional.
Public API Notes
Public trait impls are compatibility surface. Add only semantics the type can support over time.
Example
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct UserId(u64);
#[derive(Clone, Eq, PartialEq)]
pub struct ApiToken(String);
impl std::fmt::Debug for ApiToken {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ApiToken(<redacted>)")
}
}
}
Exceptions
- Keep impl surface smaller while a public type’s semantics are unsettled.
- Add derives freely to test-only helpers when they do not leak into production API.
- Hand-write traits when domain behavior differs from field-by-field behavior.
Conversions, Getters, and Method Naming
Rule
Use honest conversion traits and method prefixes: From is infallible, TryFrom and FromStr validate, as_ borrows, to_ creates, into_ consumes, and field-like accessors use bare names.
Why
Conversion and method names communicate failure, ownership, and allocation without requiring callers to inspect implementations.
Do
- Use
Fromfor infallible, obvious conversions. - Use
TryFromorFromStrfor validation and fallible parsing. - Use
Fromfor lossless numeric widening andTryFromfor narrowing or signedness changes. - Choose explicit overflow behavior with
checked_*,saturating_*,wrapping_*, oroverflowing_*. - Use
as_*for borrowed or scalar views,to_*for created values, andinto_*for consuming conversions. - Name field-like accessors
id(),name(), orstatus()and predicatesis_*,has_*, orcan_*. - Borrow accessors unless returning a small
Copyvalue; ownership policy owns return choices.
Avoid
- Do not use
Fromfor validation, failure, surprising allocation, or lossy meaning. - Do not use unchecked
asfor narrowing or float-to-integer conversion. - Do not use approximate floats with
==or sort NaN-capable floats withouttotal_cmp. - Do not name allocating methods
as_*. - Do not use
get_*for simple observation or generate every possible accessor. - Do not implement
Derefmerely to forward inner methods.
Example
#![allow(unused)]
fn main() {
impl ProjectName {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_slug(&self) -> String {
self.0.to_ascii_lowercase().replace(' ', "-")
}
pub fn into_string(self) -> String {
self.0
}
}
impl std::str::FromStr for ProjectName {
type Err = ProjectNameError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::try_new(value)
}
}
}
Exceptions
- Use
get_*for keyed lookup, cache retrieval, or fallible computed access. - Return named owned snapshots when borrowing would expose unnecessary lifetimes.
Typestate and State Machines
Rule
Use typestate when compile-time transition enforcement materially protects an important workflow invariant; otherwise use runtime enums or validated structs.
Why
Typestate prevents invalid ordering but exposes state in type signatures and makes storage, persistence, and evolution more complex.
Activation
Apply this page to ordered workflows such as connected-to-authenticated or configured-to-started. Skip ordinary optional configuration.
Do
- Use small marker types for compile-time states.
- Store shared data in one generic
Workflow<State>type. - Put consuming transitions on the source state.
- Put state-independent accessors on
impl<State>. - Use
PhantomData<State>when state has no runtime data. - Use runtime enums for persisted, networked, user-selected, or heterogeneous state.
- Keep external validation even when internal transitions use typestate.
Avoid
- Do not use typestate for UI labels or reporting states.
- Do not encode runtime data as type parameters.
- Do not create markers for unclear or frequently changing workflows.
- Do not force typestate through persistence, queues, or async task boundaries.
- Do not use typestate when a fallible constructor or runtime enum is clearer.
Public API Notes
Published typestate APIs expose workflow structure to callers. Keep state names clear and consider sealed markers when the state set may evolve.
Example
#![allow(unused)]
fn main() {
use std::marker::PhantomData;
pub struct Draft;
pub struct Published;
pub struct Article<State> {
body: String,
state: PhantomData<State>,
}
impl Article<Draft> {
pub fn new(body: impl Into<String>) -> Self {
Self {
body: body.into(),
state: PhantomData,
}
}
pub fn publish(self) -> Article<Published> {
Article {
body: self.body,
state: PhantomData,
}
}
}
}
Exceptions
- Use typestate builders when missing a required step would create a dangerous or invalid operation.
- Accept additional complexity in safety-sensitive protocols where invalid ordering must not compile.
Public API Evolution
Rule
Apply compatibility discipline to published or independently consumed APIs; optimize coordinated application internals for current simplicity.
Why
External callers update independently, while internal callers can move in the same change. Compatibility machinery helps the former and burdens the latter.
Do
- Classify an API as coordinated internal code or independently consumed public code.
- Keep published surfaces small and deliberate.
- Follow semver for externally consumed APIs and record intentional breaks.
- Consider
#[non_exhaustive]when a new public enum or struct is expected to grow. - Use deprecation periods when consumers cannot migrate atomically.
- Add
#[must_use]when directly discarding a returned value is almost always a bug. - Follow owner pages for fields, traits, and dependencies.
Avoid
- Do not add compatibility shims or
#[non_exhaustive]to coordinated internal code by habit. - Do not add
#[non_exhaustive]to an existing public type without treating that addition as breaking. - Do not remove or change published APIs without compatibility review.
- Do not rely on
clippy::must_use_candidateto choose semantic policy. - Do not assume
#[must_use]enforces cleanup after a value has been bound.
Library vs Application
Applications update callers together and delete stale APIs. Published crates and independently consumed interfaces use semver-aware evolution.
Example
#![allow(unused)]
fn main() {
#[non_exhaustive]
pub enum ClientError {
Timeout,
Unauthorized,
}
/// Owns a background task that should be shut down explicitly.
#[must_use = "call `shutdown` to stop and join the task"]
pub struct Poller {
task: JoinHandle<Result<(), PollerError>>,
}
}
#[must_use] catches start_poller();, but not let poller = start_poller(); drop(poller);.
Exceptions
- Keep temporary compatibility shims for staged migrations.
- Use
#[non_exhaustive]internally only when it materially clarifies active development.
Ownership, Borrowing, and Clone Policy
Rule
Borrow inputs that are only read, take ownership of values stored unchanged, keep stored data owned, and clone when it keeps APIs simpler at acceptable cost.
Why
Borrowed read-only inputs accept both owned and borrowed callers, while owned storage and results keep lifetimes from spreading through APIs.
Do
- Accept
&str,&[T], and&Pathfor read-only inputs. - Take owned values or
impl Into<T>when a constructor or setter stores them unchanged. - Borrow values that are validated or transformed, then allocate the derived representation.
- Store
String,Vec<T>, andPathBufrather than borrowed fields by default. - Borrow from plain accessors and use ownership-signaling names for snapshots or clones.
- Return owned data when a borrow would expose unnecessary lifetimes.
- Use
.clone()consistently for ordinary values,Rc, andArc. - Use
IntoIterator,AsRef,Into, orCowonly when the flexibility solves a real caller problem and remains local. - Revisit clone cost only with profiling or domain evidence.
Avoid
- Do not take owned containers when a function only reads them.
- Do not accept
&String,&Vec<T>, or&PathBufby habit. - Do not store references merely to avoid a cheap allocation.
- Do not hide clones in bare-noun accessors.
- Do not add generic conversion bounds to every function.
- Do not use
Cowto avoid making an ownership decision. - Do not mix
Arc::clone(&value)andvalue.clone()conventions.
Public API Notes
Concrete borrowed refs are usually clearer than generic bounds. Document large or surprising clones in published APIs.
Example
#![allow(unused)]
fn main() {
pub struct Settings {
service_name: String,
root: PathBuf,
}
impl Settings {
pub fn new(service_name: impl Into<String>, root: PathBuf) -> Self {
Self {
service_name: service_name.into(),
root,
}
}
pub fn service_name(&self) -> &str {
&self.service_name
}
pub fn root(&self) -> &Path {
&self.root
}
}
}
Exceptions
- Use
impl AsRef<Path>when accepting path-like callers is the main ergonomic benefit. - Use
impl Readorimpl Writefor reusable data-processing seams. - Use
Cowfor local parsing or normalization that usually borrows but sometimes allocates. - Avoid clones in measured hot paths or for resource-heavy types.
Lifetimes
Rule
Prefer lifetime elision and owned results; use explicit lifetimes when borrowing is the purpose of a view, parser, iterator, or zero-copy abstraction.
Why
Explicit lifetimes are useful for real borrowed views but add coupling when introduced only to avoid small clones or allocations.
Do
- Rely on elision for ordinary borrowed inputs and accessors.
- Use lifetime-bearing structs for genuine views into another value.
- Name a lifetime when an output must clearly borrow from one input.
- Use
'_when the local relationship needs no name. - Return borrowed iterators when that is the natural API.
- Keep lifetime parameters out of unrelated types and layers.
Avoid
- Do not build self-referential structs in ordinary code.
- Do not name lifetimes that elision already communicates.
- Do not make public APIs lifetime-heavy unless borrowing is central.
- Do not add lifetimes solely to avoid a cheap owned result.
Public API Notes
Parsers, views, and zero-copy libraries may expose lifetimes deliberately. Ordinary APIs follow ownership policy.
Example
#![allow(unused)]
fn main() {
pub struct Token<'a> {
text: &'a str,
}
impl Token<'_> {
pub fn as_str(&self) -> &str {
self.text
}
}
pub fn first_token(input: &str) -> Option<Token<'_>> {
input
.split_whitespace()
.next()
.map(|text| Token { text })
}
}
Exceptions
- Accept more lifetime complexity in measured hot paths where allocation matters.
- Use borrowed adapter structs when their short relationship to a source is the abstraction.
Smart Pointers and Interior Mutability
Rule
Prefer ordinary ownership; use Box for required indirection, Rc and RefCell only for single-threaded sharing or interior mutation, and OnceLock or LazyLock for one-time initialization.
Why
Smart pointers solve real ownership shapes but add allocation, runtime borrow checks, shared lifetime, or cycle risks.
Do
- Use
Box<T>for recursive data, large enum variants, or single-owner dynamic dispatch. - Use
Rc<T>for single-threaded shared ownership. - Use
Cell<T>orRefCell<T>only when shared-handle mutation is genuinely simpler than&mut self. - Use
Weakto break parent-child and observer cycles. - Use
OnceLockorLazyLockfor one-time initialization. - Follow concurrency primitives for
Arc, locks, and channels.
Avoid
- Do not use
RcorRefCellacross threads. - Do not create strong-reference cycles.
- Do not add indirection or interior mutability by habit.
- Do not create mutable global state without explicit initialization and access rules.
Example
#![allow(unused)]
fn main() {
use std::sync::OnceLock;
static DEFAULT_LOCALE: OnceLock<String> = OnceLock::new();
pub fn default_locale() -> &'static str {
DEFAULT_LOCALE.get_or_init(|| "en-US".to_owned())
}
pub enum Expr {
Literal(i64),
Add(Box<Expr>, Box<Expr>),
}
}
Exceptions
- Use
RefCellfor narrow single-threaded caches, adapters, or tests where runtime borrow checking is the clearest model. - Use
Boxonly when recursion, size, or owned dynamic dispatch requires it.
Collections and Data Structures
Rule
Use standard-library collections by default and specialize only for required semantics, deterministic ordering, or known performance needs.
Why
Standard collections are familiar and dependency-free. Specialized structures should express real behavior rather than incidental convenience.
Do
- Choose a collection from the access and ordering behavior the code requires.
- Use sorted collections when sorted iteration is part of the model.
- Sort a
Vecat an output boundary when determinism is needed only there. - Preallocate only when size is already known.
- Use
retain,drain, andstd::mem::takefor clear in-place updates. - Use the entry API instead of repeated lookup and insertion.
- Wrap a collection in a domain type when it carries invariants or behavior.
- Add
indexmap,smallvec, arenas, or domain structures only when their semantics or measured cost matter.
Avoid
- Do not add a collection crate for one convenient call site.
- Do not rely on hash iteration order in tests, logs, serialization, or public output.
- Do not choose a sorted collection when ordering has no value.
- Do not preallocate from guesses or optimize before knowing data shape.
- Do not expose raw collection fields when callers could violate invariants.
Public API Notes
Prefer standard collections or iterators in public signatures unless a specialized collection is part of the contract.
Example
#![allow(unused)]
fn main() {
pub fn users_for_output(users: &HashMap<UserId, User>) -> Vec<&User> {
let mut users: Vec<_> = users.values().collect();
users.sort_by_key(|user| user.id());
users
}
}
Exceptions
- Use
IndexMapwhen insertion order is part of the data model. - Use specialized allocation or layout when profiling shows it matters.
- Use deterministic collections in tests when stable ordering clarifies assertions.
Error Taxonomy and Layer Boundaries
Rule
Model errors by caller decisions, convert infrastructure failures at layer boundaries, preserve structured data, and render only at external boundaries.
Why
Errors are control flow and diagnostics. Layered types keep callers independent of implementation details without losing actionable data.
Do
- Define domain variants for failures callers handle differently.
- Keep useful fields such as IDs, paths, states, and retry hints structured.
- Convert database, HTTP, SDK, parser, and I/O failures at the boundary they cross.
- Keep infrastructure causes as sources rather than public domain variants by default.
- Preserve internal detail for logs while returning curated safe API or CLI messages.
- Follow error propagation for sources, context, and rendering.
Avoid
- Do not create a variant for every low-level failure when callers cannot react differently.
- Do not expose dependency error types accidentally from domain APIs.
- Do not transport internal errors as
String,Message(String), orOther(String). - Do not include secrets or unredacted inputs in error fields.
Library vs Application
Libraries expose typed boundary errors. Applications may erase orchestration errors while retaining typed domain errors wherever code branches.
Example
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum LoadProfileError {
#[error("profile {id} was not found")]
NotFound { id: ProfileId },
#[error("reading profile {id}")]
Read {
id: ProfileId,
#[source]
source: std::io::Error,
},
}
}
NotFound supports caller behavior; Read hides the storage implementation while retaining its cause.
Exceptions
- Use a coarse variant when callers make one decision and the source carries the detail.
- Use text-only errors for external contracts that are already rendered projections.
Library Errors vs Application Errors
Rule
Expose typed thiserror errors from reusable boundaries, use anyhow for application orchestration, and introduce miette only at a rich diagnostic presentation layer.
Why
Library callers need stable failures they can inspect, while applications need convenient propagation and final-boundary reporting.
Do
- Define a crate-local error enum and result alias for one cohesive library error surface.
- Keep public variants branch-oriented and stable.
- Use
anyhow::Resultin binaries, command handlers, workers, tests, and internal glue. - Keep typed domain errors in applications where callers recover differently.
- Use
miettefor labels, snippets, help text, and polished CLI reports. - Convert into
mietteonly at presentation; keep internal errors onthiserrororanyhow. - Follow error taxonomy and propagation for structure and sources.
Avoid
- Do not expose
anyhow::Errorfrom reusable public APIs. - Do not use
mietteas a general internal error type. - Do not mix application error-reporting stacks without a project reason.
- Do not default public APIs to
Box<dyn Error>. - Do not mirror every dependency error as a public variant.
Public API Notes
thiserror generates standard trait impls without appearing in signatures, but public variant fields and source types can still expose dependencies.
Example
#![allow(unused)]
fn main() {
// Reusable library boundary.
pub fn load(path: &Path) -> Result<Config, ConfigError> {
read_and_parse_config(path).map_err(ConfigError::from)
}
// Application orchestration boundary.
pub fn run(args: Args) -> anyhow::Result<()> {
run_application(args)
}
}
Exceptions
- Hand-write error impls to avoid a dependency or control public API tightly.
- Use
anyhowin an internal crate that is purely application implementation. - Use
miettewhen diagnostic output is part of the product experience.
Error Propagation, Context, and Messages
Rule
Propagate with ?, preserve source chains, add concise context at operation boundaries, and never stringify a source merely to add context.
Why
Useful chains explain both the failed operation and its cause. Context on every line is repetitive; flattening to text destroys structure.
Do
- Use
?for ordinary propagation. - Use
Fromor#[from]for conversions that need no additional fields. - Use
.context(...)for static application context and.with_context(...)when formatting values. - Add context at command, request, job, service, task, crate, or layer boundaries.
- Keep typed error messages specific to their local failure.
- Preserve sources with
#[source],#[from],anyhow::Context, or explicit fields. - Render the full chain deliberately at the outer boundary.
Avoid
- Do not annotate every
?. - Do not restate the lower-level message in context.
- Do not use
.map_err(|err| err.to_string())oranyhow!("{err}")for propagation. - Do not interpolate a source error into a replacement context string.
- Do not put secrets or unredacted request data in messages.
Library vs Application
Libraries describe local failures with typed errors. Applications add operation context and choose final CLI, API, worker, or log rendering.
Example
#![allow(unused)]
fn main() {
use anyhow::{Context as _, Result};
fn run(path: &Path) -> Result<()> {
let config = config_lib::load(path)
.with_context(|| format!("loading configuration from {}", path.display()))?;
start_server(config).context("starting server")
}
}
Avoid flattening the chain:
#![allow(unused)]
fn main() {
let config = config_lib::load(path)
.map_err(|err| anyhow::anyhow!("failed to load config: {err}"))?;
}
Exceptions
- Add context near a call when no higher boundary can identify the operation.
- Keep propagation sparse when typed variants already explain the operation.
Panics, unwrap, expect, and assertions
Rule
Return Result for recoverable failures; panic only for violated invariants or impossible states, and prefer invariant-focused expect over bare unwrap.
Why
Panics provide no structured recovery path, so caller input, I/O, parsing, configuration, and external failures remain ordinary errors.
Do
- Return
Resultfor caller-controlled or environmental failures. - Use
expectonly when failure proves a hard-coded value, fixture, or invariant is wrong. - State the invariant in each
expectmessage. - Use assertions for tests and internal invariants.
- Use
debug_assert!only for checks unnecessary to release correctness. - Use
unreachable!only after construction has ruled out the state. - Document public panic behavior with
# Panics.
Avoid
- Do not use
unwrap,expect, or panic for recoverable runtime failure. - Do not use bare
unwrapoutside tests; workspace policy denies it. - Do not write messages such as
should workorfailed. - Do not use
unreachable!for states reachable from external input. - Do not rely on
debug_assert!for safety, security, or validation. - Do not commit
todo!()orunimplemented!()on production paths.
Library vs Application
Libraries return caller-controlled failures. Applications may fail fast for violated build-time invariants, but operator mistakes still receive clean diagnostics.
Example
#![allow(unused)]
fn main() {
pub fn parse_port(raw: &str) -> Result<u16, std::num::ParseIntError> {
raw.parse()
}
const DEFAULT_PORT: &str = "8080";
pub fn default_port() -> u16 {
DEFAULT_PORT
.parse()
.expect("DEFAULT_PORT should be a valid u16")
}
}
Exceptions
- Use
unwrapin a short test when the failure location is already obvious. - Use panic in disposable examples or prototypes.
- Panic on an impossible internal state when an error would falsely imply recovery.
Validation and Invariants
Rule
Validate untrusted data at input boundaries, convert it to invariant-bearing types, and let internal code operate on those trusted values.
Why
Early boundary validation keeps checks close to parsing and prevents repeated defensive validation throughout the system.
Do
- Validate CLI, HTTP, configuration, environment, database, message, and deserialization inputs.
- Convert raw values into domain types as soon as practical.
- Use fallible constructors or conversions for caller-provided values.
- Prevent construction paths that bypass the validated domain type.
- Use standard invariant types such as
NonZero*when they fit. - Fail startup cleanly when configuration is invalid.
- Re-check constraints that depend on mutable external state.
- Follow newtype and constructor policy for representation.
Avoid
- Do not validate the same invariant at every use site.
- Do not pass raw primitives deep into code when a validated type exists.
- Do not make
newpanic on caller input. - Do not treat deserialization alone as validation.
- Do not encode every invariant in typestate when a validated value is enough.
Library vs Application
Libraries prevent invalid public construction. Applications validate process and request inputs, then pass trusted types through internal layers.
Example
#![allow(unused)]
fn main() {
pub fn create_workspace(raw_name: &str) -> Result<Workspace, WorkspaceNameError> {
let name = WorkspaceName::try_new(raw_name)?;
Ok(Workspace::new(name))
}
pub fn workspace_path(root: &Path, name: &WorkspaceName) -> PathBuf {
root.join(name.as_str())
}
}
workspace_path trusts the invariant already established by WorkspaceName.
Exceptions
- Revalidate stale caches, legacy storage, and older serialized formats.
- Re-check authorization, uniqueness, quotas, existence, and time-dependent constraints.
- Use typestate when invalid transition order is important enough to reject at compile time.
Logging and Observability
Rule
Use tracing with operation spans, structured fields, fixed messages, sparse high-signal events, and explicit redaction.
Why
Structured traces remain searchable and aggregatable, while fixed messages identify event kinds and fields carry per-operation data.
Do
- Let applications configure subscribers; libraries emit only spans and events.
- Add spans around requests, jobs, commands, tasks, external calls, and workflow steps.
- Prefer
#[tracing::instrument(skip_all, fields(...))]and opt fields in. - Put IDs, states, attempts, counts, and durations in snake_case fields.
- Keep INFO for lifecycle and important outcomes, DEBUG for investigation, WARN for degradation, and ERROR for failed operations.
- Record errors with Debug capture (
error = ?err) or a&dyn Errorfield. - Use counts, hashes, redacted displays, or booleans instead of sensitive raw values.
Avoid
- Do not interpolate variable data into message strings.
- Do not emit duplicate or high-cardinality INFO events.
- Do not initialize subscribers in reusable libraries.
- Do not use tracing as user-facing output or durable business events.
- Do not use bare
#[instrument]on arguments that may contain secrets. - Do not log credentials, tokens, bodies, raw command output, or unredacted URLs.
Library vs Application
Libraries emit structured telemetry without choosing global formatting or filtering. Applications own destinations, propagation, and subscriber configuration.
Example
#![allow(unused)]
fn main() {
#[tracing::instrument(skip_all, fields(account_id = %account_id))]
pub async fn sync_account(
account_id: AccountId,
client: &BillingClient,
) -> Result<(), SyncError> {
let invoices = client.list_invoices(account_id).await.map_err(|err| {
tracing::error!(error = ?err, "account sync failed");
SyncError::ListInvoices(err)
})?;
tracing::info!(invoice_count = invoices.len(), "account sync complete");
Ok(())
}
}
Exceptions
- Send CLI output through the command output path; justify direct printing with a local lint expectation.
- Temporarily add DEBUG detail during investigation, then retain only durable signal.
- Use metrics or durable events for alerts, billing, audit, or product behavior.
Async Runtime and When to Use Async
Rule
Document whether the project is sync or async before broad changes, and use Tokio when async is chosen.
Why
Async affects signatures, tests, dependencies, runtime ownership, and shutdown across a codebase rather than at one isolated call site.
Activation
Apply this page when setting project posture, adding the first async dependency, or reviewing runtime boundaries.
Do
- Document whether the project is sync or async.
- Record where async belongs and which Tokio features, test macros, and runtime conventions apply.
- Use async for network I/O, timers, streaming, subprocess orchestration, and concurrent service work.
- Keep parsing, validation, formatting, and bounded CPU-local transforms synchronous.
- Let applications own runtime creation.
- Document a reusable library’s runtime assumptions.
- Follow task lifecycle, cancellation, and concurrency for implementation details.
Avoid
- Do not convert a module to async solely because its caller is async.
- Do not create a runtime inside a reusable library.
- Do not build runtime abstraction when all real callers use Tokio.
- Do not maintain parallel sync and async APIs without two real caller requirements.
- Do not make tests async unless the behavior needs it.
Library vs Application
Applications own the runtime. Libraries may expose Tokio-based APIs when async behavior is central, but runtime and lifecycle assumptions remain visible.
Example
#![allow(unused)]
fn main() {
pub async fn handle_request(
request: Request,
client: &ApiClient,
) -> Result<Response, Error> {
let command = parse_command(&request)?;
let record = client.fetch_record(command.record_id()).await?;
Ok(render_response(record))
}
fn parse_command(request: &Request) -> Result<Command, Error> {
Command::try_new(request.path(), request.query())
}
}
Exceptions
- Prefer a sync posture for local, CPU-bound, or short-lived tools.
- Add runtime abstraction only for real callers on multiple runtimes.
- Provide a sync wrapper only on a clearly synchronous application path where nested-runtime panics are impossible.
Async API Design and Task Lifecycle
Rule
Keep async signatures and spawned-task ownership explicit; reusable libraries expose awaitable work or return an owner instead of hiding tasks.
Why
Unowned tasks detach, hide failures, and make teardown and tests unreliable.
Activation
Apply this page for async APIs, async traits, Send + 'static bounds, spawning, task owners, or join handles.
Do
- Use
async fn -> Result<T, E>for one-shot work callers should await. - Introduce async traits only when callers need an abstraction.
- Add
Send + 'staticonly where values cross a spawned or stored-future boundary. - Spawn from an owner that stores handles and task-specific state.
- Use responsibility names such as
Poller,WorkerSet, orSupervisor. - Store
JoinHandle<Result<(), Error>>when failures must be reported. - Return an owner or guard when a library truly owns background work.
- Follow cancellation and shutdown for signals, joining, and teardown.
Avoid
- Do not drop a handle for important work.
- Do not assume dropping
JoinHandlecancels its task. - Do not spawn inside a constructor unless the returned value owns the lifecycle.
- Do not swallow task errors.
- Do not spawn merely to make a library API appear nonblocking.
- Do not add
Send,Sync, or'staticto ordinary async APIs by habit.
Library vs Application
Applications own spawning and task groups. Libraries normally expose futures or streams and let callers choose where to spawn.
Example
#![allow(unused)]
fn main() {
#[must_use = "call `shutdown` to stop and join the task"]
pub struct Poller {
shutdown: CancellationToken,
task: JoinHandle<Result<(), PollerError>>,
}
impl Poller {
pub async fn shutdown(self) -> Result<(), PollerError> {
self.shutdown.cancel();
self.task.await.map_err(PollerError::Join)?
}
}
}
The attribute catches a discarded return value; the explicit method owns actual cancellation and joining.
Exceptions
- Fire-and-forget only disposable, documented best-effort work.
- Tests may spawn short tasks when they abort or join them.
- Convenience APIs may spawn when they return lifecycle control.
Cancellation, Shutdown, and Blocking Work
Rule
Use cooperative cancellation, join owned tasks, put timeouts at operation boundaries, and isolate blocking or CPU-heavy work from Tokio workers.
Why
Async work may be dropped at any .await; explicit teardown and isolation keep state recoverable and the runtime responsive.
Activation
Apply this page to long-lived loops, graceful shutdown, timeouts, cancellation safety, blocking I/O, CPU-heavy work, or teardown.
Do
- Pass an explicit cancellation signal into long-lived tasks.
- Race service-loop work with shutdown using
select!. - Join task owners during teardown and surface failures.
- Put timeouts around external calls, jobs, subprocesses, and shutdown phases.
- Keep inner helpers timeout-free unless they own an operation boundary.
- Make interrupted sections idempotent, restartable, or backed by resumable state.
- Use
spawn_blockingfor bounded blocking or short CPU-heavy work. - Use a dedicated pool, queue, or Rayon for sustained CPU work.
- Drop locks before awaiting, blocking, callbacks, or expensive computation.
Avoid
- Do not rely on dropping a future as important-work shutdown.
- Do not use abort as the normal path when cleanup matters.
- Do not put non-cancel-safe work in
select!without recoverable state. - Do not scatter timeouts around small helpers.
- Do not run blocking I/O or long CPU work on Tokio workers.
- Do not expect a started
spawn_blockingclosure to be cancelled.
Example
#![allow(unused)]
fn main() {
pub async fn run_worker(
mut jobs: mpsc::Receiver<Job>,
shutdown: CancellationToken,
) -> Result<(), WorkerError> {
loop {
let job = tokio::select! {
() = shutdown.cancelled() => return Ok(()),
job = jobs.recv() => job.ok_or(WorkerError::QueueClosed)?,
};
timeout(Duration::from_secs(10), process_job(job))
.await
.map_err(|_| WorkerError::TimedOut)??;
}
}
}
Exceptions
- Abort best-effort tasks that own no cleanup-sensitive state.
- Let short request tasks complete when request drop already owns cancellation.
- Keep known tiny bounded CPU work on Tokio.
Concurrency Primitives
Rule
Choose by ownership shape: owned values first, channels for transfer, standard locks for short synchronous sections, and Tokio primitives for async coordination.
Why
The smallest primitive matching data ownership is easier to reason about and less likely to block the runtime accidentally.
Activation
Apply this page for channels, locks, atomics, worker pools, shared state, or runtime coordination.
Do
- Prefer one clear owner for mutable state.
- Use bounded channels when values or commands move to an owner.
- Use
Arc<T>for cross-thread or cross-task shared ownership. - Use standard
MutexorRwLockfor short synchronous critical sections. - Use Tokio locks, semaphores, notifications, or channels when waiting is async.
- Copy or clone needed data out before
.await. - Start with
Mutex; adoptRwLockonly for meaningful read contention. - Use atomics only for simple flags, counters, or low-level coordination with obvious ordering.
- Document ordering when code can hold multiple locks.
- Follow blocking-work policy for pools and work isolation.
Avoid
- Do not choose a Tokio mutex solely because the caller is async.
- Do not hold a standard lock guard across
.await. - Do not use
Arc<Mutex<T>>to avoid selecting an owner. - Do not use channels for simple shared counters or snapshots.
- Do not use unbounded channels without an explicit memory-growth argument.
- Do not default to
RwLockor OS threads.
Async Notes
Blocking-API lints are project guardrails, not a blanket ban on std::sync; see the new-project workflow.
Example
#![allow(unused)]
fn main() {
pub async fn handle_job(cache: &Mutex<Cache>, client: &Client, key: Key) -> Result<(), Error> {
let path = {
let mut cache = cache.lock().expect("cache mutex should not be poisoned");
cache.entry(key).or_insert_with(default_path).clone()
};
let bytes = read_file(path).await?;
client.upload(bytes).await
}
}
Exceptions
- Use an async lock when waiting must be async or a guard intentionally spans
.await. - Use an unbounded channel for a naturally bounded stream or documented best-effort telemetry.
- Use an OS thread for affinity or long-lived blocking ownership, with a lint reason.
Control Flow
Rule
Use explicit clarity-first branches and keep mutation in small scopes after fallible validation.
Why
Visible exits, exhaustive state handling, and atomic-looking updates make invariants easier to preserve during later changes.
Do
- Use
?when the current function only propagates failure. - Use early returns for invalid inputs, missing prerequisites, and permission failures.
- Use
let elsewhen a missing pattern exits the current scope. - Use
if letfor one special pattern andwhile letfor repeated extraction. - Use
matchwhen several variants or exhaustiveness matter. - Keep arms and guards small and name meaningful variants instead of
_. - Keep the happy path linear after validation.
- Use small mutable locals for accumulators, builders, and staged values.
- Compute and validate before mutating long-lived state when partial updates would be invalid.
Avoid
- Do not match on
bool. - Do not hide meaningful states behind
_. - Do not deeply nest branches when guard clauses clarify exits.
- Do not use
let elsefor substantial recovery logic. - Do not replace error handling with
unwraporexpect. - Do not mutate persistent state before fallible validation unless partial progress is intentional.
- Follow Option and Result for combinator choices.
Example
#![allow(unused)]
fn main() {
pub fn plan_action(request: Request) -> Result<Action, Error> {
let Some(user_id) = request.user_id() else {
return Err(Error::MissingUserId);
};
let command = Command::parse(request.command())?;
if !request.permissions().can_run(user_id, &command) {
return Err(Error::Forbidden { user_id });
}
match command {
Command::Start(target) => Ok(Action::Start(Target::try_new(target)?)),
Command::Stop(target) => Ok(Action::Stop(target)),
Command::Status => Ok(Action::Status),
}
}
}
Exceptions
- Use
_for intentionally ignored test or metrics cases and external non-exhaustive enums. - Mutate incrementally when every step is independently valid and no rollback exists.
Option and Result Idioms
Rule
Use simple combinators for local transformations and explicit branching when absence or failure carries behavior, side effects, context, or recovery.
Why
Short combinators keep linear transformations compact, while behavior-bearing decisions need visible control flow.
Do
- Use
?to propagate within the sameResultorOptionshape. - Convert required optional values with
ok_orfor cheap errors andok_or_elsefor computed errors. - Use
map,filter, andunwrap_or_elsefor short side-effect-free transforms. - Use
map_errfor typed conversion that retains the source. - Use
transposeto turnOption<Result<T, E>>intoResult<Option<T>, E>. - Use explicit branching for logging, metrics, cleanup, retries, or recovery.
- Add context according to error propagation.
Avoid
- Do not chain combinators past easy readability.
- Do not hide side effects in
map,and_then,or_else, orinspect. - Do not call
.ok()unless detailed failure intentionally becomes absence. - Do not eagerly evaluate an expensive fallback with
unwrap_or. - Do not use
unwrap_or_defaultwhen absence is a domain error. - Do not check
is_someand then unwrap.
Example
#![allow(unused)]
fn main() {
pub fn build_request(input: &Input) -> Result<Request, Error> {
let id = input.id().ok_or(Error::MissingId)?;
let mode = input
.mode()
.map(Mode::parse)
.transpose()?
.unwrap_or_else(Mode::default);
let timeout = match input.timeout_ms() {
Some(0) => return Err(Error::InvalidTimeout),
Some(ms) => Timeout::from_millis(ms)?,
None => Timeout::default(),
};
Ok(Request::new(id, mode, timeout))
}
}
Exceptions
- Use a longer chain when every step is pure and clearly named.
- Use
matchfor a simple case when exhaustiveness documents the domain. - Use
.ok()at an external boundary where absence is the deliberate projection.
Iterators, Closures, and Loops
Rule
Use iterator pipelines for simple transforms and loops for branching, mutation, early exits, or multi-step state.
Why
Pipelines are concise when each operation names a transformation; loops expose control flow when work is stateful or conditional.
Do
- Choose
iter,iter_mut, orinto_iterfrom the intended ownership. - Collect when the destination type is clear; annotate ambiguous results.
- Collect fallible maps into
Result<Collection, _>for simple fail-fast transformation. - Use
try_foldortry_for_eachonly for short readable fallible accumulation. - Use loops for branches, mutation, multiple accumulators, or nontrivial errors.
- Keep closures short and extract named helpers for reusable or behavior-heavy logic.
- Use
movewhen a closure outlives its scope or crosses a task/thread boundary. - Clone into closures when it avoids awkward lifetimes and cost is acceptable.
- Use
enumerateorzipinstead of manual paired indexes.
Avoid
- Do not hide control flow in long pipelines.
- Do not use
for_eachfor side-effect-heavy work. - Do not use
foldfor a complex mutable accumulator. - Do not collect a temporary collection only to iterate once.
- Do not hide logging, metrics, I/O, or mutation in transformation closures.
Example
#![allow(unused)]
fn main() {
pub fn active_names(runs: &[Run]) -> Vec<String> {
runs.iter()
.filter(|run| run.is_active())
.map(|run| run.name().to_owned())
.collect()
}
pub fn first_failed(runs: &[Run]) -> Result<Option<RunId>, Error> {
for run in runs {
if run.is_finished() && !run.exit_status()?.success() {
return Ok(Some(run.id()));
}
}
Ok(None)
}
}
Exceptions
- Follow configured Clippy policy when it prefers an equally clear local form.
- Use
for_eachin a fluent API only when side effects remain obvious.
Testing and Doctests
Rule
Test behavior at the level where it is observable and risky: focused unit tests for local logic, integration tests for public workflows, and doctests only by explicit opt-in.
Why
Test placement should protect behavior without creating file quotas or coupling coverage to implementation structure.
Do
- Test invariants, outputs, errors, and observable state changes.
- Put focused logic, parsers, validation, and transformations in nearby unit tests.
- Put public APIs, CLI behavior, I/O boundaries, and workflows in integration tests.
- Choose placement from behavior and risk, not source-file count.
- Name tests as behaviors such as
rejects_zero_limit. - Return
Resultfrom tests when setup naturally uses?. - Keep fixtures and helpers small and domain-named.
- Use real values and temporary resources where practical; fake only slow, external, or nondeterministic boundaries.
- Put regression tests where the bug was observable.
Avoid
- Do not require a test module in every nontrivial source file.
- Do not mirror private implementation steps or every helper.
- Do not use rustdoc examples as default coverage.
- Do not add sleeps or timing dependence.
- Do not make helpers public solely for integration tests.
- Do not add broad test-only controls to production APIs.
- Do not settle for “does not panic” when behavior can be asserted.
Example
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_zero_limit() {
let error = parse_limit("0").expect_err("zero should be rejected");
assert_eq!(error, LimitError::Zero);
}
}
}
An end-to-end create-user workflow belongs under tests/ because callers observe it through the public application surface.
Exceptions
- Add doctests only when the project opts into maintaining public examples.
- Use module-private tests for dense parser, validator, state-machine, or algorithm edge cases.
- Gate deliberate reusable test support behind a
test-utilfeature.
Property Tests, Snapshots, Benchmarks, and CI
Rule
Use Nextest as the default workspace runner and add snapshots, property tests, or benchmarks only for a matching behavior or performance need.
Activation
Apply this page when configuring test commands, CI, snapshots, property tests, benchmarks, or release verification.
Why
Specialized tools improve the right tests but add dependencies, review process, and maintenance cost.
Do
- Run
cargo nextest run --workspace --all-targets --all-featureslocally and in CI. - Keep
cargo testfor doctests and cases Nextest does not cover. - Run pinned formatting and Clippy checks alongside tests.
- Use
instafor stable complex textual or structured output. - Commit and review snapshot changes; normalize nondeterministic fields first.
- Use
proptestfor parsers, round trips, normalization, state machines, and broad invariants. - Use
criteriononly for stated performance requirements or regression risks. - Keep benchmark inputs representative, named, and stable.
Avoid
- Do not add every testing tool by default.
- Do not snapshot simple scalar assertions or raw nondeterministic data.
- Do not accept snapshot changes without review.
- Do not create property generators whose failures cannot be diagnosed.
- Do not treat benchmarks as correctness tests or unstable thresholds as ordinary CI gates.
- Do not let local and CI test sets diverge silently.
Example
cargo +nightly-2026-04-14 fmt --check --all
cargo clippy --locked --workspace --all-targets --all-features -- -D warnings
cargo nextest run --workspace --all-targets --all-features
Exceptions
- Existing projects may keep
cargo testuntil Nextest is adopted deliberately. - Keep
quickcheckwhere it is already established; preferproptestfor new work. - Use service load tests when Criterion does not model the real risk.
Unsafe Code and Macros
Rule
Deny project-written unsafe by default and introduce unsafe or macros only when a documented requirement outweighs their proof and readability costs.
Activation
Apply this page for unsafe, FFI, raw pointers, macros, proc macros, or generated implementations.
Why
Unsafe moves proof obligations outside the compiler, while macros can hide generated behavior and control flow.
Do
- Keep
unsafe_code = "deny"in the default workspace policy. - Prefer safe Rust and mature crates.
- Grant unsafe exceptions at crate scope with a documented policy.
- Isolate unsafe behind the smallest safe API and keep blocks minimal.
- Put
SAFETY:comments beside every unsafe block or impl. - Document public unsafe functions and traits with
# Safety. - Run Miri where it supports crates containing project-written unsafe.
- Keep FFI crates thin and translate portable boundary types into safe core logic.
- Use macros only for substantial mechanical repetition or a clear declarative pattern.
- Keep macro input syntax narrow, output predictable, and errors understandable.
Avoid
- Do not use unsafe to bypass borrow checking or optimize without measurement.
- Do not lower the workspace-wide unsafe policy for one crate.
- Do not exchange Rust-owned allocations or process-global assumptions across dynamic-library boundaries.
- Do not use uninitialized memory without a type-specific validity proof.
- Do not write a macro for one or two call sites or to invent control flow.
- Do not use a proc macro when ordinary Rust or
macro_rules!is clear enough. - Do not generate surprising names, impls, modules, or side effects.
Safety Notes
Rust 2024 uses explicit unsafe forms such as unsafe extern and #[unsafe(no_mangle)] where required. Dependency unsafe does not justify local unsafe.
Public API Notes
Public macros and unsafe contracts are public API; keep their accepted syntax and caller obligations small and documented.
Example
#![allow(unused)]
fn main() {
macro_rules! impl_id_display {
($type:ty) => {
impl std::fmt::Display for $type {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.as_str().fmt(f)
}
}
};
}
}
Use this only when several ID types already share exactly the same valid behavior.
Exceptions
- Allow unsafe in FFI, platform, hardware, or measured low-level crates with local policy.
- Keep an existing unsafe crate’s exception local when removal is out of scope.
- Use small test macros for readable repetitive case tables.
- Use generation when it replaces large error-prone handwritten implementations.
New Rust Project
Use this workflow when creating or configuring a new Rust crate, workspace, CLI, library, service, or application.
Guideline Routing
Always load the pages for house style, code classification, edition and MSRV, rustfmt, lints, Cargo, testing, CI tooling, and unsafe policy.
For a greenfield multi-crate application, also load application workspace architecture. Load async, logging, public API, and error pages only when those surfaces apply.
Workflow
- Identify the project shape: library, application, CLI, service, test support crate, or mixed workspace.
- Make the sync-vs-async posture explicit before adding async dependencies; async projects use Tokio.
- Prefer a workspace when multiple crates share version, edition, dependencies, lints, or profiles.
- Set Rust 2024 and
rust-version = "1.85"unless the project already has different constraints. - Add pinned rustfmt configuration and use
nightly-2026-04-14for formatting. - Add curated workspace lints and tailor project-specific
clippy.tomlguardrails before copying async/blocking disallow rules. - Place tests according to behavior and risk: focused unit tests near local logic and integration tests around public workflows.
- Use
cargo nextest run --workspace --all-targets --all-featuresas the normal workspace test runner. - Skip doctests unless the project explicitly opts into maintaining rustdoc examples.
- Add dependencies only for an identified need.
- Verify the project with the configured commands before handing it off.
Cargo Baseline
Use a workspace when multiple crates share package, lint, dependency, or profile policy:
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[workspace.lints.rust]
unsafe_code = "deny"
unreachable_pub = "warn"
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -2 }
allow_attributes_without_reason = "warn"
implicit_hasher = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
module_name_repetitions = "allow"
must_use_candidate = "allow"
similar_names = "allow"
struct_excessive_bools = "allow"
too_many_arguments = "allow"
too_many_lines = "allow"
cast_precision_loss = "allow"
doc_markdown = "allow"
print_stdout = "warn"
print_stderr = "warn"
dbg_macro = "warn"
empty_drop = "warn"
empty_structs_with_brackets = "warn"
disallowed_methods = "deny"
exit = "warn"
get_unwrap = "warn"
unwrap_used = "deny"
rc_buffer = "warn"
rc_mutex = "warn"
rest_pat_in_fully_bound_structs = "warn"
use_self = "warn"
wildcard_imports = "warn"
absolute_paths = "warn"
Workspace lint inheritance is opt-in per member crate: every member crate must set [lints] workspace = true in its own Cargo.toml, or the workspace lint tables do nothing.
[package]
name = "example-crate"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[lints]
workspace = true
For a single crate, put the same package fields and lint tables in the crate’s Cargo.toml instead of a workspace root, renaming the tables to [lints.rust] and [lints.clippy]; copied [workspace.lints.*] tables do nothing in a standalone manifest.
For async projects, add Tokio deliberately to the package or workspace dependencies:
tokio = { version = "1", features = ["full"] }
rustfmt Baseline
Use this rustfmt.toml at the project root:
edition = "2024"
style_edition = "2024"
max_width = 100
comment_width = 80
group_imports = "StdExternalCrate"
imports_granularity = "Module"
use_field_init_shorthand = true
merge_derives = true
overflow_delimited_expr = true
format_code_in_doc_comments = true
format_macro_matchers = true
normalize_doc_attributes = true
wrap_comments = true
struct_field_align_threshold = 20
enum_discrim_align_threshold = 20
Install the pinned formatter, the MSRV toolchain, and the test runner used by the verification commands:
rustup toolchain install nightly-2026-04-14 --profile minimal --component rustfmt
rustup toolchain install 1.85.0 --profile minimal
cargo install cargo-nextest --locked
Optional Clippy Guardrails
Use clippy.toml for project-specific architectural guardrails. For async projects, review rules like these before copying them:
allow-unwrap-in-tests = true
allow-unwrap-types = ["std::sync::LockResult"]
disallowed-methods = [
{ path = "std::thread::sleep", reason = "Prefer tokio::time::sleep on Tokio paths; document intentional blocking sleeps with #[expect(clippy::disallowed_methods, reason = \"...\")]", replacement = "tokio::time::sleep" },
{ path = "std::thread::spawn", reason = "Prefer Tokio task APIs on async paths; document intentional dedicated OS threads with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
{ path = "std::process::Command::new", reason = "Prefer tokio::process::Command on Tokio paths; document intentional synchronous subprocesses with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
]
disallowed-types = [
{ path = "std::io::Read", reason = "Blocking trait; prefer tokio::io::AsyncReadExt on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_types, reason = \"...\")]" },
{ path = "std::net::TcpStream", reason = "Blocking socket; prefer tokio::net::TcpStream on Tokio paths. Document intentional sync networking with #[expect(clippy::disallowed_types, reason = \"...\")]" },
]
Verification Commands
Use these commands as the default new-project validation set:
cargo +nightly-2026-04-14 fmt --check --all
cargo clippy --locked --workspace --all-targets --all-features -- -D warnings
cargo nextest run --workspace --all-targets --all-features
cargo +1.85.0 check --workspace --all-targets --all-features
If the project intentionally maintains doctests, add:
cargo test --doc --workspace --all-features
Avoid
- Do not add async casually; document the project posture first.
- Do not add every standard dependency to every project by default.
- Do not copy Tokio-specific Clippy guardrails into sync projects.
- Do not create broad preludes, public facades, or feature flags before the project needs them.
- Do not lower
unsafe_code = "deny"unless the new crate’s purpose requires unsafe code. - Do not impose test placement quotas by source file; test behavior at the level where it is observable.
Reusable Library Release Verification
Use this workflow before releasing or handing off a reusable library crate, especially when it has optional features, public APIs, or an explicit MSRV.
Guideline Routing
Always load the pages for classification, edition and MSRV, Cargo, lints, testing, CI tooling, and public API evolution.
Load error, documentation, unsafe, async, or observability pages only when those surfaces are part of the library API.
Workflow
- Confirm the crate is a reusable library and identify its public API, feature flags, and declared MSRV.
- Verify all features are additive. If features are intentionally incompatible, document the supported feature matrix before release.
- Check that public dependency types are exposed only when they are part of the intended contract.
- Run the default all-features verification commands.
- Run dependency and supply-chain checks when the project has the tools installed.
- Verify out-of-box behavior for the default feature set.
- For published crates, run
cargo semver-checksto detect accidental public API breaks andcargo publish --dry-runto validate the release artifact. - Record any MSRV bump, public API break, new optional dependency, or feature behavior change in release notes or the changelog.
Default Verification
Use --workspace for every library crate or -p crate-name for one crate in a mixed workspace. Replace the illustrative +1.85.0 with the declared rust-version.
| Purpose | Command |
|---|---|
| Formatting | cargo +nightly-2026-04-14 fmt --check --all |
| Lints | cargo clippy --locked --workspace --all-targets --all-features -- -D warnings |
| Tests | cargo nextest run --workspace --all-targets --all-features |
| Declared MSRV | cargo +1.85.0 check --workspace --all-targets --all-features |
| Default features | cargo check --workspace --all-targets |
| Minimal features | cargo check --workspace --all-targets --no-default-features |
If the project intentionally maintains doctests, add:
cargo test --doc --workspace --all-features
Feature Matrix
Use --all-features by default. For intentionally incompatible features, replace it with a documented matrix of supported combinations:
cargo check --workspace --all-targets --no-default-features
cargo check --workspace --all-targets --features serde
cargo check --workspace --all-targets --features tokio
cargo check --workspace --all-targets --features "serde tokio"
Keep the matrix small; reconsider features that produce a large combination surface.
Dependency Checks
When the project has the tools installed, run:
cargo audit
cargo deny check
cargo machete
Treat these as release gates for published crates when the project has adopted them. For internal libraries, use them when dependency churn, public dependency exposure, or supply-chain risk is material.
Semver and Artifact Checks
For published crates, detect accidental public API breaks and validate the release artifact:
cargo semver-checks
cargo publish --dry-run
Install the checker once with cargo install cargo-semver-checks --locked. Use cargo package instead of the dry-run publish when the crate is not published to a registry. Treat any semver-major finding as either a bug to fix or an intentional break to record in step 8.
Avoid
- Do not release a library after checking only the default feature set when optional feature-gated code changed.
- Do not use
--all-featuresas a substitute for documenting intentionally incompatible feature combinations. - Do not let a dependency update raise MSRV without making that decision explicit.
- Do not add release-only verification commands that are never run locally or in CI.
- Do not require security or dependency tools for every tiny internal crate unless the project has adopted those gates.
Performance Investigation
Use this workflow when investigating slow Rust code, performance regressions, excess resource use, or proposed optimization work.
Guideline Routing
Always load benchmark/CI policy. Load collections, ownership, concurrency, blocking work, or observability only when evidence points to that surface.
Load async, Cargo, or public API pages when the proposed optimization changes those contracts.
Workflow
- Define the symptom, workload, success metric, and acceptable tradeoffs before changing code.
- Reproduce the issue with representative inputs in a release-like build; do not trust debug timings.
- Record a baseline measurement and the exact command, input, machine, and feature set used.
- Profile before optimizing. Use the project-standard profiler,
flamegraph,samply, Instruments,perf, Tokio Console, or service telemetry as appropriate. - Identify the hot path from evidence, then classify the bottleneck: algorithm, allocation/copying, locking, blocking I/O, async scheduling, serialization, or logging overhead.
- Change one thing at a time. Prefer simpler data flow, better algorithms, fewer clones, or narrower locks before allocator, profile, or compiler tuning.
- Rerun the same measurement and keep the change only when it materially improves the target metric without violating style or correctness.
- Add a benchmark, load test, regression test, or release note when the performance behavior is important enough to preserve.
Measurement Commands
Use the tool that matches the code shape. Examples:
cargo bench
cargo test --release targeted_case -- --nocapture
hyperfine 'target/release/app input.txt'
cargo flamegraph --bench parser
Profilers need debug symbols to produce readable stacks; before capturing flamegraphs, enable debuginfo in the profiled release or bench profile (or a dedicated profiling profile):
[profile.release]
debug = true
For async services, prefer production-like tracing, metrics, load tests, and Tokio task/lock visibility over isolated microbenchmarks when the problem is scheduling or contention.
Avoid
- Do not optimize before reproducing and measuring the issue.
- Do not compare debug builds to release builds.
- Do not tune allocators, profiles,
target-cpu, or#[inline]before identifying a hot path. - Do not keep changes that make code harder to understand without a measured win.
- Do not change several variables at once and then guess which one mattered.
- Do not use benchmarks with toy inputs when real workloads have different sizes, distributions, or contention.
Code Review and Refactor
Use this workflow when reviewing, refactoring, or changing existing Rust code in a project that already has structure and conventions.
Guideline Routing
Always load classification, public API evolution, lints, and CI/testing.
Load panic, error, ownership, concurrency, observability, unsafe, newtype, trait, async, validation, collection, or documentation pages only when the changed surface needs them.
Workflow
- Classify the code first: published library API, shared in-repo library, application/service, CLI, test support, or tests.
- Identify the behavioral surface being changed and the callers affected. Treat externally consumed APIs as stricter than internal application code.
- Load only the guideline pages relevant to that surface.
- Scan high-risk patterns before editing: accidental public API changes, hidden panics, flattened errors, unnecessary clones or lifetimes, locks across
.await, blocking work on async paths, unredacted logs, unsafe, and macro-generated behavior. - Make the smallest coherent change. Preserve existing local style unless it conflicts with this guide or the requested behavior.
- Add or update tests at the level where the behavior is observable.
- Run verification appropriate to the change: formatter, Clippy, tests, MSRV/all-features checks, or a narrower command when the project makes the full suite impractical.
- Report what changed, what was verified, and any exceptions or skipped checks with the reason.
Review Checklist
- Scope: Did the change affect library, application, CLI, or test-only behavior?
- API: Did
pub, re-exports, features, MSRV, or public dependencies change? - Errors: Are recoverable failures returned with source chains and boundary context?
- Panics: Are
unwrap,expect,panic!, and assertions limited to invariants? - Ownership: Are clones, borrows, and owned snapshots named honestly?
- Async/concurrency: Are task ownership, cancellation, blocking work, and lock scopes explicit?
- Observability: Are logs structured, low-noise, and free of secrets?
- Unsafe/macros: Is any unsafe or macro complexity justified, isolated, and documented?
- Tests: Does coverage protect behavior rather than private implementation churn?
- Verification: Were the commands run fresh, and are skipped checks explained?
Avoid
- Do not load every guideline page by default.
- Do not refactor unrelated code while reviewing a focused change.
- Do not apply library-level ceremony to private application internals without a reason.
- Do not relax lint, test, or safety policy to make a local change easier.
- Do not report a change as verified without naming the commands that ran.
- Do not hide exceptions; document why the local case differs from the default rule.