Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

TaskLoad
Create or configure a Rust projectnew project workflow
Verify a reusable library releaselibrary release workflow
Investigate performanceperformance workflow
Review or refactor Rust codereview workflow

Policy Fast Paths

TaskLoad
Define a public library errorlibrary/application errors, error taxonomy, public API evolution
Handle top-level application errorslibrary/application errors, error propagation, panics
Choose enum, generic, or trait objectenum/trait selection, trait design
Add a domain ID or validated valuenewtypes, constructors, validation
Write async service codeasync runtime, task lifecycle, shutdown, logging
Add instrumentationlogging and observability
Configure formatting, lints, or testsrustfmt, Clippy, CI
Review unsafe code or macrosunsafe code and macros, public API evolution
Other Rust policy workguideline 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 Activation section 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

Tooling and Project Shape

Type and API Design

Ownership and Data Flow

Errors, Safety, and Diagnostics

Async and Concurrency

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, using ok_or_else, transpose, map, or explicit branching.
  • Iterators, closures, and loops - load when choosing iterator chains, loops, closure capture, collect, fold, or try_fold.

Testing and Release

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 pub item 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" and rust-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-version or 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.toml at the workspace root.
  • Run cargo +nightly-2026-04-14 fmt --all before handing off Rust changes.
  • Run the same toolchain with --check in 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.toml and opt member crates into them.
  • Run cargo clippy --locked --workspace --all-targets --all-features -- -D warnings in CI.
  • Enable clippy::pedantic at warn, 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

  • deny fails where the lint runs.
  • warn becomes an error under CI’s -D warnings.
  • allow is 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 clap derive 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-api and accounts-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, or common-types crates.
  • 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 and pub(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 pub when pub(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 mod when 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_CASE for 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 HTTPClient or URLParser.
  • 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 # Errors for caller-relevant failure modes.
  • Add # Panics for public panic behavior and # Safety for unsafe contracts.
  • Add examples only when they materially clarify use and the project opts into maintaining them.
  • Prefer compiling examples with a fallible main and 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 unwrap in public examples.
  • Do not mark examples ignore merely 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_run for examples that compile but would access real services or state.
  • Use ignore only when portability is impossible.
  • Use expect for 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 &self for observation, &mut self for mutation, and self for 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 new for infallible construction from required values.
  • Use try_new when construction validates or can fail; reserve parse for FromStr-backed text parsing.
  • Follow ownership policy for constructor parameters.
  • Keep validation inside the constructor or build method.
  • Implement Default only for an obvious, useful value.
  • Use a builder when defaults or optional fields would otherwise produce boolean flags and repeated None arguments.
  • 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 new panic 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 self setters 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 new for infallible wrappers and try_new for 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_* and into_* 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 Deref merely to inherit primitive methods.
  • Do not use From when conversion validates or can fail.
  • Do not choose vague names such as Value or Id outside 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 Deref for 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 Trait or 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 Trait unless 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 behind Self: 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 Debug for ordinary data and redact secret-bearing types manually.
  • Derive Clone for value semantics with acceptable cost.
  • Derive Copy only 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 Default only for a valid, useful, unsurprising value.
  • Hand-write Display for 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 Debug for credentials or tokens.
  • Do not derive Copy for resources or types likely to gain owned data.
  • Do not derive arbitrary ordering or invalid defaults.
  • Do not use Display for 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 From for infallible, obvious conversions.
  • Use TryFrom or FromStr for validation and fallible parsing.
  • Use From for lossless numeric widening and TryFrom for narrowing or signedness changes.
  • Choose explicit overflow behavior with checked_*, saturating_*, wrapping_*, or overflowing_*.
  • Use as_* for borrowed or scalar views, to_* for created values, and into_* for consuming conversions.
  • Name field-like accessors id(), name(), or status() and predicates is_*, has_*, or can_*.
  • Borrow accessors unless returning a small Copy value; ownership policy owns return choices.

Avoid

  • Do not use From for validation, failure, surprising allocation, or lossy meaning.
  • Do not use unchecked as for narrowing or float-to-integer conversion.
  • Do not use approximate floats with == or sort NaN-capable floats without total_cmp.
  • Do not name allocating methods as_*.
  • Do not use get_* for simple observation or generate every possible accessor.
  • Do not implement Deref merely 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_candidate to 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 &Path for 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>, and PathBuf rather 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, and Arc.
  • Use IntoIterator, AsRef, Into, or Cow only 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 &PathBuf by 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 Cow to avoid making an ownership decision.
  • Do not mix Arc::clone(&value) and value.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 Read or impl Write for reusable data-processing seams.
  • Use Cow for 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> or RefCell<T> only when shared-handle mutation is genuinely simpler than &mut self.
  • Use Weak to break parent-child and observer cycles.
  • Use OnceLock or LazyLock for one-time initialization.
  • Follow concurrency primitives for Arc, locks, and channels.

Avoid

  • Do not use Rc or RefCell across 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 RefCell for narrow single-threaded caches, adapters, or tests where runtime borrow checking is the clearest model.
  • Use Box only 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 Vec at an output boundary when determinism is needed only there.
  • Preallocate only when size is already known.
  • Use retain, drain, and std::mem::take for 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 IndexMap when 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), or Other(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::Result in binaries, command handlers, workers, tests, and internal glue.
  • Keep typed domain errors in applications where callers recover differently.
  • Use miette for labels, snippets, help text, and polished CLI reports.
  • Convert into miette only at presentation; keep internal errors on thiserror or anyhow.
  • Follow error taxonomy and propagation for structure and sources.

Avoid

  • Do not expose anyhow::Error from reusable public APIs.
  • Do not use miette as 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 anyhow in an internal crate that is purely application implementation.
  • Use miette when 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 From or #[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()) or anyhow!("{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 Result for caller-controlled or environmental failures.
  • Use expect only when failure proves a hard-coded value, fixture, or invariant is wrong.
  • State the invariant in each expect message.
  • 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 unwrap outside tests; workspace policy denies it.
  • Do not write messages such as should work or failed.
  • 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!() or unimplemented!() 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 unwrap in 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 new panic 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 Error field.
  • 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 + 'static only 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, or Supervisor.
  • 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 JoinHandle cancels 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 'static to 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_blocking for 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_blocking closure 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 Mutex or RwLock for 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; adopt RwLock only 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 RwLock or 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 else when a missing pattern exits the current scope.
  • Use if let for one special pattern and while let for repeated extraction.
  • Use match when 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 else for substantial recovery logic.
  • Do not replace error handling with unwrap or expect.
  • 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 same Result or Option shape.
  • Convert required optional values with ok_or for cheap errors and ok_or_else for computed errors.
  • Use map, filter, and unwrap_or_else for short side-effect-free transforms.
  • Use map_err for typed conversion that retains the source.
  • Use transpose to turn Option<Result<T, E>> into Result<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, or inspect.
  • 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_default when absence is a domain error.
  • Do not check is_some and 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 match for 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, or into_iter from 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_fold or try_for_each only 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 move when a closure outlives its scope or crosses a task/thread boundary.
  • Clone into closures when it avoids awkward lifetimes and cost is acceptable.
  • Use enumerate or zip instead of manual paired indexes.

Avoid

  • Do not hide control flow in long pipelines.
  • Do not use for_each for side-effect-heavy work.
  • Do not use fold for 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_each in 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 Result from 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-util feature.

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-features locally and in CI.
  • Keep cargo test for doctests and cases Nextest does not cover.
  • Run pinned formatting and Clippy checks alongside tests.
  • Use insta for stable complex textual or structured output.
  • Commit and review snapshot changes; normalize nondeterministic fields first.
  • Use proptest for parsers, round trips, normalization, state machines, and broad invariants.
  • Use criterion only 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 test until Nextest is adopted deliberately.
  • Keep quickcheck where it is already established; prefer proptest for 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

  1. Identify the project shape: library, application, CLI, service, test support crate, or mixed workspace.
  2. Make the sync-vs-async posture explicit before adding async dependencies; async projects use Tokio.
  3. Prefer a workspace when multiple crates share version, edition, dependencies, lints, or profiles.
  4. Set Rust 2024 and rust-version = "1.85" unless the project already has different constraints.
  5. Add pinned rustfmt configuration and use nightly-2026-04-14 for formatting.
  6. Add curated workspace lints and tailor project-specific clippy.toml guardrails before copying async/blocking disallow rules.
  7. Place tests according to behavior and risk: focused unit tests near local logic and integration tests around public workflows.
  8. Use cargo nextest run --workspace --all-targets --all-features as the normal workspace test runner.
  9. Skip doctests unless the project explicitly opts into maintaining rustdoc examples.
  10. Add dependencies only for an identified need.
  11. 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

  1. Confirm the crate is a reusable library and identify its public API, feature flags, and declared MSRV.
  2. Verify all features are additive. If features are intentionally incompatible, document the supported feature matrix before release.
  3. Check that public dependency types are exposed only when they are part of the intended contract.
  4. Run the default all-features verification commands.
  5. Run dependency and supply-chain checks when the project has the tools installed.
  6. Verify out-of-box behavior for the default feature set.
  7. For published crates, run cargo semver-checks to detect accidental public API breaks and cargo publish --dry-run to validate the release artifact.
  8. 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.

PurposeCommand
Formattingcargo +nightly-2026-04-14 fmt --check --all
Lintscargo clippy --locked --workspace --all-targets --all-features -- -D warnings
Testscargo nextest run --workspace --all-targets --all-features
Declared MSRVcargo +1.85.0 check --workspace --all-targets --all-features
Default featurescargo check --workspace --all-targets
Minimal featurescargo 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-features as 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

  1. Define the symptom, workload, success metric, and acceptable tradeoffs before changing code.
  2. Reproduce the issue with representative inputs in a release-like build; do not trust debug timings.
  3. Record a baseline measurement and the exact command, input, machine, and feature set used.
  4. Profile before optimizing. Use the project-standard profiler, flamegraph, samply, Instruments, perf, Tokio Console, or service telemetry as appropriate.
  5. Identify the hot path from evidence, then classify the bottleneck: algorithm, allocation/copying, locking, blocking I/O, async scheduling, serialization, or logging overhead.
  6. Change one thing at a time. Prefer simpler data flow, better algorithms, fewer clones, or narrower locks before allocator, profile, or compiler tuning.
  7. Rerun the same measurement and keep the change only when it materially improves the target metric without violating style or correctness.
  8. 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

  1. Classify the code first: published library API, shared in-repo library, application/service, CLI, test support, or tests.
  2. Identify the behavioral surface being changed and the callers affected. Treat externally consumed APIs as stricter than internal application code.
  3. Load only the guideline pages relevant to that surface.
  4. 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.
  5. Make the smallest coherent change. Preserve existing local style unless it conflicts with this guide or the requested behavior.
  6. Add or update tests at the level where the behavior is observable.
  7. 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.
  8. 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.