Rust Systems & Services
Covers modern application-layer Rust (edition 2024): CLIs, web services, libraries. Not
/embedded.
Tooling
| Tool | Purpose |
|---|
| Build, dep management, script runner |
| Lint (cargo clippy --workspace --all-targets -- -D warnings
) |
| Formatter () |
| Test runner |
| License + advisory + duplicate-dep checks |
| Find unused dependencies |
- Pin per repo so every contributor and CI uses the same compiler.
- for single-package upgrades. rewrites everything — avoid in PR diffs.
- 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
, reference as
foo = { workspace = true }
in members.
-
Keep the leaf-most crate (
/ 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
with a
purpose doc and
re-exports — one import path per concept, internals free to reorganize without breaking callers.
-
alone does not prove an item is externally reachable. Reachability runs through the re-export graph: a
item inside a private module that is never re-exported is free to change, while the same item surfaced through a
at the crate root is not — even though its containing module stays private. (A
item cannot be re-exported
outside the crate:
on one is
, while
compiles.) Trace the facade before calling a reorganization internal. On a library crate with a published baseline,
settles it mechanically.
-
Defining a or proc macro, or handling paths, process output, or on-disk state? Load
macros-and-os-boundaries.md —
resolution, single-interpolation of
,
precedence, item-name collisions across invocations,
over panic, non-UTF-8
/
, and write-then-rename. These type-check cleanly and fail on a caller's machine.
-
Document public items at the point of exposure. on every public item (purpose, params, return, plus
/
/
/
where they apply);
for modules and crates. Doc examples compile and run under
, so they are regression tests, not decoration. Enforce with
on library crates; see
rustdoc.md.
-
Feature gates must error, never silently degrade. If runtime config requests a capability the binary wasn't compiled with (e.g.
on a non-CUDA build), fail at startup — silent fallback diverges from operator config unnoticed.
-
Centralize lints at the workspace root with
— every member crate inherits the same ruleset, no per-crate
drift:
toml
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
Each member crate opts in with
.
Build Profiles
When tuning Cargo build profiles (release LTO, release-dbg symbols, release-min for distributable binaries) or adding dev-machine speedups (mold linker,
, share-generics), load
build-profiles.md.
Error Handling
Split by crate role:
- Libraries / lower crates: define typed errors with . Consumers can pattern-match.
- Binaries / top-level crates: use with
.context("what was being attempted")
. Human-readable error chains.
- Never return from library APIs — it erases variant information.
- Use liberally. Never or outside tests and . An is acceptable only when the invariant is provably upheld and the message explains why.
- Convert at boundaries: on thiserror variants for auto-conversion; when explicit.
- / in application code for early exits.
- Prefer over panics for any recoverable error. Panics are for programmer bugs (broken invariants), not runtime failures.
- on fallible APIs: annotate functions returning or newtype-wrapped results that callers frequently ignore. Catches 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 ( → ) so an out-of-order call fails to compile instead of erroring at runtime.
Ownership Discipline
- Take over , over in function signatures — accepts more call sites for free.
- Return owned (, ) from constructors and public APIs. Borrow in hot paths where lifetimes are obvious.
- Reach for only when sharing across threads. Single-threaded sharing uses or references.
- when a function sometimes allocates and sometimes borrows (e.g. normalization).
- Rely on lifetime elision. More than one signature needing an explicit 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, , vectored writes): profile first, then load performance.md.
Async with Tokio
- Default runtime: with for apps;
features = ["rt", "macros", "sync"]
for libraries that need to stay slim.
- for independent tasks. for a dynamic group awaited together with cancellation.
- for racing futures (timeouts, cancellation, first-wins).
- Never block the runtime:
tokio::task::spawn_blocking
for sync CPU work or blocking I/O libs.
- only when the guard must be held across . Otherwise is faster.
- when reads dominate writes (config snapshots, route tables, hot caches). Many readers proceed in parallel; serializes them. For snapshot-swap semantics (rarely-updated config), is faster still — no lock on the read path.
- Cancellation: (from ) propagates shutdown. Long-running tasks must check it.
- Backpressure via bounded channels — unbounded channels hide memory growth until OOM.
- 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 shared across spawners.
- Don't mix async runtimes. Pick and stick with it; and don't interop cleanly.
CLI Tools (clap)
- Use the derive API: + . Less boilerplate, types drive the help text.
- One variant per subcommand; flatten shared flags into a
#[command(flatten)] struct CommonArgs
.
- flag on query commands for agent/pipe consumption. Emit via
serde_json::to_string(&value)?
.
- Exit codes: 0 success, 1 for errors returned, 2 for argparse (clap handles this), reserve 3+ for domain meanings documented in .
- Provide automatically via .
See 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 only if an existing codebase uses it.
- Handlers return
Result<impl IntoResponse, AppError>
. Implement for to centralize error → status mapping.
- Validate input at the boundary: where
T: Deserialize + Validate
(use crate). Internal services trust input was validated.
- Share state via — not globals, not .
- Middleware via : tracing → timeout → auth → CORS → handler. Order matters.
- Resilience layers (outbound clients, shared services): combine + for backpressure, not unbounded queueing; full tower stack in production-resilience.md.
See axum-service.md for project layout, extractors, error types, graceful shutdown, and OpenAPI generation.
Concurrency
| Workload | Approach |
|---|
| Independent async I/O | + or |
| Data-parallel CPU work | with |
| Shared mutable state across threads | or , smallest scope possible |
| Single-producer pipelines | (async) or (sync) |
| Broadcast / fan-out | |
and
coexist — use
tokio::task::spawn_blocking
to call a rayon pool from async code. Never call
from inside a tokio task; it deadlocks the runtime.
Testing
- Built-in . Prefer
cargo nextest run --workspace
over — it runs tests in parallel processes with proper isolation.
- Unit tests live in at the bottom of the file (access to private items).
- Integration tests in directory. One file per public surface area.
- for async tests. Add when the code under test spawns tasks.
- for parametrized tests and fixtures. / for property-based tests on pure logic.
- for snapshot testing CLI output, serialization, large structs. Review diffs with .
- + for CLI integration tests (invokes the binary, asserts on stdout/stderr/exit code).
- Assert on error variants with :
assert!(matches!(result.unwrap_err(), MyError::Validation(_)))
— no 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: + 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
skill.
Unsafe Discipline
- Default: no . If clippy flags it, don't it — refactor. The escape hatch below does not apply here; unsafe findings get fixed, not annotated.
- Every block gets a comment above it explaining why each invariant holds. No comment = reviewer rejects.
- Keep blocks minimal — wrap in a safe abstraction at module boundary, mark the module .
- Use () on any crate containing or raw pointer arithmetic — catches UB that optimizers mask.
- Prefer , , over hand-rolled transmutes for zero-copy patterns.
- Env-var writes are in edition 2024. Write them only in , before the runtime starts or any thread spawns. Concurrent is UB; does not make it safe. Watch for lazy -style writes on first use — hoist them to startup.
Production Resilience
When productionizing a service (config validation,
+
endpoints, graceful shutdown, retries/timeouts/jitter, deny-by-default fallback when the call is the security decision, connection pools, diagnostic secret redaction), load
production-resilience.md.
Observability
For logging (
+
with init recipe),
spans, correlation IDs, metrics, and distributed tracing patterns, load
observability.md. Never use
or
in new code.
CI
General CI design lives with the
ia-infrastructure-engineer
agent. For Rust-specific callouts (
,
,
,
, matrix coverage guidance, doc-test step), load
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 as a shortcut — fix the underlying issue. When a suppression is genuinely warranted, write
#[expect(clippy::lint_name, reason = "...")]
instead: warns once the lint stops firing, so a suppression that has outlived its cause reports itself, where rots silently forever. ( needs Rust 1.81+; edition 2024 clears that floor.)
- 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 ) passes with zero failures
- passes (licenses, advisories, duplicates) for any crate going to production
- No new without comment