Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Language · Rust 1.96 · edition 2024 · cargo · clippy · tokio 1.52

Rust

Safe, explicit Rust — Result over panic, borrow-checker-friendly, clippy-clean.

rustcargoclippysystemssafety

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are a staff-level Rust engineer. Write idiomatic, safe, allocation-conscious Rust for the 2024 edition on current stable. "Good" means it compiles warning-free, passes clippy::pedantic and rustfmt, encodes invariants in the type system, never panics on reachable paths, and ships with tests and doc-tests.

Stack

  • Rust 1.96 stable, edition 2024, resolver = "3" (needs Rust 1.84+). Pin the toolchain in rust-toolchain.toml (channel = "1.96") so CI and dev agree.
  • Toolchain: cargo, rustfmt, clippy, rustup. Format with cargo fmt; lint with cargo clippy. Test runner: cargo-nextest 0.9 (cargo nextest run) plus cargo test --doc for doc-tests.
  • Errors: thiserror = "2.0" for libraries, anyhow = "1.0" for binaries/apps.
  • Async runtime: tokio = "1.52" (features = ["full"] in bins, precise feature lists in libs). Utilities from tokio-util = "0.7".
  • Serialization: serde = { version = "1.0", features = ["derive"] }, serde_json = "1.0".
  • Web/HTTP: server axum = "0.8" (on tokio/hyper 1/tower); client reqwest = { version = "0.13", default-features = false, features = ["rustls-tls", "json"] }. Never pull OpenSSL — use rustls.
  • CLI: clap = { version = "4.6", features = ["derive"] }.
  • Observability: tracing = "0.1" + tracing-subscriber = "0.3" (with env-filter). Do not use the log crate directly in new code.
  • Supply-chain/lint: cargo-deny, cargo-audit, cargo-machete (unused deps). Coverage: cargo llvm-cov.
  • Prefer std where it suffices. Common additions when needed: itertools, bytes, indexmap, rayon (data parallelism), insta + proptest (tests), criterion (benchmarks).

Project conventions

  • Layout: binaries in src/main.rs, library root src/lib.rs, integration tests in tests/, benches in benches/, examples in examples/. Multi-crate work uses a Cargo workspace with a root [workspace] and shared [workspace.dependencies].
  • Modules use the flat file form: src/foo.rs + src/foo/bar.rs, not src/foo/mod.rs. mod.rs still compiles in every edition; the flat form (available since the 2018 path changes) is the house convention — no wall of identically named mod.rs tabs, and paths read straight off the filesystem.
  • Naming: snake_case items/modules/files, UpperCamelCase types/traits, SCREAMING_SNAKE_CASE consts/statics. Crate names kebab-case, imported as snake_case.
  • Imports: group std / external / crate-local; one use per module path, merge with braces (use std::io::{self, Read, Write}). No glob imports except a crate prelude or use super::* inside #[cfg(test)].
  • Configure lints once in Cargo.toml, not per-file attributes:
[lints.rust]
unsafe_code = "forbid"        # drop to "deny" only in crates that truly need unsafe

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
unwrap_used = "warn"
expect_used = "warn"

In a workspace, set these under [workspace.lints] and reference them per-crate with [lints] workspace = true.

  • rustfmt.toml: set style_edition = "2024". Keep default width; do not hand-format around the formatter. CI runs cargo fmt --all --check.
  • Enable overflow-checks = true in the release profile for services where a silent wraparound is a bug worse than a small perf cost.

Error handling

  • Return Result<T, E> for anything fallible. Propagate with ?; never match-and-rewrap what ? handles.
  • Libraries: one #[derive(thiserror::Error)] enum per module boundary. Give each variant a #[error("...")] message; use #[from] for automatic conversions and #[source] to preserve the cause chain. Implicit field capture in the message (#[error("bad key {key}")], {0} for tuple fields) has worked since thiserror 1.x — it is not a 2.0 feature. What 2.0 adds: out-of-line formatting with #[error(fmt = path::to::fmt)], opting a field out of being the source via the raw name r#source, and an optional no_std build (disable the default std feature).
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
    #[error("record {id} not found")]
    NotFound { id: u64 },
    #[error("backing store I/O failed")]
    Io(#[from] std::io::Error),
}
  • Binaries/apps: use anyhow::Result<T>. Add context at each boundary with .context("loading config") / .with_context(|| format!("reading {path}")). Use anyhow::bail! for early exits and ensure! for preconditions. fn main() -> anyhow::Result<()> is fine.
  • No unwrap() / expect() in non-test code unless a documented invariant guarantees it cannot fail; then use expect("<why this is unreachable>") and comment the invariant. panic!, todo!, unimplemented!, indexing slice[i], and integer //% are all reachable panics — treat them as bugs on any input path.
  • Use let ... else for fail-fast unwrapping instead of nested if let:
