--- name: rust description: "Use when writing, reviewing, testing, or shipping Rust — ownership and the borrow checker (move/borrow/clone, Arc/RefCell, lifetimes), errors with Result/`?`/thiserror/anyhow, async on tokio, axum 0.8 services, cargo test, and sqlx + cargo-audit hardening. NOT the same service in Go (that is `go`), NOT a desktop webview shell (that is `tauri`)." tags: [rust, tokio, axum, async, backend, service] recommends: [go, postgresdb, secure-coding, deployment] origin: risco --- # Idiomatic Rust services Write, review, test, and ship idiomatic async Rust services with the ownership model working *for* you, not against you. Targets **Rust 1.85+ / edition 2024** as the floor: native `async fn` in traits (no reflexive `#[async_trait]`), **tokio 1.x** as the runtime, **axum 0.8** for the HTTP surface (`{id}` path-capture syntax, async-trait-free extractors), **thiserror 2** for library error enums and **anyhow** at the application edge, **sqlx** for compile-time-checked SQL, and **tracing** for structured observability. The thing an agent gets wrong in Rust is almost never syntax — it is *ownership*. Most "bugs" are compile errors about moves, borrows, and `Send + Sync` across `.await`. Front-load that mental model; the rest follows. ## Ownership & borrowing (essentials) This is the skill's center of gravity. Three moves: **move** (transfer ownership), **borrow** (`&`/`&mut`, no transfer), **clone** (a real copy, real cost) — and prefer them in that order, borrow first. Take `&str`/`&[T]` in function params, return owned `String`/`Vec`: borrow on the way in, own on the way out is both the most flexible and the cheapest. ```rust fn print_name(name: &str) { println!("{name}"); } // borrows; caller keeps ownership let s = String::from("ada"); print_name(&s); // Good: lend a reference println!("{s}"); // still usable // Bad: takes by value, moves it, then the caller can't use `s` anymore. fn consume(name: String) { /* ... */ } consume(s); // println!("{s}"); // error[E0382]: borrow of moved value: `s` ``` The four borrow-checker errors you will actually hit, with the fix: ```rust // 1. "value moved here" (E0382): you used a value after moving it. // Fix: borrow instead of move, or .clone() only if you genuinely need two owners. let v = vec![1, 2, 3]; let first = &v[0]; // Good: borrow // let taken = v; let _ = first; // Bad: moves v while `first` borrows it. // 2. "cannot borrow as mutable more than once" (E0499): two &mut alive at once. // Fix: scope the first borrow so it ends before the second begins. let mut data = vec![1, 2, 3]; { let a = &mut data; a.push(4); } // borrow ends here let b = &mut data; b.push(5); // Good: non-overlapping // 3. "cannot borrow as mutable, already borrowed as immutable" (E0502). // Fix: don't hold a shared ref across a mutation; collect indices first, mutate after. // 4. "does not live long enough" (E0597): a reference outlives the value it points to. // Fix: return an owned value, or restructure so the owner outlives the borrow. ``` Shared state: pick the smallest tool that fits. Decision table — | Need | Use | Why | | --- | --- | --- | | One owner, sized value | the value, or `Box` | `Box` only when heap/indirection/`dyn` is required | | Shared ownership, single thread | `Rc` | cheap refcount, **not** thread-safe | | Shared ownership, across threads/await | `Arc` | atomic refcount; the default for async app state | | Interior mutability, single thread | `RefCell` | runtime borrow check; panics on violation | | Shared mutable state, async | `Arc>` (tokio's) | but prefer a channel if it is really message passing | | Read-heavy shared state | `Arc>` | many readers, rare writer | Shared async state is `Arc` injected through axum `State` — never a global `static mut`. Lifetimes, `'static`, `Cow`, and the full smart-pointer tree -> `references/ownership.md`. ## Errors Error modeling is owned here, in Rust terms — it is not a separate skill. The model: `Result` + `?`, typed enums for libraries, `anyhow` at the edge, one mapping from a domain enum to an HTTP status. ```rust use thiserror::Error; // Library / domain layer: a typed enum callers can match on. #[from] gives free `?` conversion. #[derive(Debug, Error)] pub enum UserError { #[error("user {0} not found")] NotFound(i64), #[error("database error")] Db(#[from] sqlx::Error), // any sqlx::Error becomes UserError::Db via `?` } ``` ```rust // Application edge: anyhow when you just need context, not a match. use anyhow::Context; let config = std::fs::read_to_string(path) .with_context(|| format!("reading config at {path}"))?; // adds a human breadcrumb ``` **The 3-layer flow (twin of go's).** Repository returns the typed domain error; service propagates with `?`; the handler maps the enum to a status *once*, via `IntoResponse` — pattern-match the variant, never string-match the message, and log only the unexpected one (no internal leak to the client). ```rust use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; use serde_json::json; impl IntoResponse for UserError { fn into_response(self) -> Response { let status = match self { UserError::NotFound(_) => StatusCode::NOT_FOUND, // 404 UserError::Db(ref e) => { // 500 tracing::error!(error = %e, "unexpected db error"); // log here, not to the client StatusCode::INTERNAL_SERVER_ERROR } }; (status, Json(json!({ "error": self.to_string() }))).into_response() } } ``` Full repo->service->handler skeleton -> `references/axum-service.md`. ## Async (tokio, essentials) `#[tokio::main]` boots the multi-thread runtime; futures do nothing until `.await`. Bound your fan-out: ```rust use tokio::task::JoinSet; let mut set = JoinSet::new(); for id in ids { // Good: a JoinSet you can drain and cap set.spawn(async move { fetch(id).await }); } let mut out = Vec::new(); while let Some(res) = set.join_next().await { out.push(res??); // join error, then task error } ``` The two pitfalls that bite agents, with the fix: ```rust // Bad: std Mutex guard held across .await -> "future cannot be sent between threads safely". let guard = state.lock().unwrap(); do_io().await; // guard is still alive here -> not Send guard.update(); // Good: drop the lock before awaiting, or use tokio::sync::Mutex if the lock must span the await. { let mut g = state.lock().unwrap(); g.update(); } // guard dropped here do_io().await; // nothing non-Send is held across the await ``` ```rust // Bad: a CPU-bound parse on the async worker thread starves every other task. let parsed = heavy_parse(&blob); // blocks the executor // Good: move blocking/CPU work off the runtime. let parsed = tokio::task::spawn_blocking(move || heavy_parse(&blob)).await?; ``` `select!` races futures (handle a cancellation token in one arm); `tokio::sync::mpsc` for message passing — prefer a channel over `Arc>` when the data flows one way. Cancellation, a bounded-concurrency + jittered-retry helper (ctx-aware, never retries a 4xx), and the full `Send + Sync` rules -> `references/async-tokio.md`. ## Service (axum, essentials) axum 0.8: `{id}` capture in the path, `Path`/`State`/`Json` extractors, your error enum as the return: ```rust use axum::{extract::{Path, State}, routing::get, Router, Json}; use std::sync::Arc; async fn get_user( State(app): State>, // shared state, not a global Path(id): Path, // {id} parsed and typed ) -> Result, UserError> { // UserError: IntoResponse maps it let user = app.users.find(id).await?; // `?` propagates the typed error Ok(Json(user)) } let app = Router::new() .route("/users/{id}", get(get_user)) // 0.8 syntax: {id}, not :id .with_state(state); ``` Validate at the boundary and parse into a typed domain model — "parse, don't validate" makes illegal states unrepresentable, so the handler body never re-checks. Full skeleton — tower middleware (`TraceLayer`, timeout, request-id), graceful shutdown via `axum::serve(...).with_graceful_shutdown(...)`, and JSON helpers -> `references/axum-service.md`. ## Project layout Keep the binary thin; put logic in the library so tests and integration tests can reach it. ```text my-service/ Cargo.toml # [dependencies], [profile.release], optional [workspace] src/ main.rs # entrypoint: parse config, build state, axum::serve — wiring only lib.rs # pub mod error; pub mod app; pub mod users; — the testable surface error.rs # the thiserror enum + IntoResponse users/ mod.rs # handlers + the domain model repo.rs # sqlx queries tests/ users_api.rs # integration tests that spin up the Router ``` A larger system becomes a Cargo **workspace** (`[workspace] members = [...]`) with one crate per bounded context. Gate optional deps behind `[features]`. The `lib.rs` carries `#![forbid(unsafe_code)]`. ## Testing (essentials) `#[test]` for sync, `#[tokio::test]` for async; integration tests under `tests/` exercise the real `Router`; doctests keep examples honest. ```rust #[tokio::test] async fn get_user_404_when_missing() { let app = build_router(test_state()); // the same Router main builds let res = app .oneshot(Request::get("/users/999").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } ``` Use `cargo nextest run` for faster, cleaner parallel runs; `cargo test --doc` for doctests. Trait-based fakes (a `UserRepo` trait the handler depends on, a fake impl in tests) keep the DB out of unit tests. Integration matrices, `insta` snapshots, and the full `tests/` HTTP setup -> `references/testing.md`. ## Security (embedded) Parametrize SQL, forbid unsafe, audit dependencies, read secrets from the environment: ```rust // Good: bound parameters; sqlx checks the query at compile time against the DB schema. sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = $1", id).fetch_one(&pool).await?; // Bad: format! into SQL is injection, full stop. // sqlx::query(&format!("SELECT * FROM users WHERE id = {id}")).fetch_one(&pool).await?; ``` `#![forbid(unsafe_code)]` at the crate root; run `cargo audit` (RustSec advisories) and `cargo deny` (license + ban + advisory policy) in CI; never `.unwrap()` on untrusted input — a malicious request becomes a panic. Read secrets from env or a secret manager, never hardcode or log them. Deeper authz / threat modeling -> [`secure-coding`](../secure-coding/SKILL.md). Pure SQL schema/index/plan tuning -> [`postgresdb`](../postgresdb/SKILL.md); this skill covers only the Rust-side sqlx query. ## Production Structured logs and a lean release binary: ```rust // JSON tracing subscriber, level from RUST_LOG; do this once in main before serving. tracing_subscriber::fmt().json().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).init(); ``` ```toml [profile.release] lto = true # link-time optimization: smaller, faster binary codegen-units = 1 # better optimization at the cost of compile time panic = "abort" # no unwinding in prod; smaller binary, fail fast strip = true # strip symbols ``` Expose `/healthz` (static 200 liveness) and `/readyz` (pings the DB pool, 503 on failure). Docker: multi-stage build, `cargo build --release`, copy the binary onto a distroless/slim base. Full Containerfile + CI -> [`deployment`](../deployment/SKILL.md). ## Anti-patterns | Anti-pattern | Reality / Do instead | | --- | --- | | `.clone()` to make the borrow checker happy | It hides the real ownership question; borrow, or restructure who owns what. | | `.unwrap()` / `.expect()` off the test path | A panic on the request path is a 500 or a crashed worker; use `?` + a typed error. | | Matching an error by its message string | Messages are prose and they change; match the enum variant. | | `Box` everywhere because it is simpler | Nothing can branch on the failure; use a `thiserror` enum the caller can match. | | `#[async_trait]` on every async trait | Edition 2024 has native async fn in traits; drop the macro for most cases. | | `block_on` inside an async fn | Nesting a runtime panics or deadlocks; restructure to `.await`. | | A bare `tokio::spawn` per loop iteration | Unbounded fan-out exhausts the runtime; bound it with `JoinSet`/semaphore. | | `Arc>` for everything shared | If data flows one way it is a channel; reach for `mpsc` first. | | `unsafe` to get past the borrow checker | `unsafe` turns a compile error into UB; the checker was right — restructure. | | Skipping clippy as "just style" | clippy catches correctness (`.unwrap()` on `Option`, await-holds-lock); gate on `-D warnings`. | ## Gates & commands | Task | Command | | --- | --- | | Format check | `cargo fmt --all -- --check` | | Lint (gate) | `cargo clippy --all-targets -- -D warnings` | | Test | `cargo test` / `cargo nextest run` | | Doctests | `cargo test --doc` | | Audit deps | `cargo audit` / `cargo deny check` | | Local gate | `./scripts/verify.sh` (run in your crate root) | Format and lint are build gates, not suggestions. ## Project grounding (02-DOCS) In a project with a `02-DOCS/` layer (the [`harness`](../harness/SKILL.md) wiki), the service decisions live in `02-DOCS/wiki/stack/rust.md`, indexed from `02-DOCS/wiki/index.md`. Read it first and stay consistent; if it is missing or stale, write the project's real choices there — crate/workspace layout, runtime (tokio), HTTP framework (axum 0.8), error strategy (thiserror enum + `IntoResponse` mapping), DB layer (sqlx + pool), tracing and concurrency defaults — bump its `Updated` date, and index it. No `02-DOCS/`? Skip silently. Conventions are *recorded, not gated* — never block the task on this. [`go`](../go/SKILL.md) is the structural twin: same write/review/test/ship service shape, GC + goroutines + multi-return errors instead of ownership + futures + `Result`. A desktop shell around a webview is [`tauri`](../tauri/SKILL.md), not this.