name: init-rust
description: Use when initializing Rust best-practices rules in a project, the user invokes /init-rust, or a new Rust workspace needs convention rules wired up for an agent. Inputs - target project root, an existing or creatable .claude/rules/ directory, and confirmation the project targets Rust edition 2024 (rules assume 2024 + MSRV 1.85). Do not use when the user wants language-agnostic project init, framework-specific scaffolding (axum, tauri, leptos, etc.), or actual code generation; use those skills or write code directly. Produces .claude/rules/rust.md with paths: "**/*.rs" frontmatter covering workspace layout, error handling, async/Tokio, CLI (clap), serde, tracing, type system, process supervision, security, testing, naming, tooling, and Edition 2024 changes. Escalate if .claude/rules/rust.md already exists with manual edits, the project is not a Rust workspace, or the user targets an edition older than 2024.
Initialize Rust Best Practices
Add Rust best practices. Follow the /init-conventions skill for standard file handling.
Target File
.claude/rules/rust.md
Path Pattern
**/*.rs
Content
paths: "**/*.rs"
Rust Rules
Core principle: let the type system prevent bugs at compile time rather than catching them at runtime.
Project Structure
Use a virtual workspace manifest (no [package] in root Cargo.toml) with a flat crates/ layout:
project/
├── Cargo.toml # Virtual workspace manifest
├── Cargo.lock # Committed for binaries
├── deny.toml
├── clippy.toml
├── rustfmt.toml
├── crates/
│ ├── core/ # Business logic (lib)
│ ├── cli/ # CLI binary (bin)
│ └── daemon/ # Daemon binary (bin)
└── xtask/ # Build automation
Workspace Configuration
Centralize ALL dependency versions and lints in root Cargo.toml:
[workspace]
resolver = "3"
members = ["crates/*"]
[workspace.package]
edition = "2024"
rust-version = "1.85"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
anyhow = "1"
my-core = { path = "crates/core" }
[workspace.lints.rust]
unsafe_code = "forbid"
unreachable_pub = "warn"
unused_must_use = "deny"
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
must_use_candidate = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
dbg_macro = "deny"
print_stdout = "deny"
print_stderr = "deny"
unwrap_used = "deny"
todo = "deny"
unimplemented = "deny"
undocumented_unsafe_blocks = "deny"
clone_on_ref_ptr = "warn"
string_to_string = "warn"
wildcard_dependencies = "warn"
Member crates inherit:
[package]
name = "my-core"
version.workspace = true
edition.workspace = true
[dependencies]
serde = { workspace = true }
[lints]
workspace = true
Crate Splitting Rules
- Split when a module has clear independent responsibility or to improve compile parallelism
- Keep binary crates thin — just
main() + CLI arg parsing; all logic in library crates
- Use
pub(crate) for internal APIs, enable unreachable_pub lint
- Use
version = "0.0.0" for internal crates you don't publish
Build Profiles
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"
panic = "abort"
[profile.dev.package."*"]
opt-level = 3
Error Handling
Boundary rule: thiserror for library crates (typed, matchable), anyhow for binaries (opaque, ergonomic).
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read configuration file")]
ReadFailed(#[from] std::io::Error),
#[error("invalid value for key `{key}`")]
InvalidValue { key: String, #[source] source: ParseError },
}
use anyhow::{Context, Result};
fn load_config(path: &Path) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&content).context("failed to parse configuration")
}
Error Message Conventions
- Lowercase, no trailing punctuation
- Describe only the current layer — don't embed the source in Display
- Log errors only at the resolution point (avoid duplicate logging up the chain)
.unwrap() and .expect() Rules
- Never in production code (enforced by
clippy::unwrap_used)
.expect() only for proven invariants: Regex::new(r"^\d+$").expect("static regex")
- Both are fine in tests
Async Rust (Tokio)
Never Block the Runtime
No std::fs, std::thread::sleep, or CPU-heavy work on Tokio workers. Rule of thumb: no more than 10-100 microseconds between .await points.
| Workload | spawn_blocking | rayon | Dedicated thread |
|---|
| CPU-bound | Suboptimal | Yes | Yes |
| Sync I/O | Yes | No | Yes |
| Runs forever | No | No | Yes |
let data = tokio::task::spawn_blocking(move || {
std::fs::read_to_string(path)
}).await??;
let (tx, rx) = tokio::sync::oneshot::channel();
rayon::spawn(move || { let _ = tx.send(expensive_compute()); });
let result = rx.await?;
Structured Concurrency
Use JoinSet (owns tasks, gives return values, aborts on drop) or TaskTracker (tracks tasks for shutdown, does NOT abort on drop).
let token = CancellationToken::new();
let tracker = TaskTracker::new();
while let Some(conn) = token.run_until_cancelled(listener.accept()).await {
let child_token = token.child_token();
tracker.spawn(async move { handle(conn?, child_token).await });
}
tracker.close();
tracker.wait().await;
Channel Selection
| Channel | Pattern | Use when |
|---|
mpsc | Many-to-One, buffered | Work queues, actor mailboxes |
oneshot | One-to-One, single msg | Request/response, task results |
broadcast | Many-to-Many, all see all | Event bus, pub/sub |
watch | Many-to-Many, latest only | Config updates, state snapshots |
Always use bounded mpsc channels. Never use std::sync::mpsc in async code.
Cancellation Safety
Cancel-safe in select!: recv(), join_next(), cancelled().
Not cancel-safe: read_line(), read_exact(), send(). Use reserve().await + permit.send() instead.
Mutex Rules
std::sync::Mutex is fine and faster in async code for short critical sections
- Only use
tokio::sync::Mutex when holding across .await points
async fn in Traits
- Native
async fn in traits works since Rust 1.75 (all editions) for static dispatch
- Use
#[trait_variant::make(SendTrait: Send)] for Send bounds
- Keep
async-trait only for dyn Trait (dynamic dispatch)
CLI Design (clap)
#[derive(Debug, Parser)]
#[command(name = "myapp", version, about, propagate_version = true)]
pub struct Cli {
#[command(flatten)]
pub global: GlobalOpts,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Args)]
pub struct GlobalOpts {
#[arg(long, value_enum, global = true, default_value_t = OutputFormat::Human)]
pub format: OutputFormat,
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
}
- Top-level
Parser struct, never an enum (blocks adding global options later)
- Use
value_parser for validation — constraints appear in help text
- Data to stdout, errors to stderr
- Don't use
fn main() -> Result<()> — use explicit error handling + std::process::exit()
Serialization (serde)
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ServerConfig {
pub listen_addr: String,
#[serde(default = "default_port")]
pub listen_port: u16,
#[serde(default)]
pub tls_cert: Option<PathBuf>,
}
- Use
rename_all = "kebab-case" + deny_unknown_fields for TOML configs
- Use
#[serde(default)] on new optional fields for forward compatibility
- Don't use
deny_unknown_fields with #[serde(flatten)] — they conflict
Observability (tracing)
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,my_crate=debug"));
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_target(true))
.init();
Log Levels
| Level | Use for |
|---|
error! | Unrecoverable failures requiring action |
warn! | Unexpected but recoverable situations |
info! | Key lifecycle events (started, loaded, stopped) |
debug! | Developer diagnostics |
trace! | Hot-path details |
#[instrument] Best Practices
- Always
skip(self) on methods
skip(password, api_key) for sensitive data
- Use
err to auto-log errors
- Use
level = "trace" for hot internal functions
- Use
.instrument(span) on spawned tasks, never span.enter() across .await
Structured Logging
tracing::error!(error = %err, user_id = %uid, "payment processing failed");
tracing::error!("payment failed for user {} with error: {}", uid, err);
Type System and API Design
Newtype Pattern
pub struct UserId(u64);
pub struct OrderId(u64);
Typestate Pattern
struct Connection<State> { _state: PhantomData<State> }
struct Disconnected;
struct Connected;
impl Connection<Disconnected> {
fn connect(self) -> Connection<Connected> { }
}
impl Connection<Connected> {
fn send(&self, data: &[u8]) { }
}
Conversion Traits
- Always implement
From<T>, never Into<T> (blanket impl provides it)
- Use
TryFrom<T> for fallible conversions
- Method naming:
as_ (cheap borrow), to_ (expensive/borrow-to-owned), into_ (ownership transfer)
- Getters:
fn field(&self) -> &T (no get_ prefix)
Generics vs Trait Objects
- Generics (
impl Trait / <T>) for hot paths (static dispatch, inlining)
dyn Trait for heterogeneous collections or plugin architectures
Parameter Types
Accept borrowed types in function signatures:
| Accept | Not |
|---|
&str | String |
&[T] | Vec<T> |
&Path | PathBuf |
impl AsRef<str> | String |
Cow<'_, str> | When sometimes need to allocate |
Process Supervision (Unix)
Signal Handling
Use tokio::signal::unix::signal for async signals. Handle SIGTERM/SIGINT for shutdown, SIGHUP for reload.
PID Files
Use file locking (flock) with RAII Drop cleanup. Never check-then-create (TOCTOU race).
Process Groups
Use command-group with group_spawn(). Kill the group, not just the parent PID — prevents orphans.
Zombie Prevention
Reap children with waitpid(Pid::from_raw(-1), Some(WaitPidFlag::WNOHANG)) in a loop.
Safety and Security
unsafe_code = "forbid" at workspace level
- Every
unsafe block needs // SAFETY: comment
- Use
zeroize::Zeroizing<T> for secrets, secrecy::SecretString for secret strings
- Set file permissions at creation:
OpenOptions::mode(0o600)
- Never use
set_readonly(false) on Unix (makes files world-writable)
- Use
tempfile::tempfile() for temporary files (unnamed, immediately unlinked)
- No shell command construction from user input — pass args directly
- CI:
cargo audit + cargo deny + cargo vet
Testing
Structure
- Unit tests:
#[cfg(test)] mod tests at bottom of source files
- Integration tests:
tests/ with common/mod.rs for shared helpers
- Async tests:
#[tokio::test] with start_paused = true for time-dependent tests
Mocking
Design with trait-based dependency injection:
#[cfg_attr(test, mockall::automock)]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: u64) -> Result<User, DbError>;
}
CLI Testing
Use assert_cmd + predicates for testing exit codes, stdout/stderr content.
Naming Conventions (RFC 430)
| Item | Convention | Example |
|---|
| Types, traits, enum variants | UpperCamelCase | HttpRequest, NotFound |
| Functions, methods, modules | snake_case | read_file, is_empty |
| Constants, statics | SCREAMING_SNAKE_CASE | MAX_CONNECTIONS |
| Type parameters | Single uppercase | T, E, K |
| Lifetimes | Short lowercase | 'a, 'de |
Acronyms as one word: Uuid not UUID, Stdin not StdIn.
Module Organization
- Use
filename.rs style (not mod.rs) for new projects
- Keep
lib.rs/main.rs to module declarations and re-exports
- Use
pub use re-exports to flatten API surface
Documentation
/// for items, //! for crate/module-level docs
- First line: short summary sentence
- Required sections:
# Examples (public items), # Errors (Result fns), # Panics, # Safety (unsafe fns)
Tooling Configuration
rustfmt.toml
edition = "2024"
style_edition = "2024"
max_width = 100
hard_tabs = false
tab_spaces = 4
newline_style = "Unix"
clippy.toml
msrv = "1.85.0"
avoid-breaking-exported-api = true
cognitive-complexity-threshold = 30
too-many-arguments-threshold = 8
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-dbg-in-tests = true
allow-print-in-tests = true
large-error-threshold = 256
Edition 2024 Key Changes
- RPIT captures all lifetimes — use
+ use<> to opt out
unsafe_op_in_unsafe_fn is warn-by-default
unsafe extern blocks required
- Unsafe attributes:
#[unsafe(no_mangle)]
static mut references are errors — use Mutex/AtomicXxx
gen keyword reserved — use r#gen for old code
- Prelude:
Future and IntoFuture included
std::env::set_var is now unsafe fn
style_edition = "2024" required in rustfmt.toml
Common Mistakes
| Mistake | Fix |
|---|
Blocking async runtime with std::fs | Use tokio::fs or spawn_blocking |
.unwrap() in production code | Use ? with .context() |
Cancel-unsafe futures in select! | Use reserve() + permit.send() |
tokio::spawn without tracking | Use TaskTracker or JoinSet |
std::sync::Mutex across .await | Use tokio::sync::Mutex |
type Alias = u64 for domain types | Use struct NewType(u64) |
String params when &str works | Accept borrowed types |
fn main() -> Result<()> in CLI | Explicit error handling + exit() |
format!() in hot loops | Use write! with reusable buffer |
deny_unknown_fields + flatten | They conflict — remove one |
anyhow::Error in library public API | Use thiserror typed errors |
Unbounded mpsc channels | Use bounded with explicit capacity |
async-trait on Rust >= 1.75 | Use native async fn in traits |
set_readonly(false) on Unix | World-writable — use explicit mode() |
Missing style_edition in rustfmt.toml | Editor format-on-save uses wrong style |
CI Pipeline
Essential checks in order (fastest first):
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features (with RUSTFLAGS="-Dwarnings")
cargo nextest run --workspace --all-features + cargo test --doc --workspace
cargo deny check
cargo machete (unused dependencies)