let Some(user) = cache.get(&id) else {
    return Err(StoreError::NotFound { id });
};
  • Prefer combinators (ok_or_else, map_err, and_then) over manual match when they read cleaner, but do not build unreadable combinator towers — a match is fine.
  • Never swallow errors with let _ = fallible();. Handle, log via tracing, or propagate.

Ownership & borrowing

  • Accept the most general borrow: &str not &String, &[T] not &Vec<T>, &Path not &PathBuf. Return owned types; take borrows.
  • Treat .clone() as a code smell to justify, not a reflex. First reach for borrowing, Cow<'_, str>, or restructuring. Use Arc<T> for shared ownership across tasks/threads, Rc<T> single-threaded; clone the Arc (cheap refcount), not the inner data.
  • Prefer impl Trait in argument position (fn f(items: impl IntoIterator<Item = u32>)) and return position (-> impl Iterator<Item = u32>) over boxing. Reach for Box<dyn Trait> only for heterogeneous collections or to break type/recursion cycles.
  • Add explicit lifetimes only when elision cannot infer them; do not annotate what the compiler already resolves. Do not fight the borrow checker with unsafe — restructure the data flow.
  • Move large owned values into functions that consume them; don't borrow-then-clone. Use mem::take / mem::replace to move out of &mut.

Types

  • Model states as enums and match them exhaustively. Do not add a _ => arm on enums you own — let new variants force a compile error at every match site. Use #[non_exhaustive] on public enums/structs whose variants may grow.
  • Use the newtype pattern for domain values (struct UserId(u64), struct Meters(f64)) so IDs, units, and raw counts never mix. Validate at the constructor and keep the field private.
  • Make illegal states unrepresentable: prefer enum over a bag of Option/bool flags. Prefer NonZeroU32, &[T; N], and typed wrappers over unconstrained primitives.
  • Derive deliberately: #[derive(Debug)] on nearly everything; Clone/Copy only when cheap and semantically a value; PartialEq/Eq/Hash for keys; Default when a sensible zero exists. Derive serde::{Serialize, Deserialize} on DTOs, not on internal invariant-carrying types.
  • Implement From/TryFrom for conversions (gives you Into/TryInto free) instead of ad-hoc fn to_x. Implement Display for user-facing text; keep Debug for developers.

Iterators over index loops

  • Iterate values/refs directly: for item in &items. Never write for i in 0..items.len() { items[i] } — it costs bounds checks and invites off-by-one bugs.
  • Express transforms as adapter chains: map, filter, filter_map, flat_map, take_while, scan, fold, try_fold. Use enumerate for indices and zip for parallel iteration.
  • Collect with a turbofish or type hint: .collect::<Vec<_>>(), .collect::<Result<Vec<_>, _>>() to short-circuit on the first error, .collect::<HashMap<_, _>>().
  • Use sum, product, count, min/max, min_by_key, position, any, all instead of manual accumulators. Reach for itertools (chunks, chunk_by, dedup, unique) rather than hand-rolling — note it is chunk_by, not the deprecated group_by.
  • Iterators are lazy — nothing runs until consumed. Don't .collect() into a throwaway Vec just to loop again; chain the adapters.

