# Rust

> 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`).

- Skill: `ericrisco/rust` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add ericrisco/rust`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/rust/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/rust

---


# 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<T>`: 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<T>` | `Box` only when heap/indirection/`dyn` is required |
| Shared ownership, single thread | `Rc<T>` | cheap refcount, **not** thread-safe |
| Shared ownership, across threads/await | `Arc<T>` | atomic refcount; the default for async app state |
| Interior mutability, single thread | `RefCell<T>` | runtime borrow check; panics on violation |
| Shared mutable state, async | `Arc<Mutex<T>>` (tokio's) | but prefer a channel if it is really message passing |
| Read-heavy shared state | `Arc<RwLock<T>>` | many readers, rare writer |

Shared async state is `Arc<AppState>` 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<T, E>` +
`?`, 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<Mutex<T>>` 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<Arc<AppState>>,            // shared state, not a global
    Path(id): Path<i64>,                         // {id} parsed and typed
) -> Result<Json<User>, 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<dyn Error>` 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<Mutex<T>>` 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.

