Rust patterns for CLI tools, backend services, and general application code. Use when working with Rust, Cargo workspaces, axum/tokio services, clap CLIs, as...
---
name: ia-rust-systems
class: language
description: >-
Rust patterns for CLI tools, backend services, and general application code.
Use when working with Rust, Cargo workspaces, axum/tokio services, clap CLIs,
async concurrency, or configuring clippy, rustfmt, cargo-nextest, or Cargo.toml.
paths: "**/*.rs,**/Cargo.toml"
---
# Rust Systems & Services
Covers modern application-layer Rust (edition 2024): CLIs, web services, libraries. Not `no_std`/embedded.
## Tooling
| Tool | Purpose |
|------|---------|
| `cargo` | Build, dep management, script runner |
| `clippy` | Lint (`cargo clippy --workspace --all-targets -- -D warnings`) |
| `rustfmt` | Formatter (`cargo fmt --all`) |
| `cargo-nextest` | Test runner |
| `cargo-deny` | License + advisory + duplicate-dep checks |
| `cargo-machete` | Find unused dependencies |
- Pin `rust-toolchain.toml` per repo so every contributor and CI uses the same compiler.
- `cargo update -p <crate>` for single-package upgrades. `cargo update` rewrites everything — avoid in PR diffs.
- `Cargo.lock` goes in version control for binaries *and* libraries (modern guidance; reproducibility wins).
## Workspaces
Multi-crate projects use a workspace with layered crates. Dependencies point inward only.
```
Cargo.toml # [workspace] members + [workspace.dependencies]
crates/
protocol/ # Shared types, no deps on other workspace crates
storage/ # Persistence, depends on protocol
service/ # Business logic, depends on protocol + storage
cli/ # Binary, depends on everything
```
- Centralize versions in `[workspace.dependencies]`, reference as `foo = { workspace = true }` in members.
- Keep the leaf-most crate (`protocol` / types) dependency-free so every other crate can depend on it without cycles.
- Feature flags belong on the crate that introduces the dependency, not re-exported through the workspace root.
- **Library crates expose one stable facade**: a thin `lib.rs` with a `//!` purpose doc and `pub use` re-exports — one import path per concept, internals free to reorganize without breaking callers.
- **Feature gates must error, never silently degrade.** If runtime config requests a capability the binary wasn't compiled with (e.g. `device = "gpu"` on a non-CUDA build), fail at startup — silent fallback diverges from operator config unnoticed.
- **Centralize lints at the workspace root** with `[workspace.lints.*]` — every member crate inherits the same ruleset, no per-crate `#![deny(...)]` drift:
```toml
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
```
Each member crate opts in with `[lints] workspace = true`.
## Build Profiles
When tuning Cargo build profiles (release LTO, release-dbg symbols, release-min for distributable binaries) or adding dev-machine speedups (mold linker, `target-cpu=native`, share-generics), load [build-profiles.md](./references/build-profiles.md).
## Error Handling
Split by crate role:
- **Libraries / lower crates**: define typed errors with `thiserror`. Consumers can pattern-match.
- **Binaries / top-level crates**: use `anyhow::Result` with `.context("what was being attempted")`. Human-readable error chains.
- Never return `Box<dyn Error>` from library APIs — it erases variant information.
- Use `?` liberally. Never `.unwrap()` or `.expect()` outside tests and `main`. An `expect("...")` is acceptable only when the invariant is provably upheld and the message explains why.
- Convert at boundaries: `#[from]` on thiserror variants for auto-conversion; `.map_err(MyError::from)` when explicit.
- `bail!("...")` / `ensure!(cond, "...")` in application code for early exits.
- Prefer `Result<T, E>` over panics for any recoverable error. Panics are for programmer bugs (broken invariants), not runtime failures.
- **`#[must_use]` on fallible APIs**: annotate functions returning `Result` or newtype-wrapped results that callers frequently ignore. Catches `let _ = validate(x);` at compile time instead of shipping a silently-dropped error.
- **Make illegal call-sequences unrepresentable** — the type-state pattern: encode a mandatory call order as distinct types (`Client<Uninitialized>` → `Client<Connected>`) so an out-of-order call fails to compile instead of erroring at runtime.
## Ownership Discipline
- Take `&str` over `&String`, `&[T]` over `&Vec<T>` in function signatures — accepts more call sites for free.
- Return owned (`String`, `Vec<T>`) from constructors and public APIs. Borrow in hot paths where lifetimes are obvious.
- Reach for `Arc<T>` only when sharing across threads. Single-threaded sharing uses `Rc<T>` or references.
- `Cow<'_, str>` when a function sometimes allocates and sometimes borrows (e.g. normalization).
- Rely on lifetime elision. More than one signature needing an explicit `'a` is a signal the type should own its data — convert the borrow to owned before adding lifetimes.
- Reducing hot-path allocations (SmallVec, ArrayVec, string interning, `Bytes`, vectored writes): profile first, then load [performance.md](./references/performance.md).
## Async with Tokio
- Default runtime: `#[tokio::main]` with `features = ["full"]` for apps; `features = ["rt", "macros", "sync"]` for libraries that need to stay slim.
- `tokio::spawn` for independent tasks. `JoinSet` for a dynamic group awaited together with cancellation.
- `tokio::select!` for racing futures (timeouts, cancellation, first-wins).
- Never block the runtime: `tokio::task::spawn_blocking` for sync CPU work or blocking I/O libs.
- `tokio::sync::Mutex` only when the guard must be held across `.await`. Otherwise `std::sync::Mutex` is faster.
- **`tokio::sync::RwLock` when reads dominate writes** (config snapshots, route tables, hot caches). Many readers proceed in parallel; `Mutex` serializes them. For snapshot-swap semantics (rarely-updated config), `arc-swap::ArcSwap` is faster still — no lock on the read path.
- Cancellation: `CancellationToken` (from `tokio-util`) propagates shutdown. Long-running tasks must check it.
- Backpressure via bounded `mpsc` channels — unbounded channels hide memory growth until OOM.
- **`Semaphore` for hard concurrency limits** on spawn paths that don't fit a channel model (e.g. "at most 50 concurrent outbound HTTP calls"). `let _permit = sem.acquire().await?;` inside the task; dropping the permit releases the slot. Pair with `Arc<Semaphore>` shared across spawners.
- Don't mix async runtimes. Pick `tokio` and stick with it; `async-std` and `smol` don't interop cleanly.
## CLI Tools (clap)
- Use the derive API: `#[derive(Parser)]` + `#[derive(Subcommand)]`. Less boilerplate, types drive the help text.
- One `enum Commands` variant per subcommand; flatten shared flags into a `#[command(flatten)] struct CommonArgs`.
- `--json` flag on query commands for agent/pipe consumption. Emit via `serde_json::to_string(&value)?`.
- Exit codes: 0 success, 1 for errors `main` returned, 2 for argparse (clap handles this), reserve 3+ for domain meanings documented in `--help`.
- Provide `--version` automatically via `#[command(version)]`.
See [cli-tools.md](./references/cli-tools.md) for config layering, logging setup, progress reporting, and shell completions.
## HTTP Services (axum)
- Framework default: **axum** (tokio-native, tower middleware, extractor-based handlers). Pick `actix-web` only if an existing codebase uses it.
- Handlers return `Result<impl IntoResponse, AppError>`. Implement `IntoResponse` for `AppError` to centralize error → status mapping.
- Validate input at the boundary: `axum::extract::Json<T>` where `T: Deserialize + Validate` (use `validator` crate). Internal services trust input was validated.
- Share state via `State<Arc<AppState>>` — not globals, not `lazy_static`.
- Middleware via `tower::ServiceBuilder`: tracing → timeout → auth → CORS → handler. Order matters.
- **Resilience layers** (outbound clients, shared services): combine `LoadShed` + `ConcurrencyLimit` for backpressure, not unbounded queueing; full tower stack in [production-resilience.md](./references/production-resilience.md).
See [axum-service.md](./references/axum-service.md) for project layout, extractors, error types, graceful shutdown, and OpenAPI generation.
## Concurrency
| Workload | Approach |
|----------|----------|
| Independent async I/O | `tokio::spawn` + `JoinSet` or `futures::join!` |
| Data-parallel CPU work | `rayon` with `par_iter` |
| Shared mutable state across threads | `Arc<Mutex<T>>` or `Arc<RwLock<T>>`, smallest scope possible |
| Single-producer pipelines | `tokio::sync::mpsc` (async) or `std::sync::mpsc` (sync) |
| Broadcast / fan-out | `tokio::sync::broadcast` |
`rayon` and `tokio` coexist — use `tokio::task::spawn_blocking` to call a rayon pool from async code. Never call `.block_on()` from inside a tokio task; it deadlocks the runtime.
## Testing
- Built-in `#[test]`. Prefer `cargo nextest run --workspace` over `cargo test` — it runs tests in parallel processes with proper isolation.
- Unit tests live in `mod tests { ... }` at the bottom of the file (access to private items).
- Integration tests in `tests/` directory. One file per public surface area.
- `#[tokio::test]` for async tests. Add `flavor = "multi_thread"` when the code under test spawns tasks.
- `rstest` for parametrized tests and fixtures. `proptest` / `quickcheck` for property-based tests on pure logic.
- `insta` for snapshot testing CLI output, serialization, large structs. Review diffs with `cargo insta review`.
- `assert_cmd` + `predicates` for CLI integration tests (invokes the binary, asserts on stdout/stderr/exit code).
- **Assert on error variants with `matches!`**: `assert!(matches!(result.unwrap_err(), MyError::Validation(_)))` — no `match` arms to update when unrelated variants are added.
- Coverage: `cargo llvm-cov --workspace --html`. Target 70%+ on application code, higher on library crates.
- **Fuzzing for parsers**: `cargo fuzz` + `libfuzzer-sys` on any code parsing untrusted input; nightly runs surface panics and UB unit tests miss.
For generic test discipline (anti-patterns, mock rules, rationalization resistance), see the `ia-writing-tests` skill.
## Unsafe Discipline
- Default: no `unsafe`. If clippy flags it, don't `#[allow]` it — refactor.
- Every `unsafe` block gets a `// SAFETY:` comment above it explaining why each invariant holds. No comment = reviewer rejects.
- Keep `unsafe` blocks minimal — wrap in a safe abstraction at module boundary, mark the module `pub(crate)`.
- Use `miri` (`cargo +nightly miri test`) on any crate containing `unsafe` or raw pointer arithmetic — catches UB that optimizers mask.
- Prefer `bytemuck`, `zerocopy`, `bytes` over hand-rolled transmutes for zero-copy patterns.
- **Env-var writes are `unsafe` in edition 2024. Write them only in `main`, before the runtime starts or any thread spawns.** Concurrent `getenv` is UB; `OnceLock` does not make it safe. Watch for lazy `LD_LIBRARY_PATH`-style writes on first use — hoist them to startup.
## Production Resilience
When productionizing a service (config validation, `/health` + `/ready` endpoints, graceful shutdown, retries/timeouts/jitter, connection pools, diagnostic secret redaction), load [production-resilience.md](./references/production-resilience.md).
## Observability
For logging (`tracing` + `tracing-subscriber` with init recipe), `#[instrument]` spans, correlation IDs, metrics, and distributed tracing patterns, load [observability.md](./references/observability.md). Never use `println!` or `log::` in new code.
## CI
General CI design lives with the `ia-infrastructure-engineer` agent. For Rust-specific callouts (`rustsec/audit-check`, `cargo-llvm-cov`, `Swatinem/rust-cache`, `taiki-e/install-action`, matrix coverage guidance, doc-test step), load [ci-pipeline.md](./references/ci-pipeline.md).
## Discipline
- Simplicity first — every change as simple as possible, impact minimal code.
- Only touch what's necessary — avoid unrelated changes in a PR.
- No `#[allow(clippy::...)]` as a shortcut — fix the underlying issue. Document exceptions with a rationale.
- Before adding a trait or generic, verify it's used in 3+ places. Otherwise a concrete type is clearer.
## Verify
- `cargo fmt --all -- --check` passes with zero diffs
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` passes
- `cargo nextest run --workspace` (or `cargo test --workspace`) passes with zero failures
- `cargo deny check` passes (licenses, advisories, duplicates) for any crate going to production
- No new `unsafe` without `// SAFETY:` comment
don't have the plugin yet? install it then click "run inline in claude" again.
---
name: ia-rust-systems
slug: compound-eng-rust-systems
class: language
description: >-
Rust patterns for CLI tools, backend services, and general application code.
Use when working with Rust, Cargo workspaces, axum/tokio services, clap CLIs,
async concurrency, or configuring clippy, rustfmt, cargo-nextest, or Cargo.toml.
paths: "**/*.rs,**/Cargo.toml"
original_author: iliaal
source: clawhub
---
# Rust Systems & Services
Modern application-layer Rust (edition 2024) covering CLIs, web services, and libraries. Not `no_std`/embedded.
## intent
This skill documents production Rust patterns for CLI tools, backend services, and application libraries. Use it when building or maintaining Rust projects with Cargo workspaces, async services (axum, tokio), command-line tools (clap), or configuring the compiler toolchain (clippy, rustfmt, cargo-nextest). Follow these patterns to keep code simple, maintainable, and resilient in production.
## inputs
### Rust Toolchain
- `rust-toolchain.toml` pinned per repo so all contributors and CI use the same compiler version
- `Cargo.toml` and `Cargo.lock` (lock file goes in version control for reproducibility, both binaries and libraries)
### External Tools (optional but recommended)
| Tool | Purpose | Setup |
|------|---------|-------|
| `clippy` | Lint pass | Built-in; run `cargo clippy --workspace --all-targets -- -D warnings` |
| `rustfmt` | Code formatter | Built-in; run `cargo fmt --all` |
| `cargo-nextest` | Fast test runner | `cargo install cargo-nextest`; faster isolation than `cargo test` |
| `cargo-deny` | License/advisory/duplicate checks | `cargo install cargo-deny` |
| `cargo-machete` | Find unused deps | `cargo install cargo-machete` |
| `cargo-llvm-cov` | Coverage reports | `cargo install cargo-llvm-cov` |
| `miri` | UB detector for unsafe code | `cargo +nightly miri test` |
### Project Structure
- Monorepo using Cargo workspaces with layered crates (protocol/shared types → storage → service → CLI binaries)
- Centralized dependency versions in `[workspace.dependencies]`
- Centralized lints in `[workspace.lints.*]` inherited by all member crates
## procedure
### 1. Workspace Setup (Multi-Crate Projects)
**Input:** decision to structure as a monorepo or single crate.
**Steps:**
1. Create root `Cargo.toml` with `[workspace]` section listing all member crates.
2. Define `[workspace.dependencies]` once at the root with pinned versions.
3. Create subdirectory structure under `crates/`:
- `crates/protocol/` , shared types, zero dependencies on other workspace crates
- `crates/storage/` , persistence layer, depends only on `protocol`
- `crates/service/` , business logic, depends on `protocol` + `storage`
- `crates/cli/` , binary entrypoint, depends on everything
4. In each member's `Cargo.toml`, reference workspace versions: `tokio = { workspace = true }`.
5. Define `[workspace.lints.*]` at root with `rust` and `clippy` rule sets; each member opts in with `[lints] workspace = true`.
6. Verify no cycles: leaf crates (protocol) have no intra-workspace deps; each higher layer depends only on lower layers.
**Output:** Multi-crate workspace compiling with `cargo build --workspace`, all members sharing versions and lints.
### 2. Error Handling Strategy
**Input:** crate's role (library, binary, service layer).
**Steps:**
1. For library crates, define typed errors using `thiserror`:
```rust
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("validation failed: {0}")]
Validation(String),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
anyhow::Result<T> with .context("what was attempted") for error chains.Result<T, E> , never Box<dyn Error> from public APIs.? to propagate. Never call .unwrap() or .expect() outside tests and main..expect("reason...") in app code, the message must explain why the invariant is provably upheld.#[from] on thiserror variants for auto-conversion, or .map_err(MyError::from) when explicit.bail!("message") and ensure!(condition, "message") for early exits.Result) with #[must_use] so let _ = validate(x); fails at compile time.Client<Uninitialized> → Client<Connected>, making illegal calls unrepresentable at the type level rather than runtime errors.Output: Typed, composable error handling; application code uses anyhow, library code uses thiserror.
Input: crate type (app, library) and workload (independent tasks, data parallelism, I/O concurrency).
Steps:
#[tokio::main] with features = ["full"].features = ["rt", "macros", "sync"] to stay slim.tokio::spawn for independent tasks; wrap groups in JoinSet for coordinated cancellation.tokio::select! to race futures (timeouts, cancellation, first-to-complete).tokio::task::spawn_blocking for sync CPU work or blocking I/O library calls.tokio::sync::Mutex only when the guard must be held across .await.std::sync::Mutex (faster) when contention is low and the lock is released before any .await.tokio::sync::RwLock when reads dominate writes (many readers, few writers). For hot caches with rare updates, use arc-swap::ArcSwap instead.CancellationToken (from tokio-util); long-running tasks check it.mpsc channels to apply backpressure; avoid unbounded channels (they hide memory leaks until OOM).Semaphore: let _permit = sem.acquire().await?; inside the spawned task; dropping the permit releases the slot.tokio and commit to it; async-std and smol don't interop cleanly.Output: Async code structured with tokio primitives, no blocking calls on the runtime, proper backpressure and cancellation.
Input: requirement for a command-line interface.
Steps:
#[derive(Parser)] + #[derive(Subcommand)] for less boilerplate.enum Commands variant per subcommand.#[command(flatten)] struct CommonArgs.--json flag on query commands for agent/pipe consumption; emit output via serde_json::to_string(&value)?.--help.--version is provided automatically via #[command(version)].Output: Single-file CLI with typed subcommands, help text derived from struct fields, --json support.
Input: need to build a web service.
Steps:
axum as the framework (tokio-native, tower middleware, extractor-based handlers).Result<impl IntoResponse, AppError>.IntoResponse for your AppError to centralize error → HTTP status mapping.axum::extract::Json<T> where T: Deserialize + Validate (use the validator crate). Internal code trusts input was validated.State<Arc<AppState>> , no globals or lazy_static.tower::ServiceBuilder: tracing → timeout → auth → CORS → handler (order matters).ServiceBuilder::new()
.layer(TimeoutLayer)
.layer(RateLimitLayer)
.layer(ConcurrencyLimitLayer)
.layer(LoadShedLayer)
.layer(RetryLayer)
.service(client)
LoadShedLayer sheds excess load, ConcurrencyLimitLayer caps in-flight requests, RateLimitLayer bounds request rate, RetryLayer retries transient errors. Together, they produce backpressure instead of unbounded queueing.Output: Production axum service with middleware stack, centralized error handling, and resilience layers.
Input: function signatures, hot paths, and shared ownership needs.
Steps:
&str over &String, &[T] over &Vec<T> in signatures , they accept more call sites without conversion.String, Vec<T>) from constructors and public APIs; borrow in hot paths where lifetimes are obvious.Arc<T> only for cross-thread sharing; single-threaded sharing uses Rc<T> or references.Cow<'_, str> when a function sometimes allocates and sometimes borrows (e.g. normalization).'a in multiple signatures, consider making the type own its data instead.bytes::Bytes instead of Arc<Vec<u8>>. Use BytesMut to build buffers that split into Bytes without reallocation.smallvec::SmallVec<[T; N]> , inline for ≤N items, spills to heap beyond (good for "usually 1-8 items" like tag lists or lookup keys).arrayvec::ArrayVec<T, CAP> , fixed capacity, never allocates, returns error when full (good for bounded buffers, per-request scratch space).dashmap::DashMap<String, &'static str> with Box::leak on miss to get &'static str comparisons without per-call allocations.Vec/String on a cold path is not the bottleneck.Output: Functions accept borrowed generic types, hot paths minimize allocations, shared state uses appropriate sync/arc patterns.
Input: application code ready for testing.
Steps:
#[test] (built-in); run with cargo nextest run --workspace instead of cargo test for parallel isolation.mod tests { ... } block at the file's end for access to private items.tests/ directory, one file per public API surface.#[tokio::test]; add flavor = "multi_thread" when the code under test spawns tasks.rstest for parametrized tests and fixtures; proptest or quickcheck for property-based testing on pure logic.insta for snapshot testing (CLI output, serialization, large structs); review diffs with cargo insta review.assert_cmd + predicates (invokes the binary, asserts on stdout/stderr/exit codes).matches!: assert!(matches!(result.unwrap_err(), MyError::Validation(_))) instead of match arms. Cleaner and won't break when unrelated variants are added.cargo llvm-cov --workspace --html; target 70%+ on application code, higher on libraries.cargo fuzz + libfuzzer-sys. A short nightly run surfaces panics and UB that unit tests miss.Output: Comprehensive test suite covering happy paths, error cases, and edge cases. Coverage report generated.
Input: when unsafe is considered for zero-copy, transmute, or FFI patterns.
Steps:
unsafe. If clippy flags it, refactor instead of allowing.unsafe block gets a // SAFETY: comment above it explaining why each invariant holds. No comment = code review rejection.unsafe blocks minimal and wrap them in a safe module-level abstraction, marked pub(crate).cargo +nightly miri test on any crate containing unsafe or raw pointer arithmetic to catch UB.bytemuck, zerocopy, or bytes over hand-rolled transmutes for zero-copy patterns.std::env::set_var and remove_var are unsafe under edition 2024 because concurrent getenv from another thread is UB at the libc level. Pin every env-var write to single-threaded startup, before tokio::main or any std::thread::spawn. Common offender: native library discovery paths (LD_LIBRARY_PATH, ORT_DYLIB_PATH, LIBTORCH) set lazily , compute and set them in main before the runtime starts.Output: No unsafe code except where provably necessary; all unsafe blocks documented and verified with miri.
Input: workspace root Cargo.toml.
Steps:
[workspace.lints.rust] and [workspace.lints.clippy] once at the root:[workspace.lints.rust]
unsafe_code = "warn"
missing_docs = "warn"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
must_use_candidate = "allow"
[lints] workspace = true in its own Cargo.toml.cargo fmt --all -- --check before commit.cargo clippy --workspace --all-targets --all-features -- -D warnings and fix all warnings.#[allow(...)] as a shortcut.Output: All crates use the same lint configuration. Zero warnings on clippy and fmt checks.
Input: code ready for commit or PR.
Steps:
cargo fmt --all -- --check passes with zero diffs.cargo clippy --workspace --all-targets --all-features -- -D warnings passes with zero warnings.cargo nextest run --workspace (or cargo test --workspace) passes with zero failures.cargo deny check passes (licenses, advisories, duplicates).unsafe block has a // SAFETY: comment.#[allow(...)] lint exceptions without documented rationale.Output: Code ready to merge; all checks green.
Decision: Use a workspace if the project has 2+ crates with interdependencies (e.g. shared types, pluggable layers). For a single-purpose library or binary, a single crate is simpler.
If workspace: follow the layered dependency model (protocol → storage → service → CLI). Define [workspace.dependencies] and [workspace.lints.*] at the root.
If single crate: still define [workspace.lints.*] for consistency; no [workspace.dependencies] needed.
Decision: Use thiserror in libraries (typed errors, pattern-matchable variants). Use anyhow in binaries and app-layer code (human-readable error chains, less boilerplate).
If library: consumers must see error variants and decide how to handle them. thiserror enables that.
If binary: convert library errors to anyhow::Result at the boundary with .context("what was being attempted"). Log or display the chain to the user.
Decision: Always pick tokio. Don't mix runtimes.
If starting fresh: use tokio. It has the largest ecosystem (axum, tonic, sqlx, etc.) and best stability.
If forced to interop: stick with tokio and drive everything through it; don't run two