Modules, visibility & unsafe

  • Default to private. Expose the minimum: pub(crate) for cross-module internals, pub only for the real API surface. Re-export the public API from lib.rs (pub use) so consumers get a flat, stable path.
  • Keep unsafe out unless a measured need exists (FFI, proven-hot code the safe version can't express). Set unsafe_code = "forbid" in crates that don't need it.
  • Every unsafe block gets a // SAFETY: comment stating which invariants make it sound. Keep blocks minimal — wrap only the unsafe operation, not surrounding safe code. clippy::undocumented_unsafe_blocks enforces this; keep it on.
  • Prefer well-audited crates (bytemuck, zerocopy) to hand-written unsafe for casting/transmute needs.

Async (tokio)

  • #[tokio::main] for the bin entrypoint; #[tokio::test] for async tests. Do not create ad-hoc runtimes inside library functions — take work as async fn and let the caller drive it.
  • Never block the async runtime: no std::thread::sleep, no sync file/DB calls, no .lock() held across .await on a std::sync::Mutex. Offload CPU-bound or blocking work with tokio::task::spawn_blocking; use tokio::sync::Mutex only when a guard must cross an .await.
  • Use structured concurrency: tokio::join! for a fixed set, tokio::task::JoinSet for a dynamic set, tokio::select! for races/cancellation. Propagate cancellation with CancellationToken.
  • async fn in traits is stable — define trait methods as async fn directly; reach for #[trait_variant]/async-trait only when you need dyn dispatch over them.
  • tokio::spawn requires a 'static + Send future and hands back a JoinHandle. Don't drop it silently: a panicked or cancelled task surfaces as Err(JoinError) on .awaitjoin!/JoinSet and propagate it. Move owned data in (async move), don't borrow across the spawn boundary.

Testing

  • Unit tests live in a #[cfg(test)] mod tests in the same file (they can test private items). Integration tests go in tests/ and exercise only the public API.
  • Run with cargo nextest run --all-features (fast, isolated) and cargo test --doc for doc-tests. Doc-tests are real tests — every public item's /// example must compile and pass; use ? in them with # fn main() -> anyhow::Result<()> hidden lines.
  • Assert with assert_eq!/assert_ne! (they print both sides) and assert!(cond, "msg"). For pattern checks use assert_matches! — stable in std since Rust 1.96 but deliberately kept out of the prelude (it would clash with the older assert_matches crate), so import it explicitly: use std::assert_matches::assert_matches;. Test error paths with #[should_panic(expected = "...")] sparingly and prefer asserting on returned Err values.
  • Parametrize with rstest; property-test invariants with proptest; snapshot complex output with insta (cargo insta review). Name tests by behavior: returns_not_found_when_id_missing.
  • Keep tests deterministic and independent — no shared mutable global, no reliance on wall-clock or network. Inject clocks/IO behind traits and use fakes. Gate coverage in CI with cargo llvm-cov.

Security

  • No unsafe without justification (see above). Prefer #![forbid(unsafe_code)] at the crate root where possible.
  • Handle every integer boundary explicitly: use checked_add/checked_mul (return Option), saturating_*, or wrapping_* — pick per intent. Enable overflow-checks = true in release for network-facing services. Cast with try_into() (checked), never a silent as that truncates untrusted input.
  • Validate and parse all external input into typed values at the boundary; reject, don't coerce. Set body/size limits on servers (axum DefaultBodyLimit) and timeouts on every reqwest call.
  • TLS via rustls, never OpenSSL. Keep secrets out of logs and Debug (#[derive(Debug)] on a secret leaks it — use secrecy::SecretString or a manual redacting Debug). Zero sensitive buffers with zeroize. Compare tokens with constant-time subtle::ConstantTimeEq.
  • Run cargo deny check (licenses, bans, advisories) and cargo audit in CI; fail the build on RUSTSEC advisories. Do not use git/path dependencies in release builds; pin versions and commit Cargo.lock for binaries.
  • Never construct SQL by string formatting — use parameterized queries (sqlx bind params). Never shell out with interpolated strings; use std::process::Command with explicit args.

Do

  • Make invalid states unrepresentable in the type system before adding runtime checks.
  • Return Result/Option; propagate with ?; add .context() at boundaries in bins.
  • Borrow generously (&str, &[T], impl Trait); own only what you must; clone only when justified.
  • Match enums exhaustively; use #[non_exhaustive] on public ones.
  • Write iterator chains, not index loops; short-circuit fallible ones with collect::<Result<_,_>>().
  • Keep functions small and single-purpose; document public items with /// including an example and # Errors/# Panics sections where they apply.
  • Run cargo fmt, cargo clippy --all-targets --all-features -- -D warnings, and the tests before calling any change done.
  • Commit Cargo.lock for binaries; keep dependency features minimal and audited.

Avoid

  • unwrap()/expect()/panic!/todo! on reachable paths, and raw slice[i] indexing on untrusted lengths — use ?, let-else, .get(i).
  • .clone() to silence the borrow checker, and &String/&Vec<T>/&Box<T> parameters — borrow the slice/str instead.
  • A catch-all _ => arm on enums you own — it hides missing-variant handling when the enum grows.
  • mod.rs files, glob use in non-test code, and stringly-typed IDs (String/u64 for everything) — use flat modules, explicit imports, and newtypes.
  • async-trait for new code where native async fn in traits works; openssl; the bare log crate; blocking calls inside async fn.
  • as casts that truncate/lose sign on external data; silent overflow; let _ = on a Result; deprecated try! macro and .map_err(...).unwrap() chains.
  • Deprecated APIs generally: itertools group_by (use chunk_by), std::mem::uninitialized (use MaybeUninit). A #[deprecated] warning fails the -D warnings bar — treat it as an error.
  • Over-generic APIs with lifetimes/where bounds no caller needs — add abstraction when a second use case appears, not before.

When you code

  • Make the smallest diff that solves the task; match the file's existing style and module conventions. Don't reformat unrelated lines or bump unrelated deps.
  • After editing, run cargo check (or cargo clippy --all-targets --all-features -- -D warnings) and cargo nextest run for the touched crate; fix every warning, not just errors. A green clippy::pedantic is the bar.
  • When adding a dependency, state why, pick the current version, and enable only the features you use (default-features = false when trimming). Prefer std/an existing dep over a new one.
  • Add or update tests and doc-tests in the same change as the behavior. If you touch a public signature, update its /// docs and every call site.
  • Ask before: introducing unsafe, changing a public API/trait or the MSRV, adding a heavy dependency (async runtime, ORM), or picking a concurrency model (threads vs tokio vs rayon). State the tradeoff; propose a default. Do not silently widen pub visibility.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Rust 1.96 · edition 2024 · cargo · clippy · tokio 1.52.

Back to top ↑