rust-dev
Practical day-1 guide to building applications in Rust well. Covers the mental model (ownership, errors as values, traits-not-interfaces), day-1 decisions (String vs &str, Box vs Rc vs Arc, dyn vs impl Trait, anyhow vs thiserror), idioms, anti-patterns, and a tight crate shortlis
Install
npx skills add https://github.com/tenequm/skills/tree/main/skills/rust-dev
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install tenequm-skills@llmmart
git clone https://github.com/tenequm/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole tenequm/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Rust Development - Day 1
A practical foundation for writing Rust apps well from the first commit. Not a textbook. Focuses on the differences from other languages, the day-1 decisions that shape everything else, and the small set of crates that cover most real apps.
When to Use
- Starting a new Rust project (CLI, service, library)
- Coming to Rust from Python, JavaScript, Go, Java/C#, or C++
- Choosing between owned/borrowed types, smart pointers, trait objects vs generics
- Picking error handling strategy (
anyhowvsthiserror) - Deciding which crates to reach for
- Configuring a minimal but opinionated
Cargo.toml, clippy, and rustfmt
Day-1 Setup
# 1. Install the toolchain (rustup is the toolchain manager)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 2. Confirm components (rustfmt and clippy ship with stable, rust-src enables IDE features)
rustup component add rustfmt clippy rust-src
# 3. Create a project
cargo new my-app # binary (src/main.rs)
cargo new --lib my-lib # library (src/lib.rs)
# 4. The dev loop (memorize these four)
cargo check # fast type-check, no codegen
cargo run # build and run (binary)
cargo test # build and run tests (incl. doctests)
cargo clippy # lint (run before pushing)
cargo fmt # format
# 5. Manage dependencies without editing Cargo.toml by hand
cargo add tokio --features full
cargo remove tokio
cargo update # recompute Cargo.lock within existing semver ranges
cargo update only moves within the version ranges already in Cargo.toml. Crossing a major version (1.x to 2.0) needs a Cargo.toml edit or cargo add <crate>@2.
rust-analyzer is mandatory. It is the language server every editor uses (VS Code, Zed, Neovim, Helix, RustRover uses its own engine but is comparable). In VS Code, install the rust-analyzer extension and set rust-analyzer.check.command to "clippy" so you get lint feedback on save.
Want a file watcher later? cargo install --locked bacon, then run bacon in your project. Not needed on day 1.
The Rust Mental Model in 5 Ideas
Rust trades two things you take for granted in most languages (a garbage collector and exceptions) for compile-time guarantees about memory, data races, and error handling. The shape of the language follows from that trade.
1. Ownership: every value has exactly one owner
Think of values like physical objects. A book, a file, a network connection. At any moment, one variable owns it. You can:
- Move it:
let b = a;hands ownership tob.ais gone. - Borrow it immutably:
&alets others look at it. Many readers allowed. - Borrow it mutably:
&mut alets one person modify it. Exclusive access. - Clone it:
a.clone()makes a deep copy. Both keep their own.
When the owner goes out of scope, the value is dropped (memory freed, file closed, lock released). No GC, no manual free. This is RAII, enforced by the compiler.
2. Aliasing XOR mutability
At any moment, a piece of data has either:
- one mutable reference (
&mut T), or - any number of immutable references (
&T),
never both. This single rule is what eliminates data races and most use-after-free bugs. The borrow checker enforces it. When it complains, it is telling you your data ownership story is unclear, not that the language is being difficult.
3. Errors are values, not exceptions
There is no try/catch. Functions that can fail return Result<T, E>. Functions that can return nothing useful return Option<T>. The compiler forces you to handle both. The ? operator propagates errors up the call stack with one character:
fn read_config() -> Result<Config, anyhow::Error> {
let text = std::fs::read_to_string("config.toml")?; // ? = early-return on Err
let config = toml::from_str(&text)?;
Ok(config)
}
There is no null. Option<T> is None or Some(value). The compiler will not let you forget the None case.
4. Traits are not Java interfaces
A trait defines behavior. Types impl traits. So far so familiar. The differences:
- Static dispatch is the default. When you write
fn f<T: Display>(x: T), the compiler generates a separate copy offfor each concreteTyou call it with (monomorphization, like C++ templates). Zero runtime overhead. - Dynamic dispatch is opt-in via
dyn Trait(typicallyBox<dyn Trait>or&dyn Trait). One vtable lookup per call. - No inheritance. Traits compose. If you find yourself reaching for
Derefto "extend" a type, stop and use composition or an enum. - Orphan rule: you can
impl YourTrait for SomeoneElsesTypeorimpl SomeoneElsesTrait for YourType, but not both foreign. This keeps dependency resolution sane.
5. The borrow checker is a design oracle
The most common newcomer mistake is treating compiler errors as obstacles to silence. They are not. Almost every borrow-check error reveals a real issue with who owns what. When you get stuck, the question is rarely "how do I make this compile" and almost always "what is the actual ownership relationship I want here?" Read the error. The compiler is unusually informative.
The 3 Questions for Every Function Signature
Before writing a function, ask: does it need to own, read, or modify the input?
fn consume(s: String) // owns: function takes responsibility, caller loses it
fn read(s: &str) // reads: function looks at it, caller keeps it
fn modify(s: &mut String) // mutates: function changes it in place
Defaults that work 90% of the time:
- Function parameters: prefer
&stroverString,&[T]overVec<T>(these are slices, accept both owned and borrowed callers). - Function returns: return owned types (
String,Vec<T>). Returning references means lifetimes; avoid until you need them. - Struct fields: prefer owned types (
String,Vec<T>). Storing&strin a struct is the single most common newcomer trap and it cascades lifetime annotations through every type that holds your struct.
Day-1 Decision Table
One-line answers to the choices that come up first.
| Decision | Default | When to pick the other |
|---|---|---|
String vs &str (struct field) |
String |
Almost never &str until you have a real reason and understand lifetimes |
String vs &str (function param) |
&str |
Use String only if you must own/store it inside |
Vec<T> vs &[T] (param) |
&[T] |
Vec<T> only if you must own |
Box<T> vs Rc<T> vs Arc<T> |
Box<T> (single owner, heap) |
Arc<T> for shared ownership across threads. Avoid Rc<T> as default; use Arc<T> so you do not refactor when you go async |
RefCell<T> vs Mutex<T> |
Mutex<T> (or RwLock<T>) |
Same reason: works in async/threads, while RefCell does not |
Option<T> vs Result<T, E> |
Option<T> for "no value", Result<T, E> for "failed for a reason" |
If the absence carries meaning the caller should handle, Result |
dyn Trait vs impl Trait / <T: Trait> |
Generic (<T: Trait> or impl Trait) - static dispatch |
Box<dyn Trait> when you need a heterogeneous collection (Vec<Box<dyn Animal>>) |
| Errors in app code | anyhow::Result<T> everywhere |
- |
| Errors in library code | thiserror-derived enum |
Never Box<dyn Error> in public library APIs - forces callers to downcast |
&self vs &mut self vs self |
&self for getters, &mut self for setters, self for builders/consuming ops |
- |
| Module layout | Inline modules until a file gets long, then split | One module = one file is a Java/C# instinct, not a Rust one |
Idioms to Internalize Early
These appear in nearly every Rust program. Learn them in week 1.
? for error propagation. Replaces nine lines of match with one character.
let body = reqwest::get(url).await?.text().await?;
Iterator chains over manual loops. Compile to the same machine code as hand-written loops (LLVM inlines closures). Idiomatic Rust is functional in style.
let active_emails: Vec<String> = users
.iter()
.filter(|u| u.active)
.map(|u| u.email.clone())
.collect();
match exhaustiveness. Add a new variant to an enum and every match that does not handle it becomes a compile error. Use this. It is one of the most powerful refactoring tools in any language.
Let the compiler drive refactors. The same trick works on structs: add a mandatory field with no Default and every existing constructor becomes a compile error - a free, exhaustive worklist of every site to update.
if let and let else for the common single-arm match.
if let Some(name) = user.name { println!("hi {name}"); }
let Some(name) = user.name else { return Err(anyhow!("no name")); };
// `name` is in scope from here on, no nesting
From / Into for type conversions. Implement From, get Into for free. ? uses From to convert error types automatically.
Combinators on Option / Result. Reach for .map, .and_then, .unwrap_or, .unwrap_or_else, .ok_or before reaching for match.
Derive macros. #[derive(Debug, Clone, PartialEq)] gets you 80% of the boilerplate for free. Add #[derive(Serialize, Deserialize)] for JSON.
Recent sugar. Stabilized features worth knowing, newest first: cfg_select! is a compile-time match over cfg predicates, replacing most uses of the cfg-if crate, and if let guards work on match arms (match x { Some(v) if let Ok(n) = v.parse::<i32>() => ... }) - both 1.95. assert_matches! and debug_assert_matches! (1.96) assert on a pattern rather than equality, which is the natural assertion for an enum. Two older ones you will see constantly and should not mistake for exotic: let-chains (if let Some(x) = a && x > 3 { ... }), stable in edition 2024 since 1.88, and async closures (async || { ... }, with the AsyncFn family of bounds), stable since 1.85.
Coming From X, Here Is What Bites You
From Python or JavaScript:
let b = a;for a heap value (likeString,Vec) moves it.ais no longer usable. Use&ato borrow ora.clone()to copy.- No null.
Option<T>is forced on you. - No exceptions.
Result<T, E>and?. The compiler will not let you ignore errors. - No inheritance. Composition + traits + enums.
- Variables are immutable by default. Add
mutto mutate. Same for references:&vs&mut. - Integer types are explicit and indexing requires
usize. They also overflow differently depending on the build: debug "includes checks for integer overflow that cause your program to panic at runtime", while--releasedrops them and performs "two's complement wrapping". A test suite that passes can still wrap in production, so usechecked_*/saturating_*/wrapping_*where the arithmetic can actually reach the edge, rather than relying on the debug panic to find it. - A
Stringis not indexable.s[0]does not compile - seereferences/ownership-and-types.mdfor what to reach for instead.
From Go:
- Errors as values - same instinct, but use
?instead ofif err != nil. - No
nil.Option<T>. - No GC and no goroutines: ownership + borrowing, async/await with
tokio. The async model is cooperative (awaitis an explicit yield point), not preemptive. interface{}becomes traits. Default to generics for static dispatch;Box<dyn Trait>only when you need it.- Static linking is the default. Binaries are bigger but self-contained.
panic!should be reserved for unrecoverable bugs in app code; do not use it as Go-style "log and continue".
From Java or C#:
- Traits are not interfaces with virtual dispatch by default.
<T: Trait>is monomorphized.dyn Traitis the opt-in dynamic version. - No null references.
Option<T>. - No exceptions.
Result<T, E>and?. - No class inheritance. Use enums for sum types, traits for shared behavior.
- No GC: ownership and borrowing decide lifetimes.
Arc<T>is the closest thing to a Java reference. - Generics are monomorphized, not type-erased.
From C++:
- Like RAII, but the borrow checker enforces it at compile time.
- No copy/move constructors.
Cloneis explicit andCopyis a marker trait for cheap bitwise copies. - No undefined behavior in safe code (in theory).
&is a compile-time-checked borrow, not a raw pointer. Raw pointers exist (*const T,*mut T) but requireunsafeto dereference.- Smart pointers are
Box<T>(unique_ptr),Rc<T>(shared_ptr, single thread),Arc<T>(shared_ptr, thread-safe). - Macros are hygienic. Procedural macros (derive, attribute, function-like) are how
serde,tokio::main, etc. work.
The Crate Shortlist
These cover most real apps. Add them as needed; they are not all required.
| Crate | What it gives you |
|---|---|
serde + serde_json |
Serialization. #[derive(Serialize, Deserialize)] and you are done |
tokio |
Async runtime. #[tokio::main], tokio::spawn, async I/O |
anyhow |
App error type. anyhow::Result<T>, bail!, context() |
thiserror |
Library error enums. #[derive(thiserror::Error)] |
clap |
CLI argument parsing. #[derive(Parser)] and you have a CLI |
reqwest |
HTTP client. Async by default, blocking feature available |
tracing + tracing-subscriber |
Structured logging. The default for any async code (replaces log) |
axum |
Web framework. Built on tokio + hyper + tower. The 2026 default |
sqlx |
Database access. Async, compile-time checked queries. PostgreSQL, MySQL, SQLite |
chrono |
Dates and times. The maintainer announced soft-deprecation in Jan 2026 and recommends jiff for new code. jiff (BurntSushi) is the successor but still pre-1.0 as of September 2026 (0.2.x, with the 1.0 tracking issue open and no date). Pick chrono for serde/sqlx integration today, jiff if you can tolerate pre-1.0 churn |
See references/crate-shortlist.md for one minimal example each.
Top Anti-Patterns to Avoid
These are the mistakes that show up in every newcomer's code review. Avoid them.
- Storing
&str(or any reference) in a struct. Causes lifetime annotations to cascade through every caller. UseStringuntil you have a profiler-backed reason not to. - Reaching for
Rc<RefCell<T>>(orArc<Mutex<T>>) to simulate Python/JS object graphs. First ask whether you need shared mutable state at all - usually plain ownership, or passing data through a channel, is cleaner. When you genuinely do, preferArc<Mutex<T>>overRc<RefCell<T>>so adding threads later is not a refactor. Box<dyn Error>in library public APIs. Forces callers to downcast. Define a typed error enum withthiserror.Box<dyn Error>is acceptable inside a binary, never in a published library..unwrap()and.expect()outside prototypes and tests. Use?and propagate - for anOption,.ok_or_else(|| anyhow!(...))?does the conversion. When a value genuinely cannot be absent, prefer.expect("why it cannot fail")over.unwrap(): the message is the comment that documents the invariant.- Brute-force
.clone()until it compiles. Sometimes cloning is right, but if you are scattering.clone()to silence the borrow checker, the design is wrong. Step back and ask the 3 questions about who owns what. - Trying to inherit via
Deref.Derefis for smart-pointer-like wrappers, not for OOP-style "extends". Use composition. - Reaching for
unsafe. App developers should essentially never need it.unsafedoes not turn off the borrow checker; it lets you do five specific things (deref raw pointers, call unsafe functions, access mutable statics, implement unsafe traits, access union fields) with the contract that you have manually verified the invariants. - Reading untrusted input with
.lines()orread_line. These allocate without bound.BufRead::read_line's own docs warn that "it is possible for an attacker to continuously send bytes without ever sending a newline or EOF" - and.lines()inherits that behavior, since each item is aread_lineunder the hood. Either way a hostile or malformed peer can drive your process out of memory. Bound the read withRead::take(n), and useBufRead::skip_until(stable since 1.83) to discard an over-long line.for line in reader.lines()is the first thing every tutorial teaches and almost none mention this.
What to Defer
You do not need these on day 1. Some you may never need.
- Lifetimes in struct fields. Avoid by using owned types. The day you genuinely need them, you will know.
Pin,Futureinternals, manualpollimpls. Just writeasync fnand.await.unsafeand FFI. Almost never for app code.- Procedural macros. Library author territory.
- Higher-ranked trait bounds (
for<'a>), variance,PhantomData. Expert territory. Cell,OnceCell,LazyLock,MaybeUninit. Reach for these when you have a specific reason.
Minimal Cargo.toml
Single-crate, edition 2024, opinionated lints. Drop into a fresh project.
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[dependencies]
[dev-dependencies]
[profile.release]
lto = "thin"
codegen-units = 1
# =============================================================================
# Lints. Loose-but-helpful: deny obvious bugs, warn on common smells, leave
# room to learn. Upgrade to clippy::pedantic later if you want the full ride.
# =============================================================================
[lints.rust]
# "deny", not "forbid": forbid cannot be lifted by a local #[allow] with a
# SAFETY comment, and that bites on ordinary mmap, not just FFI.
unsafe_code = "deny"
unreachable_pub = "warn"
[lints.clippy]
all = { level = "deny", priority = -1 }
# Idiomatic helpers
# Note: uninlined_format_args moved to clippy::pedantic (allow-by-default)
# during the 1.89/1.90 cycle, so an explicit warn keeps the nudge active.
uninlined_format_args = "warn"
semicolon_if_nothing_returned = "warn"
implicit_clone = "warn"
# Smells in non-prototype code
dbg_macro = "warn"
todo = "warn"
print_stdout = "warn" # use `tracing::info!` instead in real apps
rustfmt.toml
style_edition = "2024"
edition = "2024"
That is enough. rustfmt's defaults are good. Some teams add use_small_heuristics = "Max" to keep more code on single lines. Fancy options like imports_granularity and group_imports are still nightly-only as of September 2026 (rustfmt tracking issues #4991 and #5083).
Run cargo fmt before you start editing (or commit any pre-existing drift on its own) so formatting noise stays out of your diff, and make cargo fmt --check its own CI step.
rust-toolchain.toml (optional but recommended)
Pins the toolchain per-project so everyone on the team uses the same Rust.
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy", "rust-src"]
profile = "minimal"
.gitignore
/target
Commit Cargo.lock. cargo new tracks it, and the Cargo guide's advice is "When in doubt, check Cargo.lock into the version control system". The FAQ deliberately stops short of making that universal - "whether you do is dependent on the needs of your package" - but for an application it is unambiguously right, and committing it is now the ordinary default for libraries too.
Project Structure
my-app/
src/
main.rs # binary entry point: fn main()
lib.rs # OR a library crate root
config.rs # module: declared as `mod config;` in main.rs/lib.rs
api/ # nested module
mod.rs # OR `api.rs` next to api/ folder (2018+ style preferred)
users.rs
tests/ # integration tests (each file is its own crate)
smoke.rs
Cargo.toml
Cargo.lock
rust-toolchain.toml
rustfmt.toml
.gitignore
Inline modules with mod { ... } until a file gets long, then split. Do not pre-split.
Everything is private by default, including to a parent module. mod config; makes the module exist; it does not make anything inside it reachable. That is the source of the most common early "why can't I call this" error, and the fix is a visibility keyword on the item (and on the module, if it is nested): pub exposes it to the outside world, pub(crate) exposes it only within your own crate. pub(crate) is the right default for anything that is not part of a library's published API - it is what lets the unreachable_pub lint in the table below tell you something useful.
The layout above is one crate. The moment you want a second - a shared library plus a CLI, a server plus its client - you need a Cargo workspace, along with the Cargo.toml machinery that goes with growing past a single crate: feature flags, build scripts, and what rust-version actually controls. That is references/project-shape.md.
Learning Path
- The Rust Book (https://doc.rust-lang.org/book/) - canonical, free, current. The interactive Brown University version (https://rust-book.cs.brown.edu/) adds quizzes and visualizations.
- Rustlings (https://github.com/rust-lang/rustlings) - exercises in parallel with The Book.
- 100 Exercises to Learn Rust (https://rust-exercises.com/) - alternative or supplement to Rustlings, slightly newer.
- Rust for Rustaceans (Jon Gjengset) - the post-beginner book. Read after you are comfortable.
- Zero to Production in Rust (Luca Palmieri) - if you are building a backend service. Note: the book uses
actix-webwhileaxumis the 2026 default; the patterns translate cleanly.
For looking up syntax: Rust by Example (https://doc.rust-lang.org/rust-by-example/).
For curated crate recommendations: blessed.rs (https://blessed.rs/crates).
Reference Docs
Detailed material lives in references/. Read each when you hit the topic.
- ownership-and-types.md - ownership, borrowing, lifetimes,
String/&str/Cow, why a string is not indexable, slices,HashMapand theentryAPI, smart pointers, the self-referential struct trap - error-handling.md -
Result,?,anyhowvsthiserrorpatterns, wrapping at the boundary rather than before it, custom error enums, whenpanic!is appropriate - traits-and-generics.md - traits as bounds,
dynvsimpl Traitvs generics, common derives,From/Into/Display/Debug, closures and theFn/FnMut/FnOncefamily, blanket impls, the orphan rule - async-basics.md - threads and
mpscbefore async,tokio,#[tokio::main],.await,Send/Sync, graceful shutdown on SIGTERM, common pitfalls (blocking in async,MutexGuardacross.await) - crate-shortlist.md - minimal usage example for each of the 8 crates above
- project-shape.md - past one crate: workspaces and inherited dependencies,
[workspace.lints], feature flags,build.rs, whatrust-versioncontrols,#[non_exhaustive] - testing.md - what to test and what to skip, pragmatic test organization, keeping the suite fast, the minimal high-value tool kit
- dev-environment.md - the fast build loop, build caching (kache setup and its quirks, sccache, rust-cache), platform-aware linker guidance, CI, file watchers
- releasing.md - shipping a binary:
distvs a hand-rolledrelease-plz+cargo-zigbuildpipeline,[profile.dist], cross-compiling every target from one runner, fanning out to binstall/Homebrew/Nix, and guarding what the published crate contains - performance.md - profiling before optimizing, benchmarking with criterion/divan, the real runtime wins
Files (skills)
-
references
-
async-basics.md 16.6 KB
# Async Basics Rust's async is cooperative: `.await` is an explicit yield point. There is no built-in runtime; you pick one. In 2026, that runtime is `tokio` for almost every application. (If a tutorial hands you `async-std`, stop: it has been discontinued, carries a RustSec advisory for that reason, and its own site still shows no notice. `smol` is the named replacement.) This file covers what you need to write async Rust well from day 1, and the small set of pitfalls that cause most async bugs. ## Threads First, Async Second Before any of this: **async is for I/O concurrency, not for speed.** If your work is CPU-bound - parsing, hashing, image processing, simulation - you want threads, and the standard library already gives you everything you need. Reaching for `tokio` because you want to "use all the cores" is the wrong tool. ```rust use std::thread; use std::sync::mpsc; // Detached-ish: a handle you join to get the result back let h = thread::spawn(|| expensive(1)); // must be 'static - move owned data in let a = h.join().unwrap(); // Result: Err means the thread panicked // Scoped threads: borrow from the stack, guaranteed joined at the end of the scope let data = vec![1, 2, 3]; thread::scope(|s| { s.spawn(|| println!("{:?}", &data)); // &data borrow is fine here s.spawn(|| println!("{}", data.len())); }); // both joined before this line returns // Message passing: the idiomatic way to get results out let (tx, rx) = mpsc::channel(); for id in 0..4 { let tx = tx.clone(); thread::spawn(move || tx.send(work(id)).unwrap()); } drop(tx); // the last sender must drop or rx never ends for result in rx { } // iterates until every sender is gone ``` `thread::scope` is the one worth remembering: it is what lets a thread borrow local data instead of forcing you to `Arc`-wrap everything, because the scope cannot exit until every thread inside it has finished. That `drop(tx)` is the classic hang - a receiver loop ends when all senders are dropped, and the original `tx` you cloned from is a sender. For data parallelism over a collection, do not hand-roll any of this: `rayon`'s `.par_iter()` is one word and covers most of it (see `performance.md`). ## Mental Model An `async fn` does not run when called. It returns a `Future`, which is a state machine. A runtime (`tokio`) drives futures by polling them; when a poll hits a point that needs to wait (network I/O, timer, channel receive), the future returns "not ready" and the runtime parks it until the underlying event fires. ```rust async fn add(a: i32, b: i32) -> i32 { a + b } let f = add(2, 3); // f is a Future, nothing has run yet let n = f.await; // runtime drives f to completion; n == 5 ``` `.await` only works inside `async fn` or `async {}` blocks. ## The Minimum You Need ```rust // Cargo.toml // [dependencies] // tokio = { version = "1", features = ["full"] } #[tokio::main] async fn main() -> anyhow::Result<()> { let body = reqwest::get("https://example.com").await?.text().await?; println!("{}", body.len()); Ok(()) } ``` `#[tokio::main]` is a macro that wraps `main` with the runtime setup. For tests, use `#[tokio::test]`. ```rust #[tokio::test] async fn it_works() { assert_eq!(add(2, 3).await, 5); } ``` ## `tokio` Features `tokio = { version = "1", features = ["full"] }` is fine while learning. In production, narrow the feature list: ```toml tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "fs", "sync", "time"] } ``` Common ones: - `macros` - `#[tokio::main]`, `#[tokio::test]`, `tokio::select!`, `tokio::join!` - `rt-multi-thread` - the default work-stealing runtime - `rt` - single-threaded runtime (use this in WASM, embedded, or to avoid `Send` bounds) - `net` - TCP/UDP - `fs` - async filesystem - `sync` - `Mutex`, `RwLock`, `mpsc`, `broadcast`, `oneshot`, `Notify` - `time` - `sleep`, `interval`, `timeout` - `process` - spawning subprocesses - `signal` - Ctrl-C, SIGTERM handling ## Spawning Tasks `tokio::spawn` puts a future on the runtime so it can run concurrently with the current task. ```rust use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { let handle = tokio::spawn(async { sleep(Duration::from_secs(1)).await; 42 }); let result = handle.await.unwrap(); // wait for the spawned task println!("{result}"); } ``` `spawn` returns a `JoinHandle<T>`. Awaiting it gives you the task's return value (wrapped in `Result` to handle panic). ## `Send`, `Sync`, and `'static` Bounds Tasks spawned with `tokio::spawn` must be `Send + 'static` because the runtime moves them across threads. - `Send` means "safe to move to another thread." - `Sync` means "safe to share by reference (`&T`) across threads." - `'static` means "owns all its data; does not borrow from the spawning function's stack." If you see `error: future cannot be sent between threads safely`, you have likely: - Held a non-`Send` type across an `.await` (e.g., `Rc<T>`, `RefCell<T>`, `std::sync::MutexGuard`). - Captured a reference instead of moving owned data into the task. The most common fix: replace `Rc` with `Arc`, `RefCell` with `Mutex` (and check the next pitfall), and `move` the closure body into the task. ```rust let data = Arc::new(some_data); tokio::spawn({ let data = Arc::clone(&data); // clone Arc, move into task async move { process(&data).await; } }); ``` ## The `MutexGuard` Across `.await` Pitfall Holding a synchronous `std::sync::MutexGuard` across an `.await` is wrong: 1. The guard is not `Send`, so the future is not `Send`, so `tokio::spawn` rejects it. 2. Even when it compiles, you can deadlock: the task yields while still holding the lock, and another task waiting for the lock blocks the runtime thread. Two fixes, depending on need: **A. Drop the guard before the await:** ```rust use std::sync::Mutex; let state = Arc::new(Mutex::new(Counter::new())); let snapshot = { let g = state.lock().unwrap(); g.snapshot() // get the data we need }; // guard dropped here do_async_thing(snapshot).await; ``` **B. Use `tokio::sync::Mutex` (async-aware):** ```rust use tokio::sync::Mutex; let state = Arc::new(Mutex::new(Counter::new())); let mut g = state.lock().await; // this await is OK do_async_thing(&mut g).await; // holding the lock across await is OK ``` `tokio::sync::Mutex` is slower than `std::sync::Mutex`. Default to `std::sync::Mutex` and scope your locks tightly. Reach for `tokio::sync::Mutex` only when you genuinely need to hold a lock across awaits. **Two different problems wear the same name.** Holding a `std::sync::MutexGuard` across `.await` is a *compile error* (the future stops being `Send`) - that one is not a judgment call, just fix it. Holding a `tokio::sync::Mutex` across `.await` compiles fine, and whether it is wrong is a *design* question. "Never hold a lock across an await" is repeated as though it settled both, and it does not. The clearest case where holding it is exactly right is **single-flight**: guarding an expensive one-time load (a model, a connection pool, a parsed index) so that N concurrent cold-start callers coalesce into one load instead of N. ```rust use tokio::sync::Mutex; // Concurrent first-callers all block here; exactly one does the load. let mut slot = cache.lock().await; if slot.is_none() { *slot = Some(expensive_load().await); // lock held across .await, deliberately } ``` Drop the lock and each caller kicks off its own `expensive_load()`. Judge it by the numbers, not the slogan: an uncontended `tokio::sync::Mutex` acquire is tens to hundreds of nanoseconds, so if the work it guards is milliseconds long, the warm-path cost is noise and the coalescing is the whole point. If the warm path is genuinely hot, keep the load gate but serve warm reads without the lock (an `arc_swap::ArcSwapOption` read plus a `Mutex<()>` held only during the load). ## Don't Block the Runtime Async runtimes assume tasks yield quickly. CPU-bound work (parsing big files, encoding video, expensive computations) starves other tasks. Two escapes: **`tokio::task::spawn_blocking`** for synchronous, CPU-heavy work: ```rust let result = tokio::task::spawn_blocking(|| expensive_computation()).await?; ``` **`tokio::task::block_in_place`** runs a blocking section inside the current async task without starving sibling tasks. It **panics on a `current_thread` runtime** - that is the constraint to remember. (Outside any runtime it is simply allowed, and just calls the closure normally, so a helper using it still works in a plain sync test.) It also suspends any other code running concurrently in the same task, e.g. under `join!`. Prefer `spawn_blocking`; reach for `block_in_place` only when the blocking work genuinely cannot move into its own task: ```rust tokio::task::block_in_place(|| do_blocking_thing()); ``` Never call `std::thread::sleep` in async code. Use `tokio::time::sleep`. Never call blocking I/O (`std::fs::read`, `std::net::TcpStream`) on a runtime thread. Use `tokio::fs`, `tokio::net`, or wrap with `spawn_blocking`. One caveat on `tokio::fs`: it is not true async I/O. Most operating systems have no async file API, so tokio runs ordinary blocking `std::fs` operations on the `spawn_blocking` pool behind the scenes. That keeps the runtime thread unblocked, but every call carries `spawn_blocking` overhead - for many small reads it can be far slower than plain blocking I/O. Batch file work: read a whole file in one call, or do a sequence of filesystem operations inside a single `spawn_blocking`, rather than issuing hundreds of separate `tokio::fs` calls. ## Concurrency: `select!`, `join!`, `try_join!` Run multiple futures concurrently in the same task. ```rust use tokio::{join, try_join, select}; // Run both, wait for both let (a, b) = join!(fetch_user(1), fetch_user(2)); // Run both, wait for both, short-circuit on error let (a, b) = try_join!(fetch_user(1), fetch_user(2))?; // Run both, take whichever finishes first select! { a = fetch_user(1) => println!("got {a:?}"), _ = tokio::time::sleep(Duration::from_secs(5)) => println!("timeout"), } ``` For dynamic-sized concurrency, use `futures::future::join_all` or `tokio::task::JoinSet`: ```rust use tokio::task::JoinSet; let mut set = JoinSet::new(); for id in 0..100 { set.spawn(fetch_user(id)); } while let Some(res) = set.join_next().await { println!("{:?}", res?); } ``` ## Cancellation Dropping a future cancels it. The future stops being polled at its next `.await` point. There is no "cancellation token" by default; structuring code so that dropping is a clean shutdown is the idiom. For explicit cancellation across many tasks: `tokio_util::sync::CancellationToken`. For timeouts: `tokio::time::timeout`: ```rust use tokio::time::{timeout, Duration}; match timeout(Duration::from_secs(5), slow_op()).await { Ok(Ok(value)) => { /* success */ } Ok(Err(e)) => { /* slow_op returned Err */ } Err(_) => { /* timed out */ } } ``` ### Handle SIGTERM, not just Ctrl-C Almost every shutdown snippet online awaits `tokio::signal::ctrl_c()` and stops there. That is SIGINT only. Docker, Kubernetes, and systemd all stop a process with **SIGTERM** first and SIGKILL after a grace period - so a `ctrl_c()`-only service is hard-killed on every ordinary deploy, mid-request, and you will never see it locally because Ctrl-C in your terminal works perfectly. ```rust use tokio::signal::unix::{signal, SignalKind}; async fn shutdown_signal() { let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); tokio::select! { _ = tokio::signal::ctrl_c() => {} _ = term.recv() => {} } } ``` Two things that bite after you fix that. A server's `with_graceful_shutdown` waits for **all** connections to close, so one long-lived streaming connection (SSE, a websocket, a follow-style tail) holds shutdown open forever - cancel that stream's own token as well, and put a bounded `timeout` around the drain as a backstop. And check that the handler is actually installed in the shipped artifact: on Linux, `grep SigCgt /proc/<pid>/status` tells you which signals the process is catching, which is how you find out that the binary in the container is not the one you tested. ## Channels (`tokio::sync`) | Channel | Use | |---|---| | `mpsc` | Many producers, one consumer (work queue, command bus) | | `oneshot` | Single send, single receive (request/response) | | `broadcast` | Many producers, many consumers (each receiver gets every message; lossy if slow) | | `watch` | Many producers, many consumers (each receiver gets only the latest value) | ```rust use tokio::sync::mpsc; let (tx, mut rx) = mpsc::channel::<String>(32); tokio::spawn(async move { while let Some(msg) = rx.recv().await { println!("got: {msg}"); } }); tx.send("hi".into()).await?; ``` ## Async in Traits Native async functions in traits stabilized in Rust 1.75. They work for cases that do not need `dyn Trait`: ```rust trait Greeter { async fn greet(&self) -> String; } ``` Limitations: an `async fn` in a trait cannot be used as `dyn Trait` directly without help. For that, the `async-trait` crate is still common: ```toml async-trait = "0.1" ``` ```rust #[async_trait::async_trait] pub trait Greeter { async fn greet(&self) -> String; } ``` `async-trait` boxes the future, which costs an allocation per call but lets you use `dyn Greeter`. For most app code, this is fine. Library authors writing performance-critical traits often avoid `async-trait` and use the native form with `impl Future` returns. ## Common Pitfalls 1. **Forgetting `.await`**: returns a `Future` that does nothing. 2. **Holding a `std::sync::MutexGuard` across `.await`**: see above. 3. **Calling blocking I/O in an async function**: use `spawn_blocking` or the async equivalent (`tokio::fs`). 4. **Spawning tasks that borrow from the parent**: `tokio::spawn` requires `'static`. Move owned data in (often via `Arc::clone`). 5. **`select!` arms with side effects**: when one arm completes, the others are dropped (cancelled). Make sure that is safe for the operations involved. 6. **Forgetting that `async fn` returns immediately**: until you `.await` or `spawn`, no work happens. 7. **One giant `tokio::main` task with no concurrency**: if your `main` is awaiting things sequentially, you may not need async at all. Async pays off when you have concurrent I/O. 8. **Assuming `impl Stream` means the body streams.** The signature promises an incremental *type*, not incremental *behavior* - a function returning `impl Stream` can happily read an entire file into a `String`, parse every record into a `Vec`, and only then yield item one. Nothing in the type system catches that. If a stream exists to bound memory, check that its body never materializes the whole input; the same trap hides inside `async_stream::stream!` blocks, where a plain `std::fs` call also blocks a runtime thread for the whole operation. ## Testing Time-Dependent Async Code `tokio::time::pause` (and `#[tokio::test(start_paused = true)]`, which needs the `test-util` feature and the current-thread runtime) lets a test advance the clock instantly instead of sleeping. There is a precondition nobody mentions until it bites: **it can only control `tokio::time::Instant`, not `std::time::Instant`.** If your cache expiry, rate limiter, or backoff computes deadlines from `std::time::Instant::now()`, a paused test has no effect on it and you are back to real sleeps. The fix is in the *production* code, not the test: use `tokio::time::Instant` there. Outside a paused runtime it is `std::time::Instant::now()`, so behavior is identical - you are only buying testability. Inside the test, advance explicitly with `tokio::time::advance()` rather than `sleep`, because a paused runtime auto-advances whenever it goes idle and a `sleep` will not mean what you think it means. ## Debugging a Running Runtime When a service is stalling and the profiler shows nothing hot, the problem is usually a task that is not being polled rather than one burning CPU. `tokio-console` is the debugger for exactly that: it attaches over a `tracing` subscriber and shows live per-task state, poll counts, busy versus idle time, and warnings for tasks that have blocked the runtime. It needs the `tokio_unstable` cfg flag set at build time, which is why it is a deliberate step rather than something you leave on. ## What to Defer - Manual `Future` impls and `Pin`. Almost no app code needs this. - `poll_*` methods on lower-level traits (`AsyncRead`, `AsyncWrite`). - Custom executors. Use `tokio` until you have a proven reason not to. - `tokio` internals (`tokio-uring`, `LocalSet`, custom schedulers). Only relevant for advanced cases. -
crate-shortlist.md 16.2 KB
# Crate Shortlist The handful of crates that show up in almost every Rust application. One minimal example each. None are required; pull in as you need them. For a curated wider catalog, see [blessed.rs](https://blessed.rs/crates). ## `serde` and `serde_json` Serialization. Derive macros do everything. ```toml serde = { version = "1", features = ["derive"] } serde_json = "1" ``` ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] struct User { id: u64, email: String, } let json = r#"{"id": 1, "email": "a@b.com"}"#; let user: User = serde_json::from_str(json)?; let back = serde_json::to_string_pretty(&user)?; ``` Other formats: `toml`, `serde_qs`, `rmp-serde` (MessagePack). Same derive, different crate. Two names you will find in older guides that you should not reach for now: - **`serde_yaml` is unmaintained.** Its repository is archived and the last release is `0.9.34+deprecated` (March 2024), with no official successor named. `serde_yaml_ng` and `serde_yml` are community continuations; evaluate them rather than assuming. - **`bincode` 3.0.0 is a tombstone.** Development stopped after a doxxing and harassment incident, and the entire contents of 3.0.0's `src/lib.rs` is `compile_error!("https://xkcd.com/2347/")` - so adding `bincode = "3"` does not fail at runtime, it fails to compile. `2.0.1` is the last usable release. For a new binary format, upstream points at `postcard` or `rkyv`. Common attributes: ```rust #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] // userId on the wire, user_id in Rust struct Payload { user_id: u64, #[serde(default)] // missing field uses Default::default() tags: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] note: Option<String>, } ``` ## `tokio` Async runtime. The default. See `async-basics.md` for the deep dive. ```toml tokio = { version = "1", features = ["full"] } ``` ```rust #[tokio::main] async fn main() -> anyhow::Result<()> { let sleep = tokio::time::sleep(std::time::Duration::from_millis(100)); sleep.await; Ok(()) } ``` ## `anyhow` App error handling. Use this in binaries. ```toml anyhow = "1" ``` ```rust use anyhow::{Context, Result, bail}; fn run() -> Result<()> { let cfg = std::fs::read("config.toml").context("reading config.toml")?; if cfg.is_empty() { bail!("config is empty"); } Ok(()) } ``` ## `thiserror` Library error enums. ```toml thiserror = "2" ``` ```rust use thiserror::Error; #[derive(Debug, Error)] pub enum LoadError { #[error("not found: {0}")] NotFound(String), #[error("io error")] Io(#[from] std::io::Error), } ``` ## `clap` CLI argument parsing. Derive-based; you write a struct, you have a CLI. ```toml clap = { version = "4", features = ["derive"] } ``` ```rust use clap::Parser; #[derive(Parser, Debug)] #[command(version, about = "A widget tool")] struct Args { /// Path to the input file input: std::path::PathBuf, /// Verbose output #[arg(short, long)] verbose: bool, /// Number of widgets to make #[arg(short, long, default_value_t = 1)] count: u32, } fn main() { let args = Args::parse(); println!("{args:?}"); } ``` Subcommands: ```rust #[derive(Parser)] struct Cli { #[command(subcommand)] cmd: Cmd, } #[derive(clap::Subcommand)] enum Cmd { Add { path: String }, Remove { path: String }, } ``` **Two footguns that only show up once real users touch the binary.** A positional argument beginning with `-` is parsed as a flag, so any command that forwards user-supplied text needs `--` in front of it. The failure rate is low enough to survive testing and high enough to hit production - a prompt, a search query, or a filename that happens to start with a dash: ```rust // Shelling out to your own (or any) CLI with text you did not write: // without the "--", a query like "- why does this fail" is read as flags. std::process::Command::new("mytool") .arg("search") .arg("--") .arg(user_query); ``` On the receiving side, clap already stops parsing at `--`, so a positional declared with `#[arg(trailing_var_arg = true)]` or simply documented as "put `--` first" is what makes your own tool safe to call that way. And **Rust ignores SIGPIPE at startup**, which the standard library says plainly: "we set SIGPIPE to ignore when the program starts up in order to prevent this problem." The consequence is that `mytool | head -5` does not exit quietly when `head` closes the pipe - your writes start returning `EPIPE`, and the panic or error surfaces from wherever you happened to be printing. Every Unix CLI a user pipes into `head`, `less`, or `grep -q` hits this. On stable, restore the default at the top of `main`: ```rust // Cargo.toml: libc = "0.2" fn main() { // SAFETY: restoring the OS default disposition before any threads exist. unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) }; // ... clap parsing, the rest of main } ``` (The nightly-only `-Zon-broken-pipe` flag does the same thing without `libc`; there is no stable flag equivalent yet.) ## `reqwest` HTTP client. Async by default; the `blocking` feature gives a sync API. ```toml reqwest = { version = "0.13", features = ["json"] } ``` ```rust #[derive(serde::Deserialize)] struct Repo { full_name: String, stargazers_count: u32, } #[tokio::main] async fn main() -> anyhow::Result<()> { let client = reqwest::Client::builder() .user_agent("my-app/0.1") .build()?; let repo: Repo = client .get("https://api.github.com/repos/rust-lang/rust") .send() .await? .json() .await?; println!("{}: {} stars", repo.full_name, repo.stargazers_count); Ok(()) } ``` **0.13 notes** (if you find a 0.12 tutorial): `rustls` is now the default TLS backend (was `native-tls`), and the `rustls-tls` feature is renamed to `rustls`; `query` and `form` are now opt-in crate features. The `json` example above is unaffected. Three more 0.13 changes that alter what you actually link, not just what you type. The rustls crypto provider "defaults to aws-lc instead of _ring_" - a different native dependency in your tree, and `rustls-no-provider` exists if you need to choose another. The rustls roots features were removed in favour of `rustls-platform-verifier`, so certificate validation now goes through the OS trust store by default rather than a bundled root set. And `native-tls` now includes ALPN, with `native-tls-no-alpn` to turn it back off. For tiny sync tools where you do not want a tokio dep, `ureq` is the lightweight alternative. ## `tracing` and `tracing-subscriber` Structured logging. The default in 2026 for any async code (replaces `log`). ```toml tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } ``` ```rust use tracing::{info, warn, error, instrument}; use tracing_subscriber::EnvFilter; #[instrument] // logs entry/exit async fn handle(user_id: u64) -> anyhow::Result<()> { info!(user_id, "handling user"); if user_id == 0 { warn!("got zero user"); } Ok(()) } #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) // RUST_LOG=info .init(); handle(1).await?; Ok(()) } ``` Run with `RUST_LOG=info cargo run`. Use `error!`, `warn!`, `info!`, `debug!`, `trace!`. Add structured fields by passing them as named args: `info!(user_id, action = "create", "user created")`. **The default writer is stdout, and that is a real hazard.** `SubscriberBuilder`'s writer type parameter defaults to `fn() -> Stdout`, with no TTY detection - so the example above interleaves log lines into stdout. For a service that is merely untidy. For any binary whose **stdout carries data** - a JSON-RPC or MCP server, a CLI that pipes records into another process, anything with a machine-readable stdout contract - it silently corrupts the output stream, and the failure looks like a protocol bug rather than a logging one. Logs belong on stderr; make it explicit: ```rust tracing_subscriber::fmt() .with_writer(std::io::stderr) // never inherit the stdout default .with_env_filter(EnvFilter::from_default_env()) .init(); ``` Two more you will want in production and not before: the `json` feature swaps in `format::Json`, "newline-delimited JSON logs... intended for production use", which is what a log aggregator wants instead of the human-readable default; and `tracing-appender` provides file appenders plus a non-blocking writer so a slow sink cannot stall the code that logged. ## `axum` Web framework. Built on `tokio` + `hyper` + `tower`. The 2026 default. ```toml axum = "0.8" tokio = { version = "1", features = ["full"] } ``` ```rust use axum::{routing::get, Router, Json}; use serde::Serialize; #[derive(Serialize)] struct Health { ok: bool } async fn root() -> &'static str { "hello" } async fn health() -> Json<Health> { Json(Health { ok: true }) } #[tokio::main] async fn main() -> anyhow::Result<()> { let app = Router::new() .route("/", get(root)) .route("/health", get(health)) .route("/users/{id}", get(|axum::extract::Path(id): axum::extract::Path<u64>| async move { format!("user {id}") })); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; axum::serve(listener, app).await?; Ok(()) } ``` Path params, query params, JSON body, state, middleware all extract via the `FromRequest`/`FromRequestParts` traits. The axum docs are excellent. **Scope middleware to the routes it exists for.** A `.layer(...)` attached to the whole `Router` runs on every route, which is fine for tracing and wrong for almost anything with a budget. A rate limiter meant to protect one expensive endpoint, hung on the root router, puts your static assets and health checks in the same bucket - and the first page load exhausts it. Layer the sub-router instead, and merge: ```rust let expensive = Router::new() .route("/search", get(search)) .layer(RateLimitLayer::new(5, Duration::from_secs(1))); let app = Router::new() .route("/health", get(health)) // no rate limit .merge(expensive) .layer(TraceLayer::new_for_http()); // this one genuinely is global ``` **0.8 breaking changes** (if you find a 0.7 tutorial): path captures use `/{id}` and `/{*rest}` instead of `/:id` and `/*rest`; `Option<T>` extractors require the new `OptionalFromRequestParts` trait; `Host` extractor moved to `axum-extra`; WebSocket `Message` uses `Bytes`/`Utf8Bytes` instead of `Vec<u8>`/`String`. MSRV is 1.80 (raised in 0.8.9). ## `sqlx` Async SQL with compile-time-checked queries. Postgres, MySQL, SQLite. ```toml sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "macros", "migrate"] } ``` ```rust use sqlx::PgPool; #[derive(sqlx::FromRow)] struct User { id: i64, email: String, } async fn find_user(pool: &PgPool, id: i64) -> sqlx::Result<Option<User>> { sqlx::query_as!( User, "SELECT id, email FROM users WHERE id = $1", id ) .fetch_optional(pool) .await } ``` The `query_as!` macro connects to your dev database at compile time to verify the SQL and types. To work offline (CI without a DB), run `cargo sqlx prepare` and commit the generated `.sqlx/` directory. (sqlx 0.6+ replaced the single `sqlx-data.json` file with this directory; older guides may still reference the old path.) For multi-crate workspaces, run `cargo sqlx prepare --workspace` to produce a single `.sqlx/` at the workspace root. In CI, `cargo sqlx prepare --check` exits non-zero when stored metadata is stale. To force offline mode without the CLI, set `SQLX_OFFLINE=true`. Migrations: `sqlx migrate add init`, write SQL, `sqlx migrate run`. **0.9 notes** (0.9.0 released 2026-05-21): the repository moved to the `transact-rs` GitHub org, and MSRV is now 1.94. The runtime `query()`/`query_as()` functions now take `impl SqlSafeStr` - wrap a dynamically built query string in `AssertSqlSafe(...)`. The `query_as!` macro shown above is unaffected (it takes a string literal). Older 0.8 tutorials otherwise still apply. 0.9 also added a per-crate `sqlx.toml`, which is where migration and macro settings now live instead of scattered environment variables. One packaging regression to know before you copy a CI recipe: upstream states "`cargo install --locked sqlx-cli` will no longer work", so install the CLI without `--locked`. ## `chrono` (and `jiff`) Dates and times. As of Jan 2026 the chrono maintainer announced soft-deprecation and recommends `jiff` (BurntSushi) for new code. Reality in September 2026: - `chrono` 0.4 is still production-safe and integrates cleanly with `serde`, `sqlx`, `serde_json`, and the rest of the ecosystem. Note the deprecation notice lives in the maintainer's issue thread, not in chrono's README, so the crate page looks entirely healthy. - `jiff` is the recommended successor, but still pre-1.0 (0.2.35 as of September 2026, with the 1.0 tracking issue open), and its author says plainly: "I don't currently have a timeline for a Jiff 1.0 release." Migration across the ecosystem is partial rather than done - kube-rs and k8s-openapi have landed, while the arrow-rs and jj-vcs changes are still open PRs. - Two things that lower the risk of picking `jiff` now: `jiff-sqlx` tracks sqlx 0.9, and `jiff-chrono-conversions` gives you `ToJiff`/`ToChrono` traits so a codebase can hold both during a migration. jiff also commits to critical bug fixes on 0.2 for a year after 1.0 ships. Pick `chrono` if you need ecosystem integration today. Pick `jiff` for new code that can tolerate pre-1.0 churn and where you want correct timezone-aware arithmetic out of the box. ```toml chrono = { version = "0.4", features = ["serde"] } # or jiff = { version = "0.2", features = ["serde"] } ``` ```rust // chrono use chrono::{DateTime, Utc}; let now: DateTime<Utc> = Utc::now(); let parsed: DateTime<Utc> = "2026-04-29T12:00:00Z".parse()?; let in_an_hour = now + chrono::Duration::hours(1); // jiff (equivalent) use jiff::{Timestamp, ToSpan}; let now: Timestamp = Timestamp::now(); let parsed: Timestamp = "2026-04-29T12:00:00Z".parse()?; let in_an_hour = now + 1.hour(); ``` ## When `anyhow` + `thiserror` Is Not Enough The app/library split covers almost everything, and you should not go shopping before you have a concrete complaint. When you do, these are the four alternatives worth knowing and what each actually buys: | Crate | Reach for it when | |---|---| | `eyre` | You want `anyhow`'s ergonomics but control over how reports are *rendered* - it is a fork of anyhow built around customizable error reports, usually paired with `color-eyre` for readable panics and backtraces in a binary | | `miette` | Your errors point at a span of user input - a parser, a config file, a query language. It renders rustc-style diagnostics with source snippets and carets. (This is what `dist` itself uses.) | | `snafu` | You want typed error enums like `thiserror`, but with context selectors that attach data at each `?` site instead of only at the boundary | | `error-stack` | You want an attachable, inspectable context *stack* rather than a flat chain - richer than anyhow's `.context()`, at the cost of a more opinionated API | Do not migrate a working `anyhow`/`thiserror` codebase to any of these without a specific reason; the split in this skill is still the default. ## Honorable Mentions Not on the day-1 list, but you will run into these: | Crate | Use | |---|---| | `rayon` | Parallel iterators. `par_iter()` on a `Vec` and your CPU cores light up | | `regex` | Regular expressions | | `uuid` | UUID generation and parsing | | `dotenvy` | Load `.env` into environment variables | | `config` | Layered config (file + env + CLI) | | `bytes` | Efficient byte buffer handling | | `futures` | Future combinators not in `std` | | `parking_lot` | Faster `Mutex`/`RwLock` than `std::sync` (in some workloads) | | `dashmap` | Concurrent hashmap | | `indexmap` | Hashmap that preserves insertion order | | `once_cell` / `LazyLock` (std 1.80+) | Lazy global initialization | | `crossbeam-channel` | Multi-producer, multi-consumer sync channels | | `tower` | Service abstraction (used by axum middleware) | | `tonic` | gRPC | | `redis` | Redis client | | `mongodb` | MongoDB driver | -
dev-environment.md 20 KB
# Development Environment Setting up a fast edit-compile-test loop, and keeping it fast as the project grows. None of this is needed on day 1 - reach for it when builds start to feel slow. ## The fast inner loop The commands, fastest to slowest: - `cargo check` - type-checks without code generation. This is your inner loop; run it constantly. - `cargo clippy` - `check` plus lints. Set your editor to run this on save. - `cargo build` / `cargo run` - full code generation. - `cargo test` - build plus run tests. In your editor, point rust-analyzer's check command at clippy so you get lint feedback inline (in VS Code: `"rust-analyzer.check.command": "clippy"`). rust-analyzer itself does most type-checking as you type; `cargo check` is the fallback the editor runs to populate diagnostics. ## Build speed Two things make Rust builds slow: compiling code, and linking it. Caching compilation is the bigger win and is the same on every platform; the linker story is the part that differs. ### Build caching: use kache [kache](https://github.com/kunobi-ninja/kache) is a content-addressed `RUSTC_WRAPPER`. It gives you a persistent, global cache of compiled **dependencies** - shared across every project on the machine, surviving `cargo clean`, branch switches, and fresh worktrees or clones at a different path. Cache hits restore zero-copy (a reflink on APFS/btrfs/XFS-with-reflink, a hardlink otherwise), so artifact bytes are not duplicated on disk. An optional S3 remote shares artifacts across machines and CI. ```sh # Install (mise, or brew on macOS) mise use -g github:kunobi-ninja/kache@latest brew install kunobi-ninja/kunobi/kache kache init # wires RUSTC_WRAPPER into ~/.cargo/config.toml, installs + starts the daemon kache doctor # verify ``` `kache init` is idempotent - re-run it any time to repair the setup. To wire it by hand instead, set `rustc-wrapper = "kache"` under `[build]` in `$CARGO_HOME/config.toml`. Sharing artifacts across machines needs a remote, in `~/.config/kache/config.toml`: ```toml [cache.remote] type = "s3" bucket = "my-build-cache" endpoint = "https://s3.example.com" # omit for AWS S3; required for Ceph/MinIO/R2 profile = "my-aws-profile" # an AWS profile, not env vars - see the quirks below ``` **kache strips Cargo's incremental flags for the compiles it caches.** That is deliberate: artifact caching replaces that path (and it sidesteps APFS-related incremental corruption on macOS). It is no longer the whole story, though - `adaptive_incremental` now defaults to `true`, so after a crate misses repeatedly on source or dependency changes kache learns that it is churning and gives it an isolated incremental directory for a bounded run before probing the artifact cache again. You do not have to configure that, and you should not try to force incremental back on globally; `cache.incremental_crates` is the escape hatch if you already know which crate is the churner. ### kache quirks worth knowing before they cost you a day | Quirk | Why it bites | |---|---| | `local_max_size` defaults to a **share of the disk**, not a fixed number | Since 0.17.0 the default is "5% of the volume that holds the store, rounded to the nearest GiB, then clamped to 5GiB..=100GiB", with 50GiB only as a fallback when the size probe fails. The budget therefore moves with the machine, and older advice quoting a flat 50GiB is stale. A dependency tree with heavy native or data crates (100-300 MB artifacts each) blows past a small share, GC starts evicting, and reuse you expected stops landing. This looks exactly like "cross-path reuse is broken" - it is not. Check what you actually got rather than assuming a number, then set it explicitly (`KACHE_MAX_SIZE`, or `cache.local_max_size`). GC fires above the cap and evicts down to 90%, so you get a 10% hysteresis band rather than constant thrash at the boundary. Since 0.12.0 eviction is **cost-aware** - it indexes each entry's `compile_time_ms` and weighs what an artifact costs to rebuild, not just how big it is - so the old "size-only LRU throws away your expensive artifacts first" failure mode is gone. | | Count hit rate lies | Judge by **cost-weighted** hit rate (`kache stats`, `kache report`). Cheap leaf crates hitting while the expensive spine misses every time still reads as a healthy-looking 60% by count. | | `cache_executables` is on by default on Linux and macOS | Only Windows still defaults it to `false` (its `.pdb` path keeps debug info outside the binary). If you read older advice telling you to turn this on, it is already on. dylib/cdylib/proc-macro are always cached and unaffected by the flag either way. | | `kache sync --push` filters to **workspace members** | Seeding an existing store to S3 from your project directory silently uploads almost nothing - the push filter is `cargo metadata --no-deps`. Run it from a directory with **no** `Cargo.toml` to push everything. Push also does a full unfiltered LIST of the prefix, which is a real cost on a large bucket. Since 0.15.0 `kache sync` exits non-zero if any transfer fails; pass `--allow-partial` when best-effort really is what you want. | | An auto-started daemon does **not** inherit your shell environment | `KACHE_S3_*` credentials exported from your shell profile leave the launchd/systemd daemon with no credentials at all. Put the remote in the watched `[cache.remote]` config block, use an AWS profile (`cache.remote.profile`) or `~/.aws/credentials`, or start the daemon yourself from the intended environment with `kache daemon run`. | | Set `endpoint` for any non-AWS object store | Ceph, MinIO, and R2 all need `cache.remote.endpoint` (or `KACHE_S3_ENDPOINT`) set explicitly; omit it only for AWS S3. kache always addresses path-style, so you do not need bucket-subdomain DNS to work. | | Upgrading kache can orphan the service | The launchd plist or systemd unit keeps pointing at the deleted old binary. Re-run `kache init` (or `kache daemon install`) after an upgrade. | | Keys encode **toolchain identity**, not machine identity | The key hashes the `rustc --version --verbose` banner (commit hash plus host triple), plus the linker's `--version` for outputs that actually link. Machines can share one bucket prefix safely - objects cannot collide - but only *matching toolchains* ever hit each other. Think **prefix per toolchain**, not per machine. Absolute build and checkout paths are deliberately normalized out, which is what makes a different clone path still hit. | | `key_salt` covers what the key cannot see | A glibc, linker, or Nix-store change alters compiled output while leaving every version banner unchanged, so the key does not move and a stale artifact can be restored. With `cache_executables = true` a `nix store gc` can restore a binary pointing at a garbage-collected ELF interpreter - an error no `cargo clean` fixes. Set `cache.key_salt` to something that changes with your toolchain (a closure hash, a store-path digest). | | Macro-read files are invisible to the key | sqlx's `query!` reads `.sqlx/*.json`, migration macros read `migrations/` - rustc never reports them, so editing one does not re-key the crate and you get a **stale hit**. Declare them in a per-crate `kache.toml` (`extra_inputs = [".sqlx/**/*.json", "migrations/**/*.sql"]`), or workspace-wide with a `[[workspace.extra_inputs]]` block. Note this is `kache.toml`, distinct from the project config `.kache.toml`. (Environment variables a proc macro reads during expansion *are* keyed as of 0.13.0, so that adjacent stale-hit class is closed.) | | `[cache.volumes]` keeps a shard next to the checkout | Added in 0.17.0: a store on the same volume as your source tree, which the wrapper consults first while the daemon imports remote hits into it. This is what makes reflink restores possible when your checkout and your main store live on different volumes - without it, a cross-volume restore silently degrades to a copy. | | CI that you do not trust should read the remote, not write it | 0.17.0 added the split ("Untrusted CI can read a remote but cannot write it"). A fork-PR runner that can write your shared cache is a supply-chain hole: it can seed a poisoned artifact that every later build restores. Grant read-only there. | | Do not bind-mount the cache directory into a container | kache's SQLite index needs single-machine file locking. Shared across an OS boundary it cannot open, and kache silently builds **uncached** (the build still succeeds). Give the container its own `KACHE_CACHE_DIR`. The same applies to NFS/SMB. | Diagnosing a cache that is not paying off: `kache stats` for the weighted hit rate, `kache list --sort size` (large entries showing `hits: 0` mean eviction thrash, not a keying problem), `kache why-miss <crate>` for a specific crate, `explain_miss = true` under `[cache]` to record the dependency detail that the monitor's Why tab groups by cause (0.18.0 - it only explains builds recorded *after* you turn it on), and `KACHE_LOG=warn cargo build` to run the path-leak detector, which flags any key field retaining a machine-local absolute path. For the full picture of what went into a key, `KACHE_LOG=trace` prints every component hashed - prefer that over any hand-kept list, which drifts as the keying logic evolves. **Any `RUSTC_WRAPPER` puts your build cache in the failure path.** Once a wrapper is wired in, a broken, misconfigured, or sandboxed cache surfaces as a *compile failure* - and it will not look like a cache problem, it will look like a baffling Rust error. Before you spend an hour on a compile error that makes no sense, take the wrapper out of the picture and confirm the failure is real: `KACHE_DISABLED=1 cargo build` (kache's own bypass - note it still strips incremental flags unless you also set `KACHE_PRESERVE_INCREMENTAL=1`), or `RUSTC_WRAPPER= cargo build` for any wrapper. This applies equally to sccache. Upgrading kache does **not** require wiping the cache. Keys only shift where the keying logic itself changed, so expect a one-time partial recompile and then a warm cache again - 0.15.0's move to cache-key schema v27 is exactly that: older entries cold-miss once and are then reclaimed automatically. ### sccache: the conservative alternative [sccache](https://github.com/mozilla/sccache) is the older, more widely deployed compilation cache, and it is a reasonable choice if you want the most boring option: ```toml # ~/.cargo/config.toml [build] rustc-wrapper = "sccache" ``` It caches dependencies but cannot cache linker-invoking crates - its docs are explicit that "Crates that invoke the system linker cannot be cached. This includes `bin`, `dylib`, `cdylib`, and `proc-macro` crates." It also does not touch your incremental setting, but that is not the win it sounds like: "Incrementally compiled crates cannot be cached" either, which is why the CI recipe below sets `CARGO_INCREMENTAL=0` when using sccache. The trade-off against kache: sccache does not restore zero-copy, and it has no cross-machine story as direct as kache's S3 sync. The two can also be chained - set `KACHE_FALLBACK=sccache` and kache hands the compiles it declines to cache over to sccache. ### Linkers Independent of which cache you use. On **macOS**, do nothing. The fast third-party linkers are Linux-first: mold describes itself as "a high-performance drop-in replacement for existing Unix linkers" and its supported-architecture list is ELF-only (x86-64, i386, ARM 32/64, RISC-V 32/64, PowerPC 32/64, s390x, LoongArch 32/64, SPARC64, m68k, SH-4) - no Mach-O anywhere in it; wild (now at `wild-linker/wild`, published as the `wild-linker` crate) still lists Mach-O under what it does not yet support. Apple's bundled linker is already fast. On **Linux**, you have the win for free: since Rust 1.90, `x86_64-unknown-linux-gnu` uses the bundled `rust-lld` linker by default (around 7x faster incremental linking than GNU `ld`, no setup). `mold` is faster still if you want to go further - its own August 2026 benchmarks claim it "links 4.9x faster than [LLVM lld](https://lld.llvm.org) and 1.9x faster than [wild](https://github.com/wild-linker/wild) at the median". Wire it up via `.cargo/config.toml`: ```toml [target.x86_64-unknown-linux-gnu] linker = "clang" rustflags = ["-C", "link-arg=-fuse-ld=mold"] ``` Profile before assuming linking is your bottleneck - often it is not. ### CI A minimal, reproducible GitHub Actions gate is three commands behind one toolchain action: ```yaml name: ci on: [push, pull_request] permissions: contents: read # least-privilege GITHUB_TOKEN jobs: check: strategy: fail-fast: false # one OS failing still shows the other matrix: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 - uses: actions-rust-lang/setup-rust-toolchain@v2 - run: cargo fmt --check - run: cargo clippy --locked - run: cargo test --locked ``` `actions-rust-lang/setup-rust-toolchain` reads `rust-toolchain.toml` for the channel and components and bundles `Swatinem/rust-cache` - toolchain install and build cache in one step, no separate cache action. `--locked` fails CI if `Cargo.lock` is stale. Note the **v2** pin. v2.0.0 (September 2026) stopped exporting `RUSTFLAGS="-D warnings"` and sets cargo's own `build.warnings` config instead, via a `build-warnings` input that already defaults to `deny`. That is why the clippy step above carries no `-- -D warnings`: the action is doing it, for every cargo command rather than only the one you remembered to append flags to. The change exists because a `RUSTFLAGS` export silently overrides any `target.*.rustflags` and `.cargo/config.toml` flags your project set. If you are still on `@v1`, you need the explicit `cargo clippy --locked -- -D warnings` form - and note the lint flags must come *after* `--`, since they are for the lint driver, not for cargo. The setting the action reaches for is cargo's own, stabilized in 1.97, and you can own it yourself rather than delegating to CI. `build.warnings` "controls how lint warnings from local packages are treated", and the release notes describe it as "useful for enforcing a warning-free build in CI, replacing `-Dwarnings`". Committing it to `.cargo/config.toml` means local builds and CI enforce the same thing, with no action input to keep in sync: ```toml # .cargo/config.toml [build] warnings = "deny" ``` **Keep the toolchain current, not just pinned.** Cargo shipped fixes for CVE-2026-5222 and CVE-2026-5223 in Rust 1.96.0, and 1.96.1 patched CVE-2025-15661, CVE-2026-55199 and CVE-2026-55200 in its vendored libssh2. It is not only CVEs: 1.98.1 (September 2026) is a one-line point release that fixes "a miscompilation in generating vtables" - 1.98.0 could emit a vtable with a null pointer where a function pointer belonged, which is undefined behavior in code that compiled cleanly. A `rust-toolchain.toml` pin is for reproducibility, not for freezing - bump it deliberately and regularly. **Think twice before adding `--all-features` to a lint or test job.** It is common advice, and it breaks the moment any optional feature needs a toolchain the runner does not have - a `cuda` feature that makes a build script shell out to `nvcc`, a feature pulling a GPU or FFI SDK. Those features exist precisely so that people who lack the toolchain can skip them, and `--all-features` removes that choice, making the job unbuildable on an ordinary machine. Lint the feature combinations you actually ship. **Pick one cache, not two.** Stacking a compiler cache on top of the bundled `rust-cache` competes for the same Actions cache budget and usually makes things worse. When you outgrow `rust-cache` - typically once the dependency tree is big enough that restoring `target/` is itself slow, or you want runners and laptops to share compiled artifacts - swap it for [`kache-action`](https://github.com/kunobi-ninja/kache-action), which is a one-liner: ```yaml - uses: kunobi-ninja/kache-action@v1 ``` That uses the Actions cache by default; pass `s3-bucket` plus credentials to back it with S3, which is what makes reuse work across runners and across machines. Remember that cross-machine hits only land where the toolchain matches exactly, so a CI runner and a laptop on different host triples will never share artifacts no matter how the bucket is configured. If you use `sccache` in CI instead, set `CARGO_INCREMENTAL=0` so it can cache every compilation (kache handles this itself by disabling incremental). ## Dependency hygiene Three cheap CI steps that catch things clippy never looks at. None of them need to be there on day 1; add them once the dependency tree is real. - **`cargo audit`** checks `Cargo.lock` against the [RustSec advisory database](https://rustsec.org) - "Audit `Cargo.lock` files for crates with security vulnerabilities." This is the one to add first; it is a single command and it is the only thing in your pipeline that knows about published advisories. - **`cargo deny check`** is the broader gate: licenses, banned crates, advisories, and allowed sources in one pass. **Its generated template does not work out of the box** - `cargo deny init` writes a `deny.toml` whose license allow-list is empty, which rejects every dependency and fails the check immediately. That is not a bug report waiting to happen, it is a file you are expected to fill in. Decide your license policy before wiring it into CI. - **`cargo machete`** finds dependencies you declare and never use. It works by scanning for `use` statements, which is fast and deliberately imprecise - its own README calls the approach out. Expect false positives on dependencies that exist for a feature flag, a build script, or a re-export, and verify each hit against the source before deleting it. (`cargo-udeps` is more accurate and needs nightly; `cargo-shear` is a third option.) Cargo now has a first-party `unused_dependencies` lint covering the same ground, but its whole lint system "is unstable and can only be used on nightly toolchains", so on stable the external tools are still the answer. ## Finding out where build time actually goes Before installing a cache, measure. `cargo build --timings` (stable since Cargo 1.60) writes an HTML report showing how long each crate took and how much of the build was actually parallel. It is free, it needs no setup, and it regularly shows that the problem is one pathological dependency or a serialized critical path rather than anything a cache would fix. Two more commands worth knowing in the same breath: `cargo fix --edition` applies the mechanical changes for an edition migration, and `cargo clippy --fix` applies the lint suggestions that clippy marks as machine-applicable. Both operate on a clean git tree by default, which is exactly the safety you want. ## Optimizing dependencies in dev builds Debug builds are slow at runtime because nothing is optimized. If a dependency does heavy computation (image processing, crypto, compression) and that dominates your dev-run time, optimize just the dependencies while keeping your own crates unoptimized for fast compiles: ```toml [profile.dev.package."*"] opt-level = 3 ``` Your crates still compile fast; the dependencies, compiled once, then run fast. To speed up your own crate's debug rebuilds, cut the debug info you are not reading. Cargo's own guide now ships this recipe, which goes a step further than the usual one-liner - dependencies get no debug info at all, and a separate opt-in profile exists for the days you actually need a debugger: ```toml [profile.dev] debug = "line-tables-only" # keep file/line for panics and backtraces [profile.dev.package."*"] debug = false # you almost never step into a dependency [profile.debugging] inherits = "dev" debug = true # cargo build --profile debugging ``` That recipe comes from the Cargo Book's [Optimizing Build Performance](https://doc.rust-lang.org/stable/cargo/guide/build-performance.html) chapter, added in Rust 1.92 - it is the canonical, first-party version of most of this page and worth reading straight through before you install anything. ## File watchers `bacon` runs `check`/`clippy`/`test` in a loop, re-running on save, with a compact summary view. Install with `cargo install --locked bacon` and run `bacon` in the project. Optional, but a nice background companion to the editor. -
error-handling.md 9.8 KB
# Error Handling Rust has no exceptions. Every fallible function returns `Result<T, E>`, every "maybe absent" value is `Option<T>`, and the `?` operator stitches them together with one character. This file covers the patterns you will use every day. ## `Result<T, E>` and `?` ```rust use std::fs; use std::io; fn read_config() -> Result<String, io::Error> { let s = fs::read_to_string("config.toml")?; // returns early on Err Ok(s) } ``` `?` does three things: 1. If the value is `Ok(v)`, unwrap and continue with `v`. 2. If `Err(e)`, return early from the function with `Err(e.into())`. 3. The `into()` calls `From` to convert the error type if needed (this is why `From` impls between error types are the foundation of error-handling ergonomics). `?` works on `Option<T>` too: returns early with `None`. ## `Option<T>` `Option<T>` is `Some(value)` or `None`. It replaces null. Common combinators: ```rust let n: Option<i32> = Some(5); n.unwrap(); // panics on None - prototypes only n.expect("must be set"); // panics with message - prototypes only n.unwrap_or(0); // default value n.unwrap_or_else(|| compute()); // default from a closure n.map(|x| x * 2); // Some(10), None stays None n.and_then(|x| checked_div(x, 0)); // chain another Option-returning op n.ok_or("missing"); // turn Option into Result ``` ## `panic!`, `unwrap`, `expect` - When Each Is Appropriate | Usage | Acceptable | Why | |---|---|---| | `panic!("bug: ...")` | Truly unreachable code, broken invariants | Bug indicator, not a control flow tool | | `unwrap()` | Tests, prototypes, `examples/`, throwaway scripts | Crashes the program with a stack trace | | `expect("msg")` | Same as `unwrap`, with a message documenting the assumption | Better than `unwrap` because the message helps debugging | | `?` | Production code | Propagates the error upward | | `.unwrap_or(default)` | When a default makes sense | Recovery without panic | **Rule of thumb for app code**: outside of `main()` (which can panic), use `?` everywhere. `unwrap` and `expect` should be flagged in code review unless paired with a comment explaining why the case is impossible. To enforce this mechanically, clippy's `unwrap_used` lint flags `.unwrap()` calls - though not literally every one: `allow-unwrap-in-consts` defaults to `true`, so an unwrap in a const context is exempt without any configuration from you. The lint is in the `restriction` group - off by default, and a deliberate per-project opt-in, not a blanket default. If you enable it, add a `clippy.toml` with `allow-unwrap-in-tests = true`, since test code panics by design. Leave `expect_used` off - `.expect("reason")` is the documented-invariant form you want people reaching for. ## `anyhow` for Application Code Use `anyhow` in binaries, scripts, and any code where you do not need callers to react to specific error variants. It gives you a single `anyhow::Error` type that anything implementing `std::error::Error` can become. ```rust use anyhow::{Context, Result, bail}; fn load_user(id: u64) -> Result<User> { let bytes = std::fs::read(format!("users/{id}.json")) .with_context(|| format!("reading user {id}"))?; let user: User = serde_json::from_slice(&bytes) .context("parsing user JSON")?; if !user.email.contains('@') { bail!("user {id} has no @ in email"); } Ok(user) } ``` Key features: - **`Result<T>`**: type alias for `Result<T, anyhow::Error>`. - **`.context(...)`**: attaches a string to an error so the chain reads top-down. - **`bail!("msg")`**: shorthand for `return Err(anyhow!("msg"))`. - **`anyhow!("msg")`**: builds an error from a format string. `anyhow::Error` prints the full chain when displayed with `{:?}`: ``` Error: parsing user JSON Caused by: expected `,` at line 3 column 5 ``` ## `thiserror` for Library Code Library APIs should expose typed error enums so callers can match on specific variants. `thiserror` derives the boilerplate. ```rust use thiserror::Error; #[derive(Debug, Error)] pub enum LoadError { #[error("user file not found: {path}")] NotFound { path: String }, #[error("io error")] Io(#[from] std::io::Error), // From impl auto-derived #[error("invalid JSON")] Parse(#[from] serde_json::Error), #[error("user {id} has no email")] MissingEmail { id: u64 }, } pub fn load(id: u64) -> Result<User, LoadError> { let bytes = std::fs::read(format!("users/{id}.json"))?; let user: User = serde_json::from_slice(&bytes)?; if user.email.is_empty() { return Err(LoadError::MissingEmail { id }); } Ok(user) } ``` The `#[from]` attribute generates the `From` impl that `?` needs to convert from `io::Error` or `serde_json::Error` into `LoadError`. **Do not return `Box<dyn std::error::Error>` from a public library API.** It forces callers to downcast to inspect the error. Define the enum. ## Wrap at the Boundary, Not Before It The corollary of the split above, and the one that costs real debugging time: **`anyhow::Error` is a one-way door.** Once a typed error is wrapped, the variant is still in there but nothing downstream can `match` on it without a `downcast_ref`, and code that needed to branch on the failure silently stops branching. This shows up most often in retry loops and error-to-status mapping: ```rust // BAD: the helper is generic over anyhow::Result, so by the time the retry // loop sees a failure the typed variant is already gone - it cannot tell a // retryable conflict from a permanent bad-request, and retries both. async fn with_retry<T>(f: impl Fn() -> anyhow::Result<T>) -> anyhow::Result<T> // GOOD: stay typed as long as something still needs to classify. async fn with_retry<T, E: IsRetryable>(f: impl Fn() -> Result<T, E>) -> Result<T, E> ``` The rule: convert to `anyhow::Error` at the point where the only remaining job is to report the failure - a request handler, `main`, a log line. Anywhere above that, if a caller has to *decide* something from the error, keep the type. `.context(...)` is free to add on the way; it enriches without erasing. ## Combining `anyhow` and `thiserror` The standard pattern in a project that has both library and binary crates: - Library crates define typed errors with `thiserror`. - Binary crates use `anyhow` and let `?` convert library errors via `From` (which `thiserror` derived for free, since `thiserror` errors implement `std::error::Error` and `anyhow::Error: From<E> where E: std::error::Error`). This way binaries get ergonomic `?` everywhere, libraries give callers something useful to match on, and no boilerplate is duplicated. ## Custom Error Enums Without `thiserror` You do not have to use `thiserror`. Plain enums work, you just write the impls yourself: ```rust use std::fmt; #[derive(Debug)] pub enum MyError { NotFound, Io(std::io::Error), } impl fmt::Display for MyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { MyError::NotFound => write!(f, "not found"), MyError::Io(e) => write!(f, "io: {e}"), } } } impl std::error::Error for MyError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { MyError::NotFound => None, MyError::Io(e) => Some(e), } } } impl From<std::io::Error> for MyError { fn from(e: std::io::Error) -> Self { MyError::Io(e) } } ``` Use `thiserror`. The above is what it generates. ## `?` Conversion: How `From` Powers Error Propagation When `?` returns from a function, it calls `.into()` on the error. `.into()` calls `From::from`. So `?` works whenever there is a `From` impl from the inner error type to the outer error type. ```rust fn outer() -> Result<(), MyError> { let _bytes = std::fs::read("x")?; // io::Error -> MyError via From impl Ok(()) } ``` If you derived `#[from]` on a variant with `thiserror`, this Just Works. If you wrote the enum by hand, write the `From` impl. If you use `anyhow::Error`, all errors that impl `std::error::Error` convert automatically. ## `Result` in `main` `fn main` can return a `Result`. With `anyhow`: ```rust fn main() -> anyhow::Result<()> { let user = load_user(1)?; println!("{user:?}"); Ok(()) } ``` If `main` returns `Err`, the program exits with a non-zero status and prints the error chain via `Debug`. ## Common Pitfalls - **Returning `Result<T, String>`**: tempting in early days, but you lose the error chain (`source()`), and you cannot `?`-convert from other error types. Use `anyhow` or `thiserror`. - **`.unwrap()` because the error type is annoying**: define the conversion with `From` once, then `?` everywhere. - **Catching every error and logging it**: errors are values; let them propagate to a single point that decides how to log or display them. Your app likely has one or two such points (request handler, CLI entry). - **Ignoring `Result`**: `let _ = fallible();` discards an error silently. The compiler warns by default; do not silence the warning without a comment. ## Quick Reference ```rust // Construct Ok(value) Err(my_error) Some(value) None // Unwrap / propagate val? // propagate val.unwrap() // panic on Err / None val.unwrap_or(d) // default val.unwrap_or_else(|| compute()) val.expect("msg") // panic with message // Transform val.map(|x| ...) // Result<T,E> -> Result<U,E> val.map_err(|e| ...) // Result<T,E> -> Result<T,F> val.and_then(|x| ...) // chain Result-returning op val.or_else(|e| ...) // recover from Err val.ok() // Result<T,E> -> Option<T> val.ok_or(my_err) // Option<T> -> Result<T,E> // Build (anyhow) anyhow::anyhow!("msg with {var}") anyhow::bail!("msg") // = return Err(anyhow!("msg")) result.context("doing X") result.with_context(|| format!("doing X for {id}")) ``` -
ownership-and-types.md 10.8 KB
# Ownership and Types Deep dive on the rules that govern every Rust program: who owns what, who can read it, who can change it. Also: when to use `String` vs `&str`, `Vec<T>` vs `&[T]`, and the smart pointers (`Box`, `Rc`, `Arc`, `RefCell`, `Mutex`). ## The Three Ownership Rules 1. Every value has exactly one owner (a variable). 2. When the owner goes out of scope, the value is dropped (memory freed, file closed, etc.). 3. There can be either many readers (`&T`) or one writer (`&mut T`), never both at once. That is the entire system. Lifetimes are bookkeeping that proves rule 3 to the compiler when references cross function or struct boundaries. ## Move, Borrow, Clone ```rust let a = String::from("hello"); // MOVE: ownership transfers; a is no longer usable let b = a; // println!("{a}"); // compile error: value moved // BORROW: a keeps ownership, c looks at it let c = &b; // immutable borrow println!("{c}"); // MUTABLE BORROW: exclusive write access let mut s = String::from("hi"); let m = &mut s; m.push_str("!"); // CLONE: deep copy, both keep their own let d = b.clone(); ``` `Copy` types (integers, floats, bools, `char`, `&T`, and tuples and arrays whose elements are all `Copy`) are copied implicitly on assignment instead of moved. They are cheap bitwise duplications. `String`, `Vec<T>`, `Box<T>`, etc., are not `Copy`; they move. ## Borrowing Rules in Practice ```rust let mut v = vec![1, 2, 3]; let r = &v; let m = &mut v; // ERROR: cannot borrow as mutable while r is alive println!("{r:?}"); ``` The fix is to end the immutable borrow before starting the mutable one, often by restructuring the code: ```rust let mut v = vec![1, 2, 3]; { let r = &v; println!("{r:?}"); } // r dropped here let m = &mut v; // now fine m.push(4); ``` In modern Rust (NLL: non-lexical lifetimes), the compiler often shortens borrow scopes automatically. You usually do not need explicit blocks. ## `String` vs `&str` vs `Cow<str>` | Type | What it is | Owns? | When to use | |---|---|---|---| | `String` | Heap-allocated, growable UTF-8 | Yes | Struct fields, return values, buffers you mutate | | `&str` | Borrowed view into a UTF-8 string | No | Function parameters; string literals (`"hi"` is `&'static str`) | | `&'static str` | Borrowed string with program-long lifetime | No | Constants, hardcoded strings | | `Cow<'_, str>` | Either owned or borrowed | Sometimes | When most calls do not need to allocate but a few do | ```rust // Good: parameter is &str, accepts both String and &str callers fn shout(s: &str) -> String { s.to_uppercase() } shout("hi"); shout(&String::from("hi")); // &String coerces to &str via deref ``` The newcomer trap: putting `&str` in a struct field. The struct now needs a lifetime parameter, and so does every function and struct that holds it. Use `String` and stop the cascade. ### You cannot index a string `s[0]` does not compile, and this stops people cold on day 1. A Rust string is UTF-8, so a byte offset is not a character offset: indexing by number would either return a meaningless byte or silently cost O(n), and Rust refuses to pick. What you actually want is usually one of: ```rust let s = "héllo"; s.chars().next(); // Option<char> - the first character s.chars().nth(2); // Option<char> - the third; O(n), that is the point s.chars().count(); // characters (5), NOT s.len(), which is bytes (6) for (i, c) in s.char_indices() {} // byte offset paired with the char at it &s[0..2]; // a &str slice by BYTE range ``` Range slicing is allowed, but it panics if an endpoint lands inside a multi-byte character - `&s[0..2]` above cuts `é` in half at runtime. Slice on offsets you got from the string itself (`char_indices`, `find`, `split`), never on arithmetic you did yourself. When you genuinely want fixed-width elements, you want `Vec<u8>` or `&[u8]`, not a string. ## `Vec<T>` vs `&[T]` vs `[T; N]` | Type | What it is | When to use | |---|---|---| | `Vec<T>` | Heap-allocated, growable array | Field, owned collection, return value | | `&[T]` | Borrowed slice, view into a contiguous run | Function parameter; accepts `&Vec<T>`, `&[T; N]`, slice | | `[T; N]` | Fixed-size array, size known at compile time | Small fixed buffers, lookup tables | ```rust // Generic over input source: works with Vec, array, or slice fn sum(xs: &[i32]) -> i32 { xs.iter().sum() } sum(&vec![1, 2, 3]); sum(&[1, 2, 3]); sum(&[1, 2, 3][..]); ``` ## `HashMap<K, V>` The other collection you will reach for on day 1. Keys must be `Eq + Hash` (derive both). ```rust use std::collections::HashMap; let mut counts: HashMap<String, u32> = HashMap::new(); counts.insert("a".to_string(), 1); counts.get("a"); // Option<&u32> - borrows, does not move counts.get("a").copied().unwrap_or(0); counts.remove("a"); // Option<V> - hands you the owned value back for (k, v) in &counts { } // iterate by reference ``` `get` takes `&str` even though the key is `String`, because `HashMap::get` is generic over `Borrow<Q>` (see the standard-traits table in `traits-and-generics.md`). That is why you do not have to allocate a `String` just to look one up. The method worth learning immediately is **`entry`**, which resolves "insert if absent, otherwise update" in one lookup instead of two: ```rust // Count occurrences - the canonical example let text = "a b a"; let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word).or_insert(0) += 1; // counts["a"] == 2 } // Build a multimap; or_insert_with's closure runs only on a miss let mut by_letter: HashMap<char, Vec<&str>> = HashMap::new(); for word in text.split_whitespace() { let first = word.chars().next().unwrap(); by_letter.entry(first).or_default().push(word); // or_insert_with(Vec::new) } ``` Writing that as a `contains_key` check followed by an `insert` hashes the key twice and fights the borrow checker; `entry` does neither. `BTreeMap` has the same API and keeps keys sorted, at the cost of `Ord` instead of `Hash` and slower lookups - use it when you need ordered iteration. ## Smart Pointers | Pointer | Ownership model | Thread-safe? | Mutability | |---|---|---|---| | `Box<T>` | Single owner, heap allocation | n/a | Through `&mut Box<T>` or by moving out | | `Rc<T>` | Shared owner, ref-counted | NO | Read-only; pair with `RefCell<T>` for interior mutability | | `Arc<T>` | Shared owner, atomic ref-counted | YES | Read-only; pair with `Mutex<T>` or `RwLock<T>` | | `RefCell<T>` | Single owner, runtime-checked borrows | NO | Mutable through `.borrow_mut()` (panics if rule 3 violated) | | `Mutex<T>` | Owner gates access via lock | YES | Mutable through `.lock()` | | `RwLock<T>` | Many readers or one writer | YES | Many `.read()`, one `.write()` | ### When to pick which - **Single owner, just needs to be on the heap** (e.g., recursive types, large structs in enums): `Box<T>`. - **Shared ownership across one thread** (e.g., a tree where multiple parents point to the same child): `Rc<T>`. Reach for it sparingly; it usually signals a graph structure that could be flattened. - **Shared ownership across threads** (e.g., shared state in a `tokio::spawn`'d task): `Arc<T>`. - **Need to mutate through a shared pointer**: - Single thread: `Rc<RefCell<T>>` (not recommended as default) - Multi-thread / async: `Arc<Mutex<T>>` (recommended default for shared mutable state) ### Why default to `Arc<Mutex<T>>` over `Rc<RefCell<T>>` `Rc` and `RefCell` are not `Send`, so the moment you spawn an async task or thread, you have to refactor. Picking `Arc<Mutex<T>>` from the start avoids that refactor. The performance cost (atomic ops vs non-atomic) is negligible compared to the cost of reorganizing your code. ### Mutex pitfall in async Holding a `MutexGuard` across an `.await` point can deadlock. Either drop the guard before awaiting, or use `tokio::sync::Mutex` (async-aware) for state that needs to be locked across awaits. ```rust use std::sync::Mutex; let state = Arc::new(Mutex::new(Counter::new())); // BAD: guard held across .await { let guard = state.lock().unwrap(); do_async_thing().await; // tasks waiting on this lock are stuck } // GOOD: scope the lock tightly { let mut guard = state.lock().unwrap(); guard.bump(); } // guard dropped here do_async_thing().await; ``` ## Lifetimes (When You Cannot Avoid Them) Most function lifetimes are inferred (lifetime elision). You only write them when: 1. A function takes multiple input references and returns a reference, and the compiler cannot tell which input the output borrows from. 2. A struct field is a reference (which you should mostly avoid). ```rust // Elided: compiler infers 'a for both input and output fn first_word(s: &str) -> &str { /* ... */ } // Explicit: compiler does not know if the output borrows from x or y fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } ``` The `'static` lifetime means "lives for the entire program." String literals and most constants are `'static`. Use it sparingly; it is more constraint than asset. ## The Self-Referential Struct Trap You will at some point want to write something like: ```rust struct Parsed { text: String, first_word: &str, // points into self.text } ``` This does not work in safe Rust. A struct cannot hold a reference to itself, because moving the struct would invalidate the reference. Workarounds: 1. Store an index or range instead of a reference: `first_word: std::ops::Range<usize>`. Recompute the slice on demand. 2. Use a crate like `ouroboros` or `self_cell` if you really need this pattern. 3. Refactor to keep the parsed pieces and the source string in separate owners. Most of the time option 1 is the right answer. ## When You Find Yourself Cloning a Lot `.clone()` is fine when learning. Some cases where it is correct in production: - You genuinely need two independent copies (e.g., one for a callback, one for the current scope). - Cloning is cheap (`Arc<T>` clone is one atomic increment). - The alternative is significantly more complex and the perf does not matter. When it is wrong: - You are cloning a large `Vec<T>` per request to "fix" a borrow error. The borrow error is telling you the data ownership is unclear. Step back and look at the design. ## `Cow<T>` (Clone on Write) `std::borrow::Cow<'a, T>` is "borrowed if possible, owned if necessary." Useful when most callers do not need to mutate or own the value, but some do. ```rust use std::borrow::Cow; fn normalize(s: &str) -> Cow<'_, str> { if s.contains('\r') { Cow::Owned(s.replace('\r', "")) // allocate only when needed } else { Cow::Borrowed(s) // zero allocation common case } } ``` Most code does not need `Cow`. Reach for it when profiling shows allocation pressure on a hot path. -
performance.md 7.5 KB
# Performance Making a Rust app actually fast at runtime. The order matters: measure first, optimize second. Rust is fast by default, so most "optimizations" written without a profiler are guesses that add complexity for no gain. ## Measure first Never optimize without a profiler pointing at the hot spot. The workflow: 1. Build with optimizations (`cargo build --release` - debug builds are not representative). 2. Profile under a realistic workload. 3. Fix the actual hot spot the profiler shows. 4. Measure again. ## Keep the harness Whatever you build to answer a performance question - a benchmark, an A/B timing comparison of two builds, a memory/CPU monitor over a full run - is a reusable asset, not scratch work. Its value is being re-run after the *next* change, to confirm you did not regress. Give it a committed home: a Rust benchmark or stress harness goes in `benches/` (below); a whole-program profiling or timing script goes in a committed `scripts/` or `xtask/` directory. Never leave it in `/tmp` or as inline shell - then the next person to ask the same question rebuilds it from scratch. ## Profiling A profiler needs optimized code *and* debug symbols, but a plain `--release` build strips the symbols - so the profile comes back as unreadable `[unknown]` frames. Add a `profiling` profile once: One toolchain change to know before you blame your tools: **Rust 1.97 made the v0 symbol mangling scheme the default.** The release notes are explicit that this "may cause some tools (such as debuggers or profilers, especially with old versions) to fail to demangle symbols emitted by Rust" and "may also cause the formatting of text in backtraces to change". So garbled or undemangled frames after a toolchain bump usually mean an out-of-date profiler, not a broken build. ```toml [profile.profiling] inherits = "release" debug = true ``` - **`samply`** is the go-to sampling profiler: `cargo install --locked samply`, build with `cargo build --profile profiling`, then `samply record ./target/profiling/my-app`. It opens an interactive Firefox Profiler view in the browser; works on macOS, Linux, and Windows (on Linux, grant perf access once with `sysctl kernel.perf_event_paranoid=1`). - **`cargo-flamegraph`** produces a static flamegraph SVG; `samply` has largely displaced it for interactive work. On Linux it now needs `--no-rosegment`, because rust-lld became the default linker in 1.90 and perf cannot generate accurate stack traces without it - the same applies under mold. - For **heap profiling** - what allocates and how much - the `dhat` crate gives in-process, cross-platform heap profiling (unlike Valgrind): add it, set its global allocator, and view the profile in the DHAT online viewer. Budget for the slowdown; its docs warn that "the program will run more slowly than normal... it can be large", and the author notes maintenance is not a high priority. It is a tool you reach for deliberately, not one you leave wired in. - The **Rust Performance Book** (https://nnethercote.github.io/perf-book/) is the canonical guide - read it before reaching for tricks. For a release profile that is representative and ships fast: ```toml [profile.release] lto = "thin" # link-time optimization across crates codegen-units = 1 # less parallelism, better optimization ``` ## Benchmarking To compare two implementations or guard against regressions, write a benchmark - do not eyeball it. Benchmarks live in `benches/`, declared in `Cargo.toml` with `harness = false` so the bench crate provides its own `main`: ```toml [[bench]] name = "parse" harness = false ``` `cargo bench` builds them with the optimized `bench` profile automatically (it inherits `release`) - no `--release` flag to remember. Two harnesses: - **`criterion`** (`criterion = "0.8"`) - the standard. Statistical analysis, warmup, outlier detection, regression tracking between runs. - **`divan`** (`divan = "0.1"`) - simpler and lighter, less machinery. The one thing you must get right: the compiler will delete a benchmark's work if it sees the result is unused, giving a fake "0 ns" result. Wrap inputs and outputs in `std::hint::black_box` so the optimizer cannot see through them: ```rust use std::hint::black_box; c.bench_function("parse", |b| { b.iter(|| parse(black_box(INPUT))); }); ``` A note on profiles: `cargo bench` inherits `[profile.release]`, so an aggressive release profile (`lto`, `codegen-units = 1`) makes every `cargo bench` re-do a slow whole-program link. If your bench loop feels sluggish, move the ship-grade settings into a dedicated profile and keep `release` lighter: ```toml [profile.dist] # build shipping artifacts with `cargo build --profile dist` inherits = "release" lto = "fat" codegen-units = 1 ``` To catch performance regressions automatically, run benchmarks in CI. **CodSpeed** is the low-friction default: rename your import to `codspeed-criterion-compat` (or `codspeed-divan-compat`), add its GitHub Action, and it posts low-variance per-PR comparisons; **Bencher** (https://bencher.dev) is the self-hostable alternative. For quick whole-program or CLI A/B timing with no code at all, `hyperfine` runs a command repeatedly and compares. ## The real wins Before micro-optimizing, the changes that actually move the needle in typical Rust apps: - **Allocations.** Needless `.clone()` on a hot path, or building a `Vec` without `Vec::with_capacity` when the size is known, are the most common real costs. A profiler will point you straight at them. - **`into_iter()` instead of `.iter()` when you are transforming a collection you own.** Iterating by reference keeps every source element alive until the whole transform finishes, so peak memory holds both representations at once. `into_iter()` lets each source element drop as it is consumed. On a large decode this is a one-word change that can halve the peak. - **Data parallelism.** For CPU-bound work over a collection, `rayon` turns `.iter()` into `.par_iter()` and uses every core - often the biggest win for the least code. - **Avoid premature `Arc`/`Box`/`dyn`.** Indirection has a cost; reach for it when the design needs it, not preemptively. - **Async is for I/O concurrency, not speed.** If your workload is CPU-bound, async adds overhead without benefit - use threads or `rayon`. And the discipline that ties it together: a change is only an optimization if a benchmark or profiler says so. Otherwise it is just added complexity. ## Memory: high RSS is usually not a leak When resident memory looks too high, the folk-wisdom fix is to swap the global allocator to `jemalloc` or `mimalloc`. Measure before you believe it - that swap is as likely to make things worse, and it does nothing about the actual cause. Most "excess" RSS is memory the process already freed but the allocator has **retained** rather than returned to the OS. It is available for reuse and it is not a leak; it just looks alarming in a process monitor. Confirm which you have before acting - a heap profiler (`dhat`) shows *live* bytes, and the gap between live bytes and RSS is retention and fragmentation, not leakage. If that gap is where your memory went, the lever is **the transient peak, not the allocator**: an allocator only holds regions that some earlier spike forced it to acquire. Cutting the spike (stream instead of buffering, `into_iter()` instead of `.iter()`, process in batches) means the regions are never claimed in the first place. Reach for an allocator swap only with a measurement in hand showing it helps *your* workload on *your* platform - and re-measure after, because the intuitive result is not guaranteed. -
project-shape.md 7.4 KB
# Project Shape: Past One Crate The main skill teaches a single crate with one `Cargo.toml`. This file is what you need the day that stops being enough - a second crate, an optional dependency, a generated file, a minimum Rust version somebody actually depends on. None of it is day-1 material. Read the section you have hit. ## Workspaces A workspace is several crates sharing one `Cargo.lock`, one `target/` directory, and one `cargo test` invocation. The moment you have a library plus a CLI that uses it, you want one. ```toml # Cargo.toml at the repo root - this crate has no [package] of its own [workspace] resolver = "3" members = ["crates/*"] [workspace.package] version = "0.1.0" edition = "2024" license = "MIT" rust-version = "1.85" [workspace.dependencies] serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } anyhow = "1" ``` ```toml # crates/my-cli/Cargo.toml [package] name = "my-cli" version.workspace = true edition.workspace = true rust-version.workspace = true [dependencies] my-lib = { path = "../my-lib" } serde = { workspace = true } tokio = { workspace = true, features = ["fs"] } # add features, never remove them ``` Two things worth internalizing: - **Inherited dependencies unify.** Every member resolves to one version of `serde`, which is the point - it prevents the diamond where two crates in your own repo disagree and you compile both. A member can *add* features on top of the workspace entry, but cannot subtract them. - **`target/` is shared.** A workspace build reuses artifacts across members, which is why splitting a big crate into several rarely costs build time and often saves it. `cargo test`, `cargo clippy`, and `cargo build` operate on the whole workspace from the root; `-p <crate>` scopes to one member. ## `[workspace.lints]` and the trap that goes with it The main skill's `[lints]` table configures one crate. In a workspace you define it once and inherit: ```toml # root Cargo.toml [workspace.lints.rust] unsafe_code = "deny" unreachable_pub = "warn" [workspace.lints.clippy] all = { level = "deny", priority = -1 } dbg_macro = "warn" ``` ```toml # EVERY member Cargo.toml needs this - it is not automatic [lints] workspace = true ``` That second block is the trap. `workspace.lints` is **not** implicitly inherited, and a member that omits `[lints] workspace = true` is silently unlinted - it compiles clean while the rest of the workspace is under `-D warnings`. Cargo does have a `missing_lints_inheritance` lint for exactly this, but do not count on it: "Cargo's linting system is unstable and can only be used on nightly toolchains", so on stable nothing warns you at all. When you add a new member, this is the line you will forget, and the only thing that catches it is you. ## Feature flags Features are named, additive sets of optional functionality. They are how a crate offers "TLS, but pick a backend" or "serde support if you want it" without forcing the dependency on everyone. ```toml [features] default = ["json"] json = ["dep:serde_json"] tls = ["dep:rustls"] # Turn on a dependency's feature only if that dependency is already enabled metrics = ["dep:prometheus", "tokio?/rt-multi-thread"] [dependencies] serde_json = { version = "1", optional = true } rustls = { version = "0.23", optional = true } prometheus = { version = "0.14", optional = true } ``` Three pieces of syntax, all worth knowing because older guides predate them: - **`dep:foo`** refers to the optional dependency without also creating an implicit feature named `foo`. Available since Rust 1.60. Without it, every optional dependency silently becomes part of your public feature surface. - **`foo?/bar`** enables dependency `foo`'s `bar` feature *only if* `foo` is otherwise enabled - the `?` is what stops it from pulling `foo` in. - **Features must be additive.** Two crates in one build can both enable features of a shared dependency, and cargo unifies them; a feature that *removes* or *changes* behavior will break somebody. If you find yourself wanting `no-std` and `std` as mutually exclusive features, that is the shape fighting you. In code, gate with `#[cfg(feature = "json")]`. Test the combinations you actually ship - and see `dev-environment.md` on why `--all-features` in CI is a trap, and `releasing.md` on how feature unification bites across cross-compilation targets. ## Build scripts (`build.rs`) A `build.rs` at the crate root compiles and runs *before* the crate does. Legitimate uses are narrow: compiling bundled C, generating code from a schema (protobuf, SQL), or baking in build-time facts. ```rust // build.rs fn main() { // Re-run only when this actually changes - without it, cargo re-runs // the script whenever any file in the package changes. println!("cargo::rerun-if-changed=proto/api.proto"); println!("cargo::rustc-cfg=has_fancy_backend"); } ``` The syntax changed: directives use a **double colon** (`cargo::rerun-if-changed`) as of Rust 1.77. The old single-colon form still works but is deprecated, and tutorials are full of it. Two costs to weigh before adding one. A build script is a compile-time dependency for everyone who builds you, including in environments you cannot see - a script that shells out to `cmake` or `protoc` makes those tools a hard install requirement, which is exactly the class of thing that breaks `cargo install` for users while working fine in your repo. And build scripts are opaque to compiler caches: files a macro or script reads are invisible to the cache key unless you declare them (see the `extra_inputs` row in `dev-environment.md`). ## `rust-version` (MSRV) and why it now matters more `rust-version = "1.85"` in `[package]` declares your minimum supported Rust version. It used to be documentation with a build-time error attached. It is now load-bearing for dependency resolution. Edition 2024 defaults to `resolver = "3"`, which flips `resolver.incompatible-rust-versions` from `allow` to **`fallback`**: cargo will prefer an older version of a dependency when the newest one requires a newer Rust than you declare. That is usually what you want - it stops `cargo update` from silently breaking your MSRV promise - but it means a stale `rust-version` now quietly holds your whole dependency tree back. Set it to a version you actually test against, and raise it deliberately. If you support an MSRV, test it in CI with that toolchain, not just the latest. ## `#[non_exhaustive]` An attribute on a struct, enum, or variant that "indicates that a type or variant may have more fields or variants added in the future". It stops downstream code from writing an exhaustive `match` or a struct literal, so adding a variant later is not a breaking change. ```rust #[non_exhaustive] pub enum LoadError { NotFound, Io(std::io::Error), } ``` This is the right default for a **published library's error enum**, which is precisely the thing the main skill teaches you to build with `thiserror` - error enums grow, and without this every new variant is a semver break. Know the cost from the consumer side, because it produces a confusing error. A downstream `match` now needs a `_ =>` arm, and a non-exhaustive *struct* cannot be built with a struct literal at all - not even with `..Default::default()`. The compiler says `error[E0639]: cannot create non-exhaustive struct using struct expression`, and the answer is always to use the crate's own constructor or builder. If you apply the attribute, ship a constructor. -
releasing.md 17.5 KB
# Releasing and Distribution Getting a Rust binary from a merged commit to something a user can install. None of this matters until you have users; when you do, the difference between a good pipeline and an improvised one is measured in broken installs. There are two viable routes. Pick by how much control you need. ## Route A: `dist` (the batteries-included default) [`dist`](https://github.com/axodotdev/cargo-dist) (formerly `cargo-dist`) plans the release, cross-compiles the binaries, generates installers (shell, PowerShell, npm, Homebrew), and **writes its own CI workflow** - `dist init` emits a `release.yml` implementing the whole plan/build/host/publish/announce pipeline. For most projects this is the right answer, and you should try it before hand-rolling anything. A note on its history, because a stale memory will otherwise scare you off: `dist` was built by axo, who wound down; maintenance was picked up by the community, the Astral fork's features were merged back in, and it has shipped steady releases since (0.32.0 in May 2026). It is maintained. Reach for Route B only when you need something dist does not model - an unusual cross-compilation setup, a bespoke distribution fan-out, or strict control over the order in which things publish. **The concrete fork in the road is macOS cross-compilation.** dist will not plan a Linux-host build of a macOS target at all; it returns a typed `UnsupportedCrossCompile` error whose help text reads "cross-compiling to macOS is a road paved with sadness - we cowardly refuse to walk it." That is a deliberate refusal, not a missing feature, and the supported answer is to let dist run the macOS builds on macOS runners. If you specifically want every artifact built from one Linux runner - which is the whole premise of Route B below - that requirement alone decides it. ## Route B: a hand-rolled pipeline (one reference implementation) What follows is **one pipeline that works in production**, not the only correct shape. Copy the parts that fit. The value here is less the YAML than the reasoning behind each decision - most of these were learned by breaking something. The pieces: [`release-plz`](https://release-plz.dev) drives versioning and publishing, [`cargo-zigbuild`](https://github.com/rust-cross/cargo-zigbuild) cross-compiles every target from a single Linux runner, and a handful of plain shell steps fan the built binaries out to GitHub Releases, Homebrew, and Nix. ### release-plz drives versioning You write [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `perf:`, …). release-plz keeps a **release PR** open that bumps the version in `Cargo.toml` and writes the `CHANGELOG.md` entry from those commits. Merging that PR is the act of releasing: release-plz then publishes to crates.io, cuts the git tag, and creates the GitHub release. It runs as two different commands, and the split matters: - `release-pr` - keeps the release PR current. Never publishes. Runs on every push to `main`. - `release` - publishes to the registry and cuts the tag. Runs only when a release is actually pending. **Two bump behaviors that surprise people.** On a `0.x` version, a `feat:` commit produces a **patch** bump, not a minor one - `0.2.7` + `feat:` is `0.2.8`. This is deliberate (Cargo treats `0.x` → `0.(x+1)` as the breaking-change channel, so features cannot claim it), and it means a `feat!:` breaking change on `0.2.3` gives you `0.3.0`, not `1.0.0`. The first is overridable in config with `features_always_increment_minor`; the second is **not** - `breaking_always_increment_major` exists only as a Rust API on release-plz's version updater, not as a `release-plz.toml` key, so do not go looking for it there. The defaults are the correct ones anyway. Expect them rather than fighting them. **Squash-merge can silently erase your release.** The version bump is computed from *commit messages*. Squash-merging a PR replaces its commits with a single commit whose message is the **PR title** - so a PR titled `ci: tidy workflow` that happens to contain the `feat:` commit produces no `feat:` in history, and therefore no release at all. The commit you cared about is gone. Either title the PR conventionally (so the squashed message carries the right type), or use a merge commit for release-worthy PRs. This is not a release-plz quirk - it bites every conventional-commit release tool. ### Build the binaries *before* you publish The single most important ordering decision. If `cargo publish` runs first and the binary build then fails, crates.io has a version whose release has no binaries - and crates.io publishes are **irreversible**. So the publish job builds the artifacts first, and only then invokes release-plz's `release` command: ```yaml publish-release: needs: [release-prep] if: needs.release-prep.outputs.is_release == 'true' steps: - uses: actions/checkout@v7 with: { fetch-depth: 0, persist-credentials: false } - name: Build + package binaries # must succeed before anything publishes run: ./scripts/build-dist.sh - uses: release-plz/action@v0.5 # NOW publish crates.io + cut the tag id: release-plz with: { command: release } env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} # a PAT, not the workflow token - see below CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - name: Upload binaries to the release if: ${{ steps.release-plz.outputs.releases_created }} run: gh release upload "v$VERSION" dist/* --clobber ``` ### Detect a pending release with the tag, not with `--dry-run` To gate the publish job you need to know "is a release pending?" - and release-plz's dry-run **cannot tell you**: in dry-run it always reports `releases_created=false` and merely logs the plan. Check the tag instead. This mirrors release-plz's own first gate ("skip a package whose tag already exists"): ```yaml - id: detect run: | set -euo pipefail V=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') if git rev-parse "v$V" >/dev/null 2>&1; then echo "is_release=false" >> "$GITHUB_OUTPUT" else echo "is_release=true" >> "$GITHUB_OUTPUT" fi ``` ### Set concurrency per job - never workflow-level A workflow-level `concurrency: cancel-in-progress: true` will eventually kill a release **mid-publish**: a newer push to `main` cancels the run somewhere between release-plz cutting the tag and the artifacts finishing upload. Once the tag exists, the pending-release gate above reads `false` forever, so the interrupted release **cannot auto-recover** - you get a tagged, published version with no binaries and no retry. Keep builds cancellable and make publishing non-cancellable: ```yaml build-and-test: concurrency: { group: "ci-build-${{ github.ref }}", cancel-in-progress: true } publish-release: # A newer push queues behind this run instead of killing it mid-publish. concurrency: { group: "release-${{ github.ref }}", cancel-in-progress: false } ``` ### Use a PAT for the release PR, not `GITHUB_TOKEN` A pull request opened by the default `GITHUB_TOKEN` does not get CI the way a human-opened one does - GitHub gates that to prevent recursive workflow runs. The current behavior is that the resulting `pull_request` runs are created in an **approval-required** state rather than never existing at all, so the symptom is a release PR sitting with no green checks until somebody clicks through, which is the same practical failure: you merge a version bump nothing has tested. Give the `release-pr` step a personal access token (or a GitHub App token) instead. ### `[profile.dist]`: pay for optimization only at release Full LTO and a single codegen unit make a meaningfully faster binary and a much slower build. You do not want that on every `cargo build --release` during development. Split the profiles: ```toml # Iteration profile: no LTO, so local rebuilds relink in seconds. [profile.release] # Ship profile: used only for release artifacts. [profile.dist] inherits = "release" lto = "thin" codegen-units = 1 ``` Then build artifacts with `--profile dist`. See `performance.md` for the fuller treatment of this split. ### Cross-compile every target from one runner with `cargo-zigbuild` `cargo-zigbuild` uses Zig as the linker, which cross-links glibc (and Mach-O, given an SDK) without Docker, without QEMU, and without a per-OS CI matrix. One Linux runner produces every artifact: ```sh rustup target add aarch64-apple-darwin x86_64-pc-windows-gnu \ aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu cargo zigbuild --locked --profile dist --target aarch64-apple-darwin cargo zigbuild --locked --profile dist \ --target aarch64-unknown-linux-gnu \ --target x86_64-unknown-linux-gnu cargo zigbuild --locked --profile dist --target x86_64-pc-windows-gnu ``` **The trap - and why those are three commands, not one.** Cargo resolves and **unifies features across every `--target` in a single invocation**. A dependency that a `cfg`-gated feature pulls in for one platform therefore leaks into the others: a macOS-only GPU backend gets enabled for the Windows build, or a `cfg(not(windows))` assembly feature drags a crate that `compile_error!`s on Windows into the Windows build. Grouping only the targets that share a feature set into one invocation - and giving the platform-divergent ones their own - is what avoids it. If a cross build fails with an error about a dependency that has no business being there, this is why. One `cargo` invocation is **one feature resolution graph**. "This feature on for the macOS artifact, off for the Linux one" asks a single resolution to hold two contradictory feature sets for the same dependency; cargo deliberately refuses to model that. It is not a missing flag, so stop looking for one and split the invocation. Worse, the leak does not have to come from *your* manifest. A crate three levels down your tree can gate a feature with `[target.'cfg(not(windows))'.dependencies]`, and if Windows shares an invocation with Linux you inherit the breakage with no feature flag of your own to turn it off - the only fixes are per-target invocations or a `[patch]`. So you cannot audit your way out of this by reading your own `Cargo.toml`. **How to actually see the resolved features:** `cargo tree -e features` (and `cargo tree -e features -i <crate>` to ask *why* a feature is on). Do not go looking in `Cargo.lock` - **it does not record features at all**. Cargo's own lockfile decoder says so outright: "It also does not include `features`." Believing otherwise is a common and expensive detour when debugging exactly this class of bug. If you build macOS binaries with Zig, expect to re-sign them (an ad-hoc `rcodesign sign` is enough); Zig's recorded SDK metadata can otherwise trip newer macOS loaders. ### Fan out to installers from a single build Every channel is fed from the same artifacts, so build once and derive the rest. Compute the tarball checksums once, then template them into whatever you publish: - **GitHub Release** - the tarballs plus a `checksums.txt`. Everything else fetches *from* this, so upload it first. - **`cargo-binstall`** - fetches a prebuilt binary instead of compiling on `cargo install`. Declare it in `Cargo.toml`. **The gotcha:** binstall's default URL template uses the **crate** name, but release assets are usually named after the **binary**. When those differ (a crate published under a suffixed name because the good one was taken), the defaults silently miss and every install falls back to a full compile: ```toml [package.metadata.binstall] pkg-url = "https://github.com/myorg/my-app/releases/download/v{ version }/my-app-{ target }{ archive-suffix }" pkg-fmt = "txz" bin-dir = "{ bin }{ binary-ext }" [package.metadata.binstall.overrides."x86_64-pc-windows-gnu"] pkg-fmt = "zip" ``` - **Homebrew** - generate the formula in CI and push it to your tap repo, with the per-target `sha256` values from the build. - **Nix** - render a derivation that `fetchurl`s the release tarballs. A prebuilt glibc binary needs `autoPatchelfHook` to rewrite its interpreter and RPATH to Nix store paths; Mach-O needs no patching. **Ship shell completions pre-generated inside the tarball.** Generating them by *running* the binary at install time fails exactly where you cannot afford it - Nix cannot execute the not-yet-patched binary during its install phase, and cross-built binaries cannot run on the build host at all. With clap, the crate for this is `clap_complete`, and the API you want is `generate_to` - the one documented "for generating at compile-time", writing completion files to a directory from a build script or an `xtask` rather than from the shipped binary at runtime. `clap_mangen` does the same job for man pages. ### Guard what the published crate actually contains `exclude` in `Cargo.toml` keeps your published crate small by dropping tests, fixtures, and docs from the `.crate` file. It is also a loaded gun: **exclude a file the code embeds with `include_str!`/`include_bytes!` and `cargo install` breaks for everyone while `cargo build` in your repo keeps working perfectly.** The failure is invisible locally and can survive several releases. The same blind spot applies to the install path itself. If you ship through Homebrew, Nix, or `cargo-binstall`, nobody on your team ever runs the plain `cargo install <crate>` that compiles from the published `.crate` - so a packaging break can sit undiscovered for weeks while every channel you actually use keeps working. Test that path directly, in a container with nothing pre-installed and no local checkout to fall back on: ```sh docker run --rm rust:slim-bookworm sh -c 'cargo install my-app --locked && my-app --version' ``` The guard below is the cheaper, per-commit half - assert in CI that everything the code embeds appears in the packaged file list: ```sh # Every file the code embeds must survive into the packaged .crate. for f in $(rg -o 'include_(str|bytes)!\("([^"]+)"\)' -r '$2' src/); do cargo package --list | grep -qx "$f" || { echo "embedded file not packaged: $f"; exit 1; } done ``` ### Stop storing a registry token: use Trusted Publishing The pipeline above hands CI a long-lived `CARGO_REGISTRY_TOKEN` out of repository secrets. crates.io now supports **Trusted Publishing**, which removes that secret entirely: "It uses OpenID Connect (OIDC) to verify that your workflow is running from your repository, then provides a short-lived token for publishing." Tokens expire after 30 minutes, and the configuration binds publishing to a specific repository *and workflow filename*, so a compromised unrelated workflow cannot publish. Configure the crate once under Settings → Trusted Publishing on crates.io (naming the repo and the workflow file), then drop the stored secret: ```yaml permissions: id-token: write # required for the OIDC token exchange steps: - uses: actions/checkout@v7 - uses: rust-lang/crates-io-auth-action@v1 id: auth - run: cargo publish env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} ``` Two constraints: the crate must already exist on crates.io, so the very first publish still needs an API token, and there is no `cargo publish --trusted-publishing` flag - the exchange happens in the auth action, which is why the token still arrives through the same env var. GitLab CI is supported in public beta. Both auth methods can be active at once, so migrate first and delete the secret afterwards. ### Keep the working tree clean, or publishing stops `cargo publish` refuses to package a dirty working tree, and **untracked files count as dirty** - so a pipeline that builds artifacts into the repo (a `dist/` directory, generated completions) and then publishes will abort with "files in the working directory contain changes that were not yet committed into git." Precisely: only dirty files that intersect the set of files being packaged trigger it. The tempting fix is `--allow-dirty`. That is the wrong lever - it disables the check that stops you publishing whatever junk is lying around. **Gitignore your build output instead**, so it is not part of the tree cargo inspects. Reach for `--allow-dirty` only when you deliberately intend to publish uncommitted content, which is almost never. Two release-plz settings worth understanding rather than cargo-culting: - `publish_no_verify = true` skips the verification build `cargo publish` runs by default. Legitimate **only** when CI has already compiled that exact code, and only once you have a packaging gate like the one above - it is precisely the check you are turning off. It saves a cold rebuild of the whole dependency tree at publish time. - `semver_check = false` disables [`cargo-semver-checks`](https://github.com/obi1kenobi/cargo-semver-checks). Reasonable for a binary whose library target exists only so the binary and its tests can share code. If anyone actually depends on your library, **leave it on** - it is the thing that stops you shipping a breaking change as a patch bump. ### Why there is no task runner here The pipeline above is plain shell and cargo invocations. A monorepo task runner (moon, Bazel, Nx) buys you remote task caching and cross-project dependency graphs - real wins in a large polyglot monorepo, and dead weight in a single-crate Rust app, where it adds a second task layer over cargo and a second toolchain-management story next to `rust-toolchain.toml`. If you want named tasks, a `Justfile` is enough. For build caching, cache at the compiler level instead (see `dev-environment.md`) - it is finer-grained and helps every build, not just the ones you routed through a task graph. -
testing.md 16.2 KB
# Testing Rust's compiler already eliminates whole bug classes - null derefs, data races, use-after-free, most type errors. That changes the testing calculus: tests that re-verify what the type system guarantees are wasted effort. This file is about the tests that actually earn their keep, and how to organize them so the suite stays fast enough that you keep running it. ## What to test, what to skip The ROI is in what the compiler cannot see: - Business logic, calculations, ranking/ordering rules. - Parsers, serializers, encoders - especially round-trips and edge cases. - State machines and their transitions. - Error paths - the `Err` arms, not just the happy path. - Arithmetic that can overflow, wrap, or truncate. - Behavior at integration boundaries (your code against a DB, an HTTP API, the filesystem). Skip: - Anything the type system already guarantees (an enum cannot hold an out-of-range variant; a `NonZeroU32` cannot be zero). - Trivial getters, setters, and `Default` impls. - Tests that assert a dependency's behavior - that is the dependency's job, and the test breaks every time you bump the version. - "Smoke tests" whose only assertion is "did not panic" or "returned `Ok`". - Constants checked against themselves (`assert_eq!(MAX, 100)` where the code says `MAX = 100`). A useful filter: **write tests for features, not for code.** A good test survives a full reimplementation - if you replaced the function body with an opaque black box that produced the same outputs, the test should still pass. Tests coupled to internal structure break on every refactor and tell you nothing. Watch for weak assertions. `assert!(count >= expected)` passes for buggy code that emits too much; a count-based assertion (`batches == 4`) goes stale the moment batching changes. Assert durable invariants and exact values. One assertion shape to never write: a **negative wall-clock property** - "less than 20 ms elapsed between these two calls", "the cached call was faster than the cold one". It is not a flaky test you can stabilize by loosening the threshold; it is structurally unsound, because nothing guarantees your thread is scheduled at all on a loaded CI runner compiling three other jobs. Assert the thing you actually meant instead: that the second call did not hit the backend (a counter on a fake), that the value came from the cache (an explicit flag), that the work happened once. If you genuinely need to test timing behaviour, control the clock - `tokio::time::pause` and `tokio::time::Instant`, covered in `async-basics.md`. ## The reproduce-then-fix habit The single highest-ROI testing habit: when you find a bug, write the failing test *first*, watch it fail, then fix the bug. Confirm the test actually catches the bug by reverting the fix and seeing it go red again. A regression test that still passes when you delete the fix is worthless - and that happens more often than you would think. ## Purity over "unit vs integration" Stop sorting tests into "unit" and "integration" buckets. Sort them by **purity** - how much I/O they do: - A pure test calls a function with in-memory inputs and checks the output. It is fast and it cannot be flaky. - An impure test touches the filesystem, network, a database, the clock, or global state. It is slower and can flake. Optimize purity hard. I/O - not lines of code under test - is what makes a suite slow and flaky. Push logic into pure functions and test those; keep the impure shell thin. This is why **you should not mock your own code.** Mocking internal layers to make a test "smaller" lowers its fidelity and couples it to your current structure, so it breaks on refactor. Mock *resources*, not code: replace the external, impure things (an HTTP call, a clock, the filesystem) with a fast in-process fake, and let the test exercise as much of your real code as it naturally reaches. A hand-written fake - a `HashMap`-backed store behind your repository trait - usually beats a mocking framework. ## Test organization Rust gives you two homes for tests. **Default: keep tests with the code they test.** Unit tests for `src/parser.rs` belong in `src/parser.rs` - they refactor, move, and get read together with the code, and being co-located means the test layout mirrors the source layout for free. Reserve `tests/` for genuine integration tests: end-to-end exercises of the public API or cross-module behavior. Do not promote a unit test to `tests/` just because it grew - there it loses access to private items and pays a separate-binary link cost for nothing. **Unit tests** live in the file they test, in a `#[cfg(test)]` module. They can reach private items. ```rust // src/parser.rs pub fn parse(input: &str) -> Result<Ast, ParseError> { /* ... */ } #[cfg(test)] mod tests { use super::*; #[test] fn parses_empty_input() { assert_eq!(parse("").unwrap(), Ast::Empty); } } ``` For a long test module, move it to its own file and declare it - Cargo then skips recompiling the library crate when you only change tests: ```rust // at the bottom of src/parser.rs #[cfg(test)] mod tests; // lives in src/parser/tests.rs ``` **Integration tests** live in `tests/`. Each file there is compiled as a *separate crate* that links your library and uses it through its public API only - the same surface your users see. ``` tests/ api.rs # one test binary common/ mod.rs # shared helpers - NOT a test binary ``` Auto-discovery of `tests/` is flat: each top-level `.rs` becomes its own test binary, subdirectories do not. That is why shared helpers go in `tests/common/mod.rs` (the `mod.rs` form), imported with `mod common;` in each test file. A plain `tests/common.rs` would be compiled and run as its own (empty) test binary. One refinement: for a **published library**, also keep at least one integration test in `tests/` that drives the real public API - unit tests alone cannot catch API-ergonomics problems. If an integration suite grows enough to want structure, give it a module tree under one binary (`tests/it.rs` plus a `tests/it/` directory) so the layout can mirror the public surface without paying a separate link step per file. ## Fixtures Put test data files - sample inputs, golden outputs, config samples - in `tests/fixtures/` by default. Cargo's flat `tests/` discovery ignores subdirectories, so `tests/fixtures/` is a safe home for pure data; it is never compiled as a test binary. Mirror the source layout under it - `tests/fixtures/adapter/claude-code/` for `src/adapter/claude-code.rs` - so a fixture's path is predictable from the module it belongs to. Build fixture paths from `env!("CARGO_MANIFEST_DIR")`, the compile-time absolute path to the crate root. It resolves identically from a unit test in `src/` and an integration test in `tests/`, regardless of the process working directory - this is what lets a test co-located with the code load a shared `tests/fixtures/` file cleanly: ```rust let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/sample.json"); let input = std::fs::read_to_string(path).unwrap(); ``` The one justified exception: a small, rarely-changing fixture tied to one module can be co-located with the source and pulled in with `include_str!` / `include_bytes!` (which resolve relative to the current file). That bakes the data into the binary and forces a recompile whenever it changes, so keep it for small, stable fixtures only - and note `include_*!` cannot pull in a directory tree, so multi-file fixtures must live under `tests/fixtures/` regardless. For non-trivial fixtures, keep a short `tests/fixtures/README.md` noting where the data came from and any anonymization applied - it stops fixtures from becoming opaque blobs nobody dares change. ## Keep the suite fast A slow suite is one developers stop running - then they push untested code, which is worse than no suite. Speed is the binding constraint. - **Each file in `tests/` is a separate binary that links separately.** Linking dominates `cargo test` wall-clock for crates with heavy dependencies. Consolidate many suites into one binary - keep a single `tests/integration.rs` and pull each suite from a subdirectory: ```rust // tests/integration.rs - the only file Cargo builds as a test binary #[path = "integration/api.rs"] mod api; #[path = "integration/db.rs"] mod db; ``` `#[path]` is required: `tests/integration.rs` is a crate root, so a bare `mod api;` would resolve to `tests/api.rs`, which Cargo's flat discovery would build as *another* binary. Pointing `#[path]` into `tests/integration/` (a subdirectory Cargo ignores) keeps every suite in one binary - often a several-fold test-build speedup. - Gate genuinely slow tests behind an environment variable so they run on CI but not in the local loop. Do not hide them with `#[cfg(...)]` - conditional compilation means they rot. - Doc tests are compile-checked documentation and run under `cargo test` - keep them for public-API examples. The 2024 edition merges compatible doctests into one compilation unit, so the old "doctests are slow to build" concern is largely gone (they still each run in their own process). A doc comment is `///` above the item (or `//!` at the top of a file, documenting the file itself), and any Rust block inside it is compiled and run: ````rust /// Parses a config file. /// /// # Examples /// /// ``` /// let cfg = mycrate::parse("key = 1")?; /// assert_eq!(cfg.key, 1); /// # Ok::<(), mycrate::Error>(()) /// ``` /// /// # Errors /// /// Returns [`Error::Syntax`] if the input is not valid TOML. pub fn parse(input: &str) -> Result<Config, Error> { /* ... */ } ```` Two mechanics worth knowing up front: a line prefixed with `# ` inside the block is compiled but hidden from the rendered docs, which is how you make an example that uses `?` compile without showing the boilerplate; and `cargo doc --open` renders the whole thing locally. Mark a block ```` ```no_run ```` to compile but not execute it, and ```` ```ignore ```` to do neither - reach for `ignore` rarely, since an unchecked example is exactly the one that rots. ## Async tests Use `#[tokio::test]`. For tests that need real concurrency (two tasks actually contending), use the multi-thread flavor: ```rust #[tokio::test] async fn fetches_user() { assert_eq!(fetch_user(1).await.unwrap().id, 1); } #[tokio::test(flavor = "multi_thread")] async fn two_writers_contend() { /* ... */ } ``` Fire-and-forget concurrency (`spawn` something and drop the handle) is hard to test deterministically - you end up adding `sleep`s to "wait for it." Do not. Design for joinability: return the `JoinHandle`, or signal completion through a channel, so a test can deterministically wait. ## Test isolation Tests run in parallel. Anything touching shared external state needs per-test isolation: - Filesystem: `tempfile::TempDir` gives each test its own directory, cleaned up on drop. - Databases: `#[sqlx::test]` creates and migrates a fresh database per test automatically. - In-memory resources keyed by name: generate a unique name per test (e.g. a UUID) so parallel tests do not collide. `cargo-nextest` runs each test in its own process, which makes isolation failures (tests that share a global, or call `std::env::set_var`) surface as real failures. Treat those as latent bugs to fix, not a reason to serialize the suite. **Environment variables are process-global, and `cargo test` runs tests as threads in one process.** So one test calling `std::env::set_var` corrupts any test that reads that variable - the classic "passes in isolation, fails in the suite" flake. (Rust 2024 makes `set_var` `unsafe` for exactly this reason.) The non-obvious half: isolating the test that *sets* the variable is not enough. **Every test that reads it** must be isolated too, or it will still see the polluted value in a parallel run. Either give each such test a jailed environment (`figment::Jail`, `temp-env`), or thread the config through as a parameter instead of reading the environment inside the code under test - which is the fix that makes the problem go away permanently. ## Lints apply to test and bench code too `cargo clippy --all-targets` lints `tests/` and `benches/`, not just `src/`. That is what you want for real bugs, but the "smells in non-prototype code" lints from the skill's `[lints.clippy]` table - `print_stdout`, and `unwrap_used`/`expect_used` if you enabled them - fire constantly on code where printing and unwrapping are correct by design. Put a crate-level allow at the top of the file rather than sprinkling per-line ones or weakening the lint globally: ```rust // benches/parse.rs #![allow(clippy::print_stdout, clippy::unwrap_used, clippy::expect_used)] ``` A `#[cfg(test)]` support module inside `src/` needs the same treatment, since it is part of the library crate. ## Two profile traps in the test suite **`cargo test` builds with the `test` profile, which inherits `dev`** - so `debug-assertions` is on, and `--release` turns it off. That means a `debug_assert!` **inside a dependency** is live during your test suite and dead in production. A dependency asserting an invariant more strictly than it documents can fail your tests on input that works perfectly in a release build. When a test failure points into a dependency's internals, check whether it is a `debug_assert!` before assuming you found a bug in your own code. **`#![recursion_limit]` is a crate-root attribute, and every file in `tests/` is its own crate root.** When trait solving overflows (`E0275: overflow evaluating the requirement ... Send`, a real risk for deeply-nested async futures under `#[tracing::instrument]`), the attribute must go on the crate that actually overflows. If your library compiles fine but the integration binary does not, it belongs at the top of `tests/integration.rs` - putting it in `src/lib.rs` does nothing for the test crate. Related: an overflow that reproduces only in CI is often a clean-build-vs-incremental-cache difference, not a toolchain mismatch, and trait-solving limits can genuinely differ by target. ## Coverage is a diagnostic, not a target Coverage percentage has sharply diminishing returns - the last 10% costs far more than the first 90% and rarely catches proportional bugs. Use a coverage tool (`cargo-llvm-cov`) to *find* untested branches you care about, then decide case by case. Do not wire a coverage threshold into CI and chase a number; that pressure produces low-value tests that exist only to move the metric. ## The tool kit Start with what the toolchain gives you for free - `#[test]`, `#[cfg(test)]`, `#[tokio::test]`, doctests, parallel execution. Many applications need nothing more. Add as defaults: | Tool | Why | |---|---| | `cargo-nextest` | Faster runs and per-test process isolation for multi-binary workspaces. It does not run doctests - keep `cargo test --doc` as a separate step. Its `slow-timeout` with `terminate-after` auto-kills hung tests, and `cargo nextest bench` now runs benchmark targets through the same runner. | | `rstest` | Parametrized tests: one `#[case(...)]` per row generates an independent, individually-named test. Genuinely cuts table-test boilerplate. | Reach for these when a specific need appears: | Tool | Use it when | |---|---| | `insta` | Output is large or structured (serializer output, API responses, CLI text). Review every diff with `cargo insta review`; never blind-run `cargo insta accept` - an unreviewed baseline certifies nothing. | | `wiremock` | Your code calls a third-party HTTP API - run a real local mock server instead of mocking the HTTP client. | | `#[sqlx::test]` | Database code - per-test isolated database, lighter than `testcontainers`. | | `assert_cmd` + `predicates` | You ship a CLI binary and want to assert on exit code / stdout / stderr. | | `proptest` | You have an invariant or a reference implementation to check against - parsers, codecs, round-trips. Property testing is a *search* strategy: it finds the input combinations you would never write by hand. | | `cargo-llvm-cov` | You want a coverage signal (see above - signal, not target). | Usually skip: - **Mocking frameworks (`mockall`).** Idiomatic Rust - traits plus hand-written fakes - covers most needs without the proc-macro machinery. Reach for `mockall` only on large trait surfaces where you need many call-count/argument assertions. - **`quickcheck`** - `proptest` is generally preferred (its integrated per-value shrinking is more flexible). -
traits-and-generics.md 11.4 KB
# Traits and Generics Traits are how Rust does polymorphism. Generics give you static dispatch (zero runtime cost). Trait objects (`dyn Trait`) give you dynamic dispatch (one vtable lookup). Traits are not Java/C# interfaces in the way that matters most: there is no runtime cost by default. ## Defining and Implementing a Trait ```rust trait Greet { fn hello(&self) -> String; // Default method body - implementors can override fn loud_hello(&self) -> String { self.hello().to_uppercase() } } struct Cat; impl Greet for Cat { fn hello(&self) -> String { "meow".into() } } ``` ## Three Ways to Use a Trait in a Function ```rust // 1. Generic with a trait bound (monomorphized, static dispatch) fn greet1<T: Greet>(x: T) { println!("{}", x.hello()); } // 2. impl Trait sugar (same as above, less verbose, only one type per call site) fn greet2(x: impl Greet) { println!("{}", x.hello()); } // 3. Trait object (dynamic dispatch, one vtable lookup per call) fn greet3(x: &dyn Greet) { println!("{}", x.hello()); } ``` | Form | Dispatch | Code size | When to use | |---|---|---|---| | `<T: Trait>` | Static (monomorphized) | Larger (one copy per type) | The default for library code | | `impl Trait` | Static (monomorphized) | Larger | Same as above; cleaner for one trait bound | | `&dyn Trait` / `Box<dyn Trait>` | Dynamic (vtable) | Smaller, one copy | Heterogeneous collections, plugin-like APIs, when you need to store mixed types | ### When you actually need `dyn Trait` ```rust // Cannot use generics: Vec needs a single concrete type let animals: Vec<Box<dyn Greet>> = vec![ Box::new(Cat), Box::new(Dog), ]; for a in &animals { println!("{}", a.hello()); } ``` If your collection is homogeneous (all the same type), use a generic. If it is heterogeneous, you need `dyn`. ### Dyn compatibility (object safety) Not every trait can be used as `dyn Trait`. A trait must be *dyn compatible* - the rule was called *object safety* before Rust 1.83, and the compiler's `E0038` error and current docs now say "dyn compatible." Common reasons a trait is not dyn compatible: - A method returns `Self` (e.g., `Clone`). - A method is generic (`fn f<T>(&self, x: T)`). - A method has no `&self`/`&mut self`/`self` receiver. If you hit this, either redesign the trait or use a generic instead. ## Trait Bounds and `where` Clauses ```rust fn print_all<T>(xs: &[T]) where T: std::fmt::Display { for x in xs { println!("{x}"); } } // Multiple bounds with + fn print_and_clone<T: std::fmt::Display + Clone>(x: &T) { println!("{x}"); let _y = x.clone(); } ``` Use `where` when bounds get long; it improves readability: ```rust fn complex<T, U>(t: T, u: U) -> Vec<T> where T: Clone + std::fmt::Debug, U: IntoIterator<Item = T>, { u.into_iter().collect() } ``` ## Common Derive Macros `#[derive(...)]` auto-implements traits when the implementation is mechanical. The ones you will use constantly: ```rust #[derive( Debug, // {:?} formatting Clone, // .clone() (deep copy) Copy, // implicit-copy semantics (must also derive Clone; only for plain-data types) PartialEq, Eq, // == and != Hash, // for HashMap/HashSet keys PartialOrd, Ord, // < <= > >= Default, // T::default() )] pub struct User { pub id: u64, pub email: String, } // With serde: #[derive(serde::Serialize, serde::Deserialize)] pub struct Payload { /* ... */ } // With thiserror: #[derive(thiserror::Error, Debug)] pub enum MyError { /* ... */ } ``` Default these on every public struct unless you have a reason not to: `Debug`, `Clone`. Add `PartialEq` and `Eq` if you compare instances. Add `Hash` if you use them as map/set keys. `Copy` only for small types of POD shape (no allocations, no `Drop`). ## `From`, `Into`, `TryFrom`, `TryInto` These four traits are how Rust handles type conversions. Implement `From` and you get `Into` for free. ```rust struct UserId(u64); impl From<u64> for UserId { fn from(n: u64) -> Self { UserId(n) } } let id: UserId = 42.into(); // uses From above let id = UserId::from(42); // same thing // Fallible version impl TryFrom<&str> for UserId { type Error = std::num::ParseIntError; fn try_from(s: &str) -> Result<Self, Self::Error> { Ok(UserId(s.parse()?)) } } let id: UserId = "42".try_into()?; ``` This is also how `?` converts error types: `?` calls `.into()` on the inner error, which uses your `From` impls. ## `Display` and `Debug` Two ways to format a value as a string. They have different audiences. ```rust use std::fmt; struct Money { cents: i64 } // Debug: developer-facing, "{:?}" or "{:#?}", usually #[derive(Debug)] impl fmt::Debug for Money { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Money({})", self.cents) } } // Display: user-facing, "{}", explicit impl impl fmt::Display for Money { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "${}.{:02}", self.cents / 100, self.cents.abs() % 100) } } ``` Derive `Debug` always. Implement `Display` when there is a single, obvious string representation users should see. ## Blanket Implementations A trait can be `impl`'d for any type satisfying some bound. The standard library does this constantly: ```rust // In std: impl<T: Display> ToString for T { ... } // So every Display type automatically gets .to_string() let s: String = 42.to_string(); // works because i32: Display ``` Blanket impls are how `Into` exists for free when you write `From`, how iterator combinators work for any `Iterator`, etc. ## The Orphan Rule You can `impl YourTrait for SomeoneElsesType`, or `impl SomeoneElsesTrait for YourType`, but not both foreign. This is the orphan rule. It exists so that two crates cannot independently implement the same foreign trait for the same foreign type and conflict at link time. Workaround when you need to implement a foreign trait for a foreign type: wrap the foreign type in your own newtype. ```rust struct MyVec(Vec<i32>); impl std::fmt::Display for MyVec { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "MyVec({:?})", self.0) } } ``` The newtype pattern is also how you add type safety to primitives: ```rust struct UserId(u64); struct OrderId(u64); fn lookup(id: UserId) { /* ... */ } // lookup(OrderId(5)) // compile error - good ``` ## Iterator Trait `Iterator` is the most-used trait in Rust. It has one required method (`next`) and dozens of provided combinators (`map`, `filter`, `collect`, etc.). ```rust let total: i32 = (1..=10) .filter(|n| n % 2 == 0) .map(|n| n * n) .sum(); ``` You implement `Iterator` for your own types when they are sequences: ```rust struct Counter { n: u32 } impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { self.n += 1; if self.n <= 5 { Some(self.n) } else { None } } } ``` `for x in collection` desugars to `let mut iter = collection.into_iter(); while let Some(x) = iter.next()`. Three iter methods you must distinguish: | Method | Yields | When | |---|---|---| | `iter()` | `&T` | Read-only iteration | | `iter_mut()` | `&mut T` | Modify in place | | `into_iter()` | `T` (consumes the collection) | Move items out | ## Closures and the `Fn` Traits A closure is an anonymous function that can capture its environment. You write them constantly - every `.map(|x| ...)` is one - and they are ordinary values with ordinary trait bounds. There are three traits, and which one a closure implements is decided by what it *does* with what it captured, not by how you wrote it: | Bound | The closure... | Callable | |---|---|---| | `Fn` | only reads its captures | many times, from a `&self` | | `FnMut` | mutates its captures | many times, needs `&mut` access | | `FnOnce` | consumes its captures (moves something out) | exactly once | They nest: every `Fn` is also an `FnMut` and an `FnOnce`. So take the loosest bound your function actually needs - `FnOnce` for something you call one time, `Fn` when you will call it repeatedly from shared state. ```rust fn call_twice(f: impl Fn(i32) -> i32) -> i32 { f(1) + f(2) } fn consume(f: impl FnOnce() -> String) -> String { f() } let factor = 3; call_twice(|x| x * factor); // Fn: only reads `factor` let mut log = Vec::new(); let mut record = |x| log.push(x); // FnMut: mutates `log` record(1); let owned = String::from("hi"); consume(move || owned); // FnOnce: moves `owned` out ``` `move` forces the closure to take ownership of everything it captures rather than borrowing. You need it whenever the closure outlives the current scope - `thread::spawn`, `tokio::spawn`, anything stored in a struct - which is why those signatures also demand `'static`. Note `move` and `FnOnce` are unrelated: a `move` closure that only reads its captures is still `Fn`. When a trait has one method and no state, a closure is usually the better abstraction than the trait - see the anti-patterns below. ## Common Standard Library Traits to Know | Trait | What it represents | |---|---| | `Clone` | `.clone()` deep copy | | `Copy` | Implicit bitwise copy on assignment (must also be `Clone`) | | `Debug` | `{:?}` formatting | | `Display` | `{}` formatting (user-facing) | | `Default` | `T::default()` | | `From<T>` / `Into<T>` | Conversion between types | | `PartialEq` / `Eq` | `==` | | `PartialOrd` / `Ord` | `<`, `<=`, `>`, `>=` | | `Hash` | Map/set keys | | `Iterator` | Sequence with `.next()` | | `IntoIterator` | "Can be turned into an iterator" - powers `for` loops | | `Drop` | Custom cleanup when value goes out of scope | | `Deref` / `DerefMut` | Custom `*` and method-call autoderef (for smart-pointer-like types only) | | `AsRef<T>` / `AsMut<T>` | Cheap reference-to-reference conversion (e.g., `Path: AsRef<OsStr>`) | | `Borrow<T>` | Like `AsRef`, with stricter equality/hash guarantees (used by `HashMap::get`) | ## Generics: Type, Lifetime, and Const ```rust fn count<T>(xs: &[T]) -> usize { xs.len() } // type generic fn first<'a>(xs: &'a [i32]) -> &'a i32 { &xs[0] } // lifetime generic fn sum<const N: usize>(xs: [i32; N]) -> i32 { xs.iter().sum() } // const generic ``` You will mostly write type generics. Lifetime generics show up when you store references in structs or return references that depend on inputs. Const generics let you parameterize over compile-time values like array sizes; useful for fixed-size buffers and SIMD. ## When to Reach For Generics vs Trait Objects - **Library code, performance-sensitive paths**: generics. Static dispatch, inlining, no heap allocation. - **Heterogeneous collections, plugin systems, runtime-determined behavior**: trait objects (`Box<dyn Trait>`). - **You think you need generics but only have one or two implementors**: just use a concrete type or an enum. ## Anti-Patterns - **Single-method traits that should be closures.** If a trait has one method and no state, `Fn(...)` or `FnMut(...)` or `FnOnce(...)` may be the better abstraction. - **`Deref` for inheritance simulation.** `Deref` is for smart-pointer-like wrappers (`Box`, `Rc`, `MutexGuard`) where the wrapper "is-a" pointer to the inner type. It is not a way to "extend" a struct. - **Excessive generic parameters.** Two or three type parameters is normal. Five or more usually means a design that is too abstract. - **Trait + always one impl.** A trait with one implementor is just an interface for nothing. Inline it until you have a second use case.
-
-
CHANGELOG.md 33.1 KB
# Changelog All notable changes to this skill will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), and this skill adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.6.0] - 2026-09-09 ### Added - references/ownership-and-types.md: the two Book-chapter-8 collections gaps the skill was silent on - `HashMap` with the `entry` API (`or_insert`, `or_insert_with`, `or_default`, and why `contains_key` + `insert` hashes twice), and the fact that a string cannot be indexed. `s[0]` does not compile because a byte offset is not a character offset; `.chars()`, `.char_indices()`, and byte-range slicing are what you actually want, and a range that lands mid-character panics. - SKILL.md "From Python or JavaScript": integer overflow behaves differently per profile. Debug "includes checks for integer overflow that cause your program to _panic_ at runtime"; `--release` drops them and performs "_two's complement wrapping_" - so a passing test suite can still wrap in production. Plus a pointer to the string-indexing material above. - references/async-basics.md: a "Threads First, Async Second" section. The skill sent CPU-bound work to threads (`performance.md`: "use threads or `rayon`") and then only ever showed tokio. Covers `thread::spawn` and its `'static` bound, `thread::scope` for borrowing stack data without `Arc`, `std::sync::mpsc`, and the classic hang where the original `tx` is never dropped so the receiver loop never ends. - SKILL.md Project Structure: items are private by default, including to a parent module. `mod config;` makes a module exist without making anything in it reachable - the source of the most common early "why can't I call this" error - with `pub` vs `pub(crate)` and why `pub(crate)` is what makes the `unreachable_pub` lint useful. - references/testing.md: what a doc comment actually looks like, since the file already told you to keep doctests for public-API examples without showing one. `///` vs `//!`, the `# ` hidden-line trick that lets an example use `?` without showing the boilerplate, and `no_run` vs `ignore`. - references/traits-and-generics.md: a "Closures and the `Fn` Traits" section. The file taught iterator chains built entirely out of closures while naming `Fn`/`FnMut`/`FnOnce` only in a passing anti-pattern. Covers which trait a closure gets (decided by what it does with its captures, not how it is written), that the three nest, and that `move` and `FnOnce` are unrelated. - references/crate-shortlist.md (clap): two CLI footguns that only appear once real users touch the binary. A positional argument beginning with `-` is parsed as a flag unless `--` precedes it; and Rust ignores SIGPIPE at startup - std's own comment reads "we set SIGPIPE to ignore when the program starts up in order to prevent this problem" - so `mytool | head -5` fails instead of exiting quietly. Includes the stable `libc::signal` fix and notes `-Zon-broken-pipe` is nightly-only. - references/async-basics.md: handling SIGTERM, not just Ctrl-C. Nearly every shutdown snippet awaits `tokio::signal::ctrl_c()`, which is SIGINT only, so a service is hard-killed on every container or systemd stop while Ctrl-C keeps working locally. Plus the two follow-on traps: `with_graceful_shutdown` waits for all connections, so one long-lived stream holds shutdown open forever, and `/proc/<pid>/status` `SigCgt` tells you what the shipped binary actually catches. - references/dev-environment.md: mold's own August 2026 benchmark now quantifies the Linux linker choice - it "links 4.9x faster than LLVM lld and 1.9x faster than wild at the median". - references/dev-environment.md: the Cargo Book's "Optimizing Build Performance" chapter (added in Rust 1.92), which is the canonical first-party version of most of this page, along with its fuller debug-info recipe - `[profile.dev.package."*"] debug = false` plus an opt-in `[profile.debugging]` - rather than the `line-tables-only` one-liner alone. - references/dev-environment.md: Cargo's first-party `unused_dependencies` lint, noted as covering the same ground as `cargo machete` but unusable on stable, since "Cargo's linting system is unstable and can only be used on nightly toolchains". - references/dev-environment.md: kache 0.17/0.18 surface - `[cache.volumes]` volume-local shards (what keeps a restore zero-copy when checkout and store live on different volumes), read-only remotes for untrusted CI as a supply-chain boundary, and `explain_miss` alongside `why-miss` in the diagnosis list. - references/error-handling.md: a "Wrap at the Boundary, Not Before It" section. `anyhow::Error` is a one-way door, and wrapping a typed error upstream of a retry loop or a status mapping silently destroys the classification that code needed - the retry loop then treats a permanent failure exactly like a retryable one. - references/crate-shortlist.md: three reqwest 0.13 changes the notes omitted, all of which alter the dependency tree rather than the API - the rustls crypto provider "defaults to aws-lc instead of _ring_", the rustls roots features were dropped for `rustls-platform-verifier`, and native-tls now includes ALPN. - references/crate-shortlist.md: sqlx 0.9's per-crate `sqlx.toml`, and the packaging regression that "`cargo install --locked sqlx-cli` will no longer work". - references/crate-shortlist.md (axum): scope a tower layer to the routes it exists for. A `.layer()` on the root `Router` runs on every route, so a rate limiter meant for one expensive endpoint also throttles static assets and health checks, and the first page load exhausts the bucket. - references/testing.md: never assert a negative wall-clock property. It is not a flaky test that a looser threshold fixes - nothing guarantees the thread is scheduled at all on a loaded runner - so assert the thing you meant (the backend was not hit, the value came from the cache) or control the clock. - references/testing.md: `cargo nextest bench` now runs benchmark targets through the same runner. - references/releasing.md: verify the plain `cargo install <crate>` path in a clean container. Teams that ship via Homebrew, Nix, or binstall never exercise the compile-from-crates.io path, so a packaging break can survive several releases. ### Changed - SKILL.md: the `.gitignore` section claimed committing `Cargo.lock` is "the recommended default for every crate type, libraries included". The Cargo FAQ no longer frames it that way - "whether you do is dependent on the needs of your package" - so the claim now cites the guide's actual wording ("When in doubt, check `Cargo.lock` into the version control system") and keeps the recommendation without overstating its source. - references/dev-environment.md: the CI recipe now pins `actions-rust-lang/setup-rust-toolchain@v2` and drops `-- -D warnings` from the clippy step. v2.0.0 stopped exporting `RUSTFLAGS="-D warnings"` and sets cargo's `build.warnings` instead (its `build-warnings` input defaults to `deny`), because a `RUSTFLAGS` export silently overrides any `target.*.rustflags` the project set. The `@v1` form and why lint flags must follow `--` are kept for anyone still pinned there. - references/dev-environment.md: replaced the mold quote. The current README contains no mention of macOS at all, so "mold's own source says outright that 'mold does not support macOS'" was no longer sourceable; the substance is now carried by mold's self-description as a replacement for "existing Unix linkers" plus its ELF-only architecture list. - references/crate-shortlist.md: sqlx 0.9.0's release date corrected to 2026-05-21 (crates.io publish and release commit), not 2026-05-06. - SKILL.md + references/crate-shortlist.md: three "as of May 2026" date stamps re-stamped to September 2026 after re-verifying the facts behind them - rustfmt's `imports_granularity` and `group_imports` are still nightly-only (tracking issues #4991 and #5083), and jiff is still 0.2.35 pre-1.0 with its 1.0 issue open. - SKILL.md: dropped the "Rust 1.89.0" attribution on `uninlined_format_args` moving to `clippy::pedantic`. Clippy's own CHANGELOG lists the same PR under both 1.89 and 1.90, so the entry now says "the 1.89/1.90 cycle" rather than asserting a version upstream is inconsistent about. - SKILL.md: the Reference Docs list re-describes ownership-and-types, traits-and-generics, async-basics, and error-handling to match what those files now contain, so the router still points at the right file. ### Fixed - references/dev-environment.md: the kache quirks table said "`local_max_size` defaults to **50GiB**" and told you to raise the cap. Since 0.17.0 the default is "5% of the volume that holds the store, rounded to the nearest GiB, then clamped to 5GiB..=100GiB", with 50GiB only as a probe-failure fallback - so the old advice was wrong on a small disk and on a large one, and the fix is to check what you got rather than assume a number. - references/project-shape.md: "Cargo ships a lint (`missing_lints_inheritance`) specifically because so many people assume otherwise" implied a safety net a stable-toolchain reader does not have. The lint exists, but Cargo's whole lint system is nightly-only, so on stable nothing warns about a member that omits `[lints] workspace = true`. ### Security - references/dev-environment.md: the toolchain-currency argument now includes Rust 1.98.1 (2026-09-03), a one-line point release fixing "a miscompilation in generating vtables" - 1.98.0 could emit a vtable with a null pointer where a function pointer belonged, which is undefined behavior in code that compiled cleanly. No new RustSec advisory since the last refresh touches any crate this skill names. Verified against: rust@1.98.1, reqwest@0.13.5, kache@0.18.0, release-plz-action@0.5.135 ## [0.5.1] - 2026-09-09 ### Changed - Description condensed to fit the repo's 250-character limit. ## [0.5.0] - 2026-08-26 ### Added - SKILL.md: Rust 1.96-1.98 sugar and tooling - `assert_matches!`/`debug_assert_matches!` (1.96), plus let-chains (stable in edition 2024 since 1.88) and async closures (1.85), which the "Recent sugar" section skipped even though the skill defaults to edition 2024. - New reference references/project-shape.md, and a pointer to it from SKILL.md's Project Structure section: the multi-crate cliff the skill never covered. Workspaces and `workspace.dependencies` (features can be added by a member, never subtracted), `[workspace.lints]` plus the trap that `[lints] workspace = true` is not implicitly inherited - a member that omits it is silently unlinted, which is why Cargo ships `missing_lints_inheritance`; feature flags including `dep:` (1.60) and the `foo?/bar` conditional form, and why features must be additive; `build.rs` with the `cargo:` -> `cargo::` directive change (1.77) and the two costs of adding one (build-time tool requirements that break `cargo install` for users, and opacity to compiler caches); what `rust-version` now controls, given edition 2024's `resolver = "3"` flips `incompatible-rust-versions` to `fallback`, so a stale MSRV silently holds the whole dependency tree back; and `#[non_exhaustive]` as the right default for a published error enum, with the `E0639` struct-literal error it produces downstream. - references/dev-environment.md: supply-chain hygiene, previously absent entirely - `cargo audit` against the RustSec database as the first step, `cargo deny` with the warning that `cargo deny init` writes a template that fails immediately (an empty license allow-list rejects everything), and `cargo machete` with its documented static-`use`-scan false positives. - references/dev-environment.md: `cargo build --timings` as the free diagnostic to run *before* installing a build cache, plus `cargo fix --edition` and `cargo clippy --fix`. - references/crate-shortlist.md: `tracing_subscriber::fmt()` writes to **stdout** by default (`SubscriberBuilder`'s writer parameter is `W = fn() -> Stdout`, with no TTY detection), which silently corrupts any binary whose stdout carries data - JSON-RPC, MCP, a CLI piping records. `.with_writer(std::io::stderr)` is the documented fix, and the skill's own example inherited the default. Also the `json` feature and `tracing-appender` for production logging. - references/crate-shortlist.md: a table for when the anyhow/thiserror split is not enough - `eyre` (customizable reports), `miette` (source-span diagnostics), `snafu` (per-`?` context selectors), `error-stack` (attachable context stack). - references/releasing.md: crates.io Trusted Publishing - OIDC via `rust-lang/crates-io-auth-action@v1` with 30-minute tokens, removing the stored `CARGO_REGISTRY_TOKEN` secret, with the two constraints (first publish still needs an API token; there is no `cargo publish --trusted-publishing` flag). - references/releasing.md: `dist` hard-refuses a Linux-host -> macOS cross-build via a typed `UnsupportedCrossCompile` error ("cross-compiling to macOS is a road paved with sadness - we cowardly refuse to walk it"), which is the concrete decision criterion between Route A and Route B. - references/releasing.md: `clap_complete`'s `generate_to` named as the compile-time API the existing "ship pre-generated completions" advice requires, plus `clap_mangen`. - references/async-basics.md: `#[tokio::test(start_paused = true)]` cannot control `std::time::Instant`, so production code must use `tokio::time::Instant` to be testable at all (behavior is identical outside a paused runtime); advance with `advance()`, not `sleep()`. Plus `tokio-console` for diagnosing a task that is not being polled, and a note that `async-std` is discontinued. - references/testing.md: `cargo clippy --all-targets` lints `tests/` and `benches/`, so `print_stdout`/`unwrap_used`-style lints need a crate-level allow header there. ### Changed - SKILL.md Minimal Cargo.toml: `unsafe_code` is now `"deny"` rather than `"forbid"`. `forbid` cannot be lifted by a local `#[allow]` with a SAFETY justification, and that bites on ordinary `mmap`, not only on FFI - the previous "downgrade to deny if you do FFI" comment understated when it matters. - references/dev-environment.md: rewrote the kache quirks table against 0.16.0 (pin was 0.9.0, seven minors back). Four claims had gone stale: `cache_executables` now defaults to **`true` on Linux and macOS** (only Windows keeps `false`), so the "turn it on" advice is removed; eviction has been **cost-aware** since 0.12.0, indexing each entry's `compile_time_ms` rather than ranking by size, which retires the "LRU throws away your expensive artifacts first" failure mode (the 50GiB cap stands, now with its 10% hysteresis band); the cache key hashes the `rustc --version --verbose` banner plus the linker's `--version`, with no LLVM-version component as previously claimed; and the path-leak detector is `KACHE_LOG=warn`, not `KACHE_LOG=kache=warn`. The undocumented S3 virtual-hosted/`NoSuchKey` row is replaced with the documented endpoint requirement (kache always addresses path-style). - references/dev-environment.md: "kache disables incremental compilation - do not turn it back on" no longer holds unconditionally; `adaptive_incremental` defaults to `true` and hands a repeatedly-missing crate an isolated incremental directory for a bounded run. Adds `cache.incremental_crates`, workspace-level `[[workspace.extra_inputs]]`, `kache sync`'s non-zero exit on partial failure (0.15.0) with `--allow-partial`, the proc-macro env-var keying fix (0.13.0), the schema v27 one-time cold miss, and the `KACHE_PRESERVE_INCREMENTAL` caveat on `KACHE_DISABLED=1`. - references/releasing.md: `breaking_always_increment_major` was presented as a release-plz config key alongside `features_always_increment_minor`. It is not one - it exists only as a Rust API on the version updater, so readers were being sent to look for a `release-plz.toml` key that does not exist. - references/releasing.md: "a pull request opened by `GITHUB_TOKEN` does not trigger workflows" restated - GitHub now creates those runs in an approval-required state rather than not at all. The practical failure and the PAT recommendation are unchanged. - references/dev-environment.md: sccache's "leaves incremental compilation on (which is what keeps your own workspace crates rebuilding fast)" was misleading - sccache does not change the setting, but "Incrementally compiled crates cannot be cached" either way, which is why the skill's own CI line sets `CARGO_INCREMENTAL=0`. Its linker-crate exclusion is now quoted directly. - references/dev-environment.md + releasing.md: `actions/checkout@v6` -> `@v7`. - SKILL.md + references/dev-environment.md: `cargo install --locked bacon` at both occurrences, matching bacon's own documented install command. - references/dev-environment.md: the macOS linker note now cites mold's own "mold does not support macOS" and reflects wild's move to `wild-linker/wild` (published as the `wild-linker` crate), whose Mach-O support is still listed as unsupported. - references/performance.md: Rust 1.97 made **v0 symbol mangling the default**, which can defeat older profilers and debuggers and changes backtrace text formatting - so garbled frames after a toolchain bump are usually a stale tool, not a broken build. `cargo-flamegraph` on Linux now needs `--no-rosegment` because rust-lld is the default linker since 1.90. - references/performance.md: `dhat` is no longer described as "native-speed" - its docs warn the slowdown "can be large" - and its maintenance caveat is noted. - references/async-basics.md: `block_in_place` "works only on the multi-threaded runtime" was too strong; calling it outside a runtime is allowed and simply runs the closure. The `current_thread` panic is the actual constraint. - references/error-handling.md: clippy's `unwrap_used` does not flag literally every `.unwrap()` - `allow-unwrap-in-consts` defaults to `true`. - references/crate-shortlist.md: the jiff migration list claimed more than upstream shows - the arrow-rs and jj-vcs changes are still open PRs, while kube-rs and k8s-openapi have landed. Adds the `jiff-chrono-conversions` bridge, `jiff-sqlx` tracking sqlx 0.9, jiff's 0.2 support commitment (critical fixes for a year after 1.0), and the note that chrono's deprecation lives in an issue thread rather than its README. - SKILL.md: the unbounded-read anti-pattern now attributes its quote to `BufRead::read_line`, where std actually carries the warning, and explains that `.lines()` inherits it by delegation. ### Deprecated - references/crate-shortlist.md: `serde_yaml` and `bincode` were listed as plain "other formats" with no caveat. `serde_yaml` is archived at `0.9.34+deprecated` with no official successor; `bincode` 3.0.0 is a tombstone release whose entire `src/lib.rs` is `compile_error!("https://xkcd.com/2347/")`, so `bincode = "3"` fails to compile rather than failing at runtime - 2.0.1 is the last usable version. ### Security - references/dev-environment.md: notes that Rust 1.96.0 shipped Cargo fixes for CVE-2026-5222 and CVE-2026-5223, and 1.96.1 patched CVE-2025-15661, CVE-2026-55199 and CVE-2026-55200 in vendored libssh2 - a concrete argument that a `rust-toolchain.toml` pin is for reproducibility, not for freezing. Verified against: rust@1.98.0, reqwest@0.13.4, jiff@0.2.35, kache@0.16.0 ## [0.4.3] - 2026-08-21 ### Changed - Declared ClawHub browse categories (`development`) and topics in `metadata`, so the release pipeline publishes them instead of leaving the skill in the `other` category. ### Removed - `skill-card.md`. The ClawHub CLI strips a root `skill-card.md` from every publish and the registry generates its own card, so the authored file never reached ClawHub. ## [0.4.2] - 2026-08-07 ### Changed - Trimmed the frontmatter description to what-plus-when; dropped the trailing trigger-keyword enumeration (semantic matching makes it redundant). ### Fixed - `metadata.upstream` pinned the floating series `release-plz@0.5`; now tracks the concrete GitHub Action release `release-plz-action@0.5.131` (the artifact references/releasing.md actually uses). Verified against: release-plz-action@0.5.131 ## [0.4.1] - 2026-07-22 ### Added - skill-card.md release record following NVIDIA's skill-card format - metadata.openclaw block (emoji, homepage) for ClawHub display ## [0.4.0] - 2026-07-14 ### Added - New reference references/releasing.md: shipping a Rust binary. Two routes, presented as alternatives rather than one blessed path - `dist` (formerly cargo-dist; community-maintained again after axo wound down, and the batteries-included default) versus a hand-rolled `release-plz` + `cargo-zigbuild` pipeline given as one worked reference implementation. Covers the design decisions that are not obvious from the YAML: build binaries before publishing (crates.io publishes are irreversible), detect a pending release via the git tag because release-plz's dry-run always reports `releases_created=false`, per-job concurrency so a cancel cannot kill a release mid-publish into an unrecoverable state, a PAT rather than `GITHUB_TOKEN` so the release PR actually triggers CI, the `[profile.dist]`/`[profile.release]` split, cross-compiling every target from one Linux runner with `cargo-zigbuild` plus the cross-target feature-unification trap, fanning out to cargo-binstall (with the crate-name-vs-binary-name URL template gotcha), Homebrew, and Nix from a single build, shipping pre-generated shell completions, and guarding `exclude` against dropping a file the code embeds via `include_str!` (an invisible `cargo install` break). Notes why no monorepo task runner appears. - references/dev-environment.md: kache (https://github.com/kunobi-ninja/kache) is now the recommended build cache on both macOS and Linux - install and `kache init` setup, the `[cache.remote]` S3 block, and a quirks table covering the traps that actually cost time: the 50GiB default store cap (LRU-evicts the expensive artifacts first and mimics broken cross-path reuse), count vs cost-weighted hit rate, `cache_executables = false` by default, `kache sync --push` filtering to workspace members only, the service daemon not inheriting shell env (use an AWS profile for S3 credentials), the base-vs-virtual-hosted endpoint, upgrades orphaning the launchd/systemd service, keys encoding toolchain identity rather than machine identity (prefix per toolchain, not per machine), `key_salt` for toolchain changes the key cannot see, `extra_inputs` for macro-read files such as sqlx's `.sqlx/` and `migrations/` (a stale-hit hazard), and the container/NFS cache-directory rule. Plus a diagnosis recipe (`kache stats`, `kache list --sort size`, `kache why-miss`, the `KACHE_LOG=kache=warn` path-leak detector) and a note that upgrades never need a cache wipe. - references/dev-environment.md CI section: `kunobi-ninja/kache-action@v1` as the upgrade path once `Swatinem/rust-cache` is outgrown, with the caveat that cross-machine hits require an exactly matching toolchain. - SKILL.md anti-patterns: reading untrusted input with `.lines()`/`read_line`, which allocate without bound (std's docs warn an attacker can send bytes forever without a newline); bound with `Read::take(n)` and drain with `BufRead::skip_until` (stable 1.83). - references/async-basics.md: the `!Send` compile error on a `std::sync::MutexGuard` held across `.await` is a different problem from the *design* question of holding a `tokio::sync::Mutex` across `.await` - which is sometimes correct, single-flight being the clearest case (N concurrent cold-start callers coalesce into one expensive load). New pitfall: `impl Stream` in a signature does not make the body stream. - references/performance.md: `into_iter()` rather than `.iter()` when transforming an owned collection, as a peak-memory lever. New section on why high RSS is usually allocator retention rather than a leak, and why swapping the global allocator to `jemalloc`/`mimalloc` is a measure-first move rather than a free win - the real lever is the transient peak. - references/testing.md: `cargo test` uses the `test` profile (inherits `dev`, so `debug-assertions` is on), which means a `debug_assert!` inside a *dependency* can fail the suite on input that is fine in release. `#![recursion_limit]` is a crate-root attribute and each `tests/*.rs` is its own crate root, so an E0275 trait-solving overflow in an integration binary needs it there, not in `src/lib.rs`. Env-var test pollution: isolating the test that *sets* a variable is not enough - every test that *reads* it must be isolated too. - references/dev-environment.md: any `RUSTC_WRAPPER` puts the build cache in the failure path, so a broken wrapper surfaces as a baffling compile error - bypass it (`KACHE_DISABLED=1`, or `RUSTC_WRAPPER=`) before trusting the failure. Caveat against `--all-features` in lint/test jobs, which force-enables optional features needing toolchains the runner lacks (CUDA/`nvcc`, GPU SDKs). - references/releasing.md: one cargo invocation is one feature-resolution graph (so per-target artifacts with different feature sets are impossible by design, not a missing flag), and the cross-target feature leak can originate in a transitive dependency you have no flag to control. `cargo tree -e features` inspects resolved features - `Cargo.lock` does not record them at all. release-plz downgrades `feat:` to a patch bump on 0.x (and `feat!:` to `0.(x+1).0`, not 1.0.0). Squash-merging a release-worthy PR under a `ci:`/`chore:` title erases the `feat:`/`fix:` commit and produces no release. `cargo publish` aborts on a dirty tree (untracked build output counts) - gitignore the output rather than reaching for `--allow-dirty`. ### Changed - references/dev-environment.md: the "Build speed" section is reorganized around one caching recommendation instead of two platform stories. sccache is retained as the conservative alternative (and as a `KACHE_FALLBACK` chain target) rather than the macOS headline; linker guidance is now a platform note independent of the cache choice. Notes that kache disables incremental compilation while it wraps rustc - the point where its advice diverges from sccache's. - SKILL.md: reference-list entry for dev-environment.md updated to mention kache and CI; releasing.md added to the list; releasing and distribution added to the description's triggers. Verified against: kache@0.9.0, dist@0.32.0, release-plz@0.5 ## [0.3.1] - 2026-07-10 ### Changed - CHANGELOG preamble pinned to Keep a Changelog 2.0.0 (format unchanged; KaC 2.0.0 keeps existing changelogs valid). ## [0.3.0] - 2026-05-21 ### Added - New reference references/testing.md: a pragmatic, anti-dogma testing guide - what to test vs what to skip, the purity-over-extent reframe, "mock resources, not your own code", reproduce-then-fix regression tests, test organization (tests with the code mirroring the source layout; `tests/` for real integration tests only; `tests/common/mod.rs`; the single-binary `#[path]` layout; fixtures in `tests/fixtures/` via `env!("CARGO_MANIFEST_DIR")` with a fixtures README), doctests, async tests, and a minimal high-value tool kit. - New reference references/dev-environment.md: development setup and build / dev-loop speed - `cargo check` as the fast inner loop, incremental compilation, platform-aware build-speed guidance (on macOS, set up sccache as a global dependency cache and skip linker tuning; on Linux, `rust-lld` is the default linker since Rust 1.90), a minimal CI workflow recipe built on `actions-rust-lang/setup-rust-toolchain`, dev-profile tuning, and `bacon`. - New reference references/performance.md: runtime performance of real apps - the measure-first discipline and keeping perf harnesses in a committed home (not `/tmp` or inline shell), profiling (the `profiling` build profile, samply, `dhat` for heap), benchmarking (`benches/` + `harness = false`, criterion vs divan, `std::hint::black_box`, CI regression benchmarking with CodSpeed/Bencher), the `[profile.dist]`-vs-`[profile.release]` split, and common real wins. - references/async-basics.md: note that `tokio::fs` is backed by `spawn_blocking` (no true async file I/O on most OSes) plus batching guidance. - SKILL.md Day-1 Setup: `cargo update`, with a note that it only moves within existing semver ranges. - SKILL.md idioms: the compiler-as-worklist refactor trick (a mandatory no-`Default` field turns every constructor into a compile error). - SKILL.md: formatting-discipline note in the rustfmt section; "testing", "benchmarking", and build-speed triggers added to the skill description. ### Changed - Adopted sqlx 0.9.0 (released 2026-05-06): bumped the dependency in references/crate-shortlist.md from 0.8 to 0.9 and added a 0.9 notes callout (repo moved to the transact-rs org; MSRV raised to 1.94; runtime `query()`/`query_as()` functions now take `impl SqlSafeStr` - the `query_as!` macro is unaffected). - Bumped tracked axum version 0.8.8 -> 0.8.9. - SKILL.md: dropped `unwrap_used`/`expect_used` from the Minimal Cargo.toml lints - both are clippy `restriction`-group lints (a deliberate per-project opt-in, not a day-1 default), and linting `expect_used` discourages `.expect("reason")`, the recommended way to document a can't-fail invariant. Anti-pattern #4 refined accordingly; references/error-handling.md now covers `unwrap_used` as an opt-in with the companion `clippy.toml` `allow-unwrap-in-tests` setting. - SKILL.md: reframed the `Rc<RefCell>`/`Arc<Mutex>` anti-pattern to first ask whether shared mutable state is needed at all (plain ownership or a channel is usually cleaner). ### Fixed - references/crate-shortlist.md: axum 0.8 MSRV corrected from 1.78 to 1.80 (raised in axum 0.8.9); jiff example now imports `ToSpan` (the `1.hour()` snippet did not compile); added a reqwest 0.13 notes callout (rustls is now the default TLS backend, `query`/`form` are now opt-in features). - references/traits-and-generics.md and async-basics.md: "object safety" updated to the current term "dyn compatibility" (renamed in Rust 1.83). - references/async-basics.md: corrected the `tokio::task::block_in_place` description - it runs a blocking section inside the current task and panics on a `current_thread` runtime; it does not bridge async to sync. - SKILL.md: the `?`-propagation example uses `toml::from_str` (the canonical API) instead of `toml::from_slice`; the `.gitignore` no longer teaches the retired "ignore `Cargo.lock` for libraries" rule; "Recent sugar" now names the Rust 1.95 feature `if let` guards (not let-chains); clippy `uninlined_format_args` comment corrected to Rust 1.89.0 (mid-2025). - references/ownership-and-types.md: `Copy` types list corrected to include floats and arrays. - references/dev-environment.md: clarified that sccache cannot cache proc-macro or other linker-invoking crates. - references/testing.md: doctest guidance updated for the 2024 edition (compatible doctests are merged); softened two over-absolute claims. Verified against: rust@1.95.0, axum@0.8.9, reqwest@0.13.3, sqlx@0.9.0, jiff@0.2.24 ## [0.2.0] - 2026-05-07 ### Changed - Bumped axum guidance from 0.7 to 0.8 in SKILL.md and references/crate-shortlist.md (path syntax `/{id}` instead of `/:id`, `Option<T>` extractor reworked, `Host` moved to `axum-extra`, MSRV 1.78). - Bumped reqwest guidance from 0.12 to 0.13 in references/crate-shortlist.md. - sqlx offline-mode docs: replaced `sqlx-data.json` with `.sqlx/` directory; added notes on `--workspace`, `--check`, and `SQLX_OFFLINE=true`. - chrono/jiff guidance rewritten: chrono soft-deprecated by maintainer (Jan 2026); jiff is recommended for new code but still pre-1.0. Updated SKILL.md crate table and references/crate-shortlist.md chrono section. - Refreshed stale "as of April 2026" timestamp in rustfmt section. ### Added - Brief mention of Rust 1.95 sugar (`cfg_select!` macro, let-chains in match arm guards) in the idioms section of SKILL.md. - Comment in Cargo.toml lints noting that clippy's `uninlined_format_args` was moved to `pedantic` (allow-by-default). - Expanded `metadata.upstream` to track volatile crates: `axum`, `reqwest`, `sqlx`, `jiff` alongside `rust`. ## [0.1.0] - 2026-04-29 ### Added - Initial release. Practical day-1 Rust development skill. - SKILL.md covering the Rust mental model (ownership, aliasing XOR mutability, errors as values, traits not interfaces, the borrow checker as design oracle), the 3 questions for every function signature, day-1 decision table, idioms to internalize early, "coming from X" deltas (Python/JS, Go, Java/C#, C++), the crate shortlist, top anti-patterns, what to defer, minimal Cargo.toml + lints + rustfmt.toml + rust-toolchain.toml, project structure, learning path. - references/ownership-and-types.md: ownership, borrowing, lifetimes, `String`/`&str`/`Cow`, `Vec`/slice/array, smart pointers (`Box`/`Rc`/`Arc`/`RefCell`/`Mutex`), `MutexGuard` across `.await` pitfall, self-referential struct trap. - references/error-handling.md: `Result`, `?`, `anyhow` for apps, `thiserror` for libraries, custom error enums, `panic!` vs `unwrap` vs `expect`, `From`-driven error conversion. - references/traits-and-generics.md: trait definitions, generic vs `impl Trait` vs `dyn Trait`, object safety, common derives, `From`/`Into`/`TryFrom`, `Display`/`Debug`, blanket impls, the orphan rule, iterator trait, common stdlib traits, generics flavors (type/lifetime/const). - references/async-basics.md: `tokio` runtime, `#[tokio::main]`, spawning, `Send`/`Sync`/`'static` bounds, `MutexGuard` pitfalls, `spawn_blocking`, `select!`/`join!`/`try_join!`, channels, async in traits, common pitfalls. - references/crate-shortlist.md: minimal usage examples for `serde`/`serde_json`, `tokio`, `anyhow`, `thiserror`, `clap`, `reqwest`, `tracing`, `axum`, `sqlx`, `chrono`, plus an honorable-mentions table. Verified against: rust@1.95.0 -
LICENSE.txt 8.9 KB
Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS -
SKILL.md 23.8 KB
--- name: rust-dev description: Day-1 guide to building well in Rust - ownership, errors as values, String vs &str, Box/Rc/Arc, anyhow vs thiserror, and a crate shortlist (tokio, serde, axum, sqlx). Use when starting a Rust project, fighting the borrow checker, or picking crates. metadata: version: "0.6.0" categories: "development" topics: "rust, ownership, cargo, crates, tokio" upstream: "rust@1.98.1, axum@0.8.9, reqwest@0.13.5, sqlx@0.9.0, jiff@0.2.35, kache@0.18.0, dist@0.32.0, release-plz-action@0.5.135" openclaw: homepage: https://github.com/tenequm/skills/tree/main/skills/rust-dev emoji: "🦀" --- # Rust Development - Day 1 A practical foundation for writing Rust apps well from the first commit. Not a textbook. Focuses on the differences from other languages, the day-1 decisions that shape everything else, and the small set of crates that cover most real apps. ## When to Use - Starting a new Rust project (CLI, service, library) - Coming to Rust from Python, JavaScript, Go, Java/C#, or C++ - Choosing between owned/borrowed types, smart pointers, trait objects vs generics - Picking error handling strategy (`anyhow` vs `thiserror`) - Deciding which crates to reach for - Configuring a minimal but opinionated `Cargo.toml`, clippy, and rustfmt ## Day-1 Setup ```bash # 1. Install the toolchain (rustup is the toolchain manager) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # 2. Confirm components (rustfmt and clippy ship with stable, rust-src enables IDE features) rustup component add rustfmt clippy rust-src # 3. Create a project cargo new my-app # binary (src/main.rs) cargo new --lib my-lib # library (src/lib.rs) # 4. The dev loop (memorize these four) cargo check # fast type-check, no codegen cargo run # build and run (binary) cargo test # build and run tests (incl. doctests) cargo clippy # lint (run before pushing) cargo fmt # format # 5. Manage dependencies without editing Cargo.toml by hand cargo add tokio --features full cargo remove tokio cargo update # recompute Cargo.lock within existing semver ranges ``` `cargo update` only moves within the version ranges already in `Cargo.toml`. Crossing a major version (`1.x` to `2.0`) needs a `Cargo.toml` edit or `cargo add <crate>@2`. **rust-analyzer is mandatory.** It is the language server every editor uses (VS Code, Zed, Neovim, Helix, RustRover uses its own engine but is comparable). In VS Code, install the `rust-analyzer` extension and set `rust-analyzer.check.command` to `"clippy"` so you get lint feedback on save. **Want a file watcher later?** `cargo install --locked bacon`, then run `bacon` in your project. Not needed on day 1. ## The Rust Mental Model in 5 Ideas Rust trades two things you take for granted in most languages (a garbage collector and exceptions) for compile-time guarantees about memory, data races, and error handling. The shape of the language follows from that trade. ### 1. Ownership: every value has exactly one owner Think of values like physical objects. A book, a file, a network connection. At any moment, **one variable owns it**. You can: - **Move it**: `let b = a;` hands ownership to `b`. `a` is gone. - **Borrow it immutably**: `&a` lets others look at it. Many readers allowed. - **Borrow it mutably**: `&mut a` lets one person modify it. Exclusive access. - **Clone it**: `a.clone()` makes a deep copy. Both keep their own. When the owner goes out of scope, the value is dropped (memory freed, file closed, lock released). No GC, no manual `free`. This is RAII, enforced by the compiler. ### 2. Aliasing XOR mutability At any moment, a piece of data has **either**: - one mutable reference (`&mut T`), **or** - any number of immutable references (`&T`), never both. This single rule is what eliminates data races and most use-after-free bugs. The borrow checker enforces it. When it complains, it is telling you your data ownership story is unclear, not that the language is being difficult. ### 3. Errors are values, not exceptions There is no `try`/`catch`. Functions that can fail return `Result<T, E>`. Functions that can return nothing useful return `Option<T>`. The compiler forces you to handle both. The `?` operator propagates errors up the call stack with one character: ```rust fn read_config() -> Result<Config, anyhow::Error> { let text = std::fs::read_to_string("config.toml")?; // ? = early-return on Err let config = toml::from_str(&text)?; Ok(config) } ``` There is no `null`. `Option<T>` is `None` or `Some(value)`. The compiler will not let you forget the `None` case. ### 4. Traits are not Java interfaces A `trait` defines behavior. Types `impl` traits. So far so familiar. The differences: - **Static dispatch is the default.** When you write `fn f<T: Display>(x: T)`, the compiler generates a separate copy of `f` for each concrete `T` you call it with (monomorphization, like C++ templates). Zero runtime overhead. - **Dynamic dispatch is opt-in** via `dyn Trait` (typically `Box<dyn Trait>` or `&dyn Trait`). One vtable lookup per call. - **No inheritance.** Traits compose. If you find yourself reaching for `Deref` to "extend" a type, stop and use composition or an enum. - **Orphan rule**: you can `impl YourTrait for SomeoneElsesType` or `impl SomeoneElsesTrait for YourType`, but not both foreign. This keeps dependency resolution sane. ### 5. The borrow checker is a design oracle The most common newcomer mistake is treating compiler errors as obstacles to silence. They are not. Almost every borrow-check error reveals a real issue with **who owns what**. When you get stuck, the question is rarely "how do I make this compile" and almost always "what is the actual ownership relationship I want here?" Read the error. The compiler is unusually informative. ## The 3 Questions for Every Function Signature Before writing a function, ask: does it need to **own**, **read**, or **modify** the input? ```rust fn consume(s: String) // owns: function takes responsibility, caller loses it fn read(s: &str) // reads: function looks at it, caller keeps it fn modify(s: &mut String) // mutates: function changes it in place ``` Defaults that work 90% of the time: - Function parameters: prefer `&str` over `String`, `&[T]` over `Vec<T>` (these are slices, accept both owned and borrowed callers). - Function returns: return owned types (`String`, `Vec<T>`). Returning references means lifetimes; avoid until you need them. - Struct fields: prefer **owned** types (`String`, `Vec<T>`). Storing `&str` in a struct is the single most common newcomer trap and it cascades lifetime annotations through every type that holds your struct. ## Day-1 Decision Table One-line answers to the choices that come up first. | Decision | Default | When to pick the other | |---|---|---| | `String` vs `&str` (struct field) | `String` | Almost never `&str` until you have a real reason and understand lifetimes | | `String` vs `&str` (function param) | `&str` | Use `String` only if you must own/store it inside | | `Vec<T>` vs `&[T]` (param) | `&[T]` | `Vec<T>` only if you must own | | `Box<T>` vs `Rc<T>` vs `Arc<T>` | `Box<T>` (single owner, heap) | `Arc<T>` for shared ownership across threads. Avoid `Rc<T>` as default; use `Arc<T>` so you do not refactor when you go async | | `RefCell<T>` vs `Mutex<T>` | `Mutex<T>` (or `RwLock<T>`) | Same reason: works in async/threads, while `RefCell` does not | | `Option<T>` vs `Result<T, E>` | `Option<T>` for "no value", `Result<T, E>` for "failed for a reason" | If the absence carries meaning the caller should handle, `Result` | | `dyn Trait` vs `impl Trait` / `<T: Trait>` | Generic (`<T: Trait>` or `impl Trait`) - static dispatch | `Box<dyn Trait>` when you need a heterogeneous collection (`Vec<Box<dyn Animal>>`) | | Errors in app code | `anyhow::Result<T>` everywhere | - | | Errors in library code | `thiserror`-derived enum | Never `Box<dyn Error>` in public library APIs - forces callers to downcast | | `&self` vs `&mut self` vs `self` | `&self` for getters, `&mut self` for setters, `self` for builders/consuming ops | - | | Module layout | Inline modules until a file gets long, then split | One module = one file is a Java/C# instinct, not a Rust one | ## Idioms to Internalize Early These appear in nearly every Rust program. Learn them in week 1. **`?` for error propagation.** Replaces nine lines of `match` with one character. ```rust let body = reqwest::get(url).await?.text().await?; ``` **Iterator chains over manual loops.** Compile to the same machine code as hand-written loops (LLVM inlines closures). Idiomatic Rust is functional in style. ```rust let active_emails: Vec<String> = users .iter() .filter(|u| u.active) .map(|u| u.email.clone()) .collect(); ``` **`match` exhaustiveness.** Add a new variant to an enum and every `match` that does not handle it becomes a compile error. Use this. It is one of the most powerful refactoring tools in any language. **Let the compiler drive refactors.** The same trick works on structs: add a mandatory field with no `Default` and every existing constructor becomes a compile error - a free, exhaustive worklist of every site to update. **`if let` and `let else`** for the common single-arm match. ```rust if let Some(name) = user.name { println!("hi {name}"); } let Some(name) = user.name else { return Err(anyhow!("no name")); }; // `name` is in scope from here on, no nesting ``` **`From` / `Into` for type conversions.** Implement `From`, get `Into` for free. `?` uses `From` to convert error types automatically. **Combinators on `Option` / `Result`.** Reach for `.map`, `.and_then`, `.unwrap_or`, `.unwrap_or_else`, `.ok_or` before reaching for `match`. **Derive macros.** `#[derive(Debug, Clone, PartialEq)]` gets you 80% of the boilerplate for free. Add `#[derive(Serialize, Deserialize)]` for JSON. **Recent sugar.** Stabilized features worth knowing, newest first: `cfg_select!` is a compile-time `match` over `cfg` predicates, replacing most uses of the `cfg-if` crate, and `if let` guards work on `match` arms (`match x { Some(v) if let Ok(n) = v.parse::<i32>() => ... }`) - both 1.95. `assert_matches!` and `debug_assert_matches!` (1.96) assert on a pattern rather than equality, which is the natural assertion for an enum. Two older ones you will see constantly and should not mistake for exotic: **let-chains** (`if let Some(x) = a && x > 3 { ... }`), stable in edition 2024 since 1.88, and **async closures** (`async || { ... }`, with the `AsyncFn` family of bounds), stable since 1.85. ## Coming From X, Here Is What Bites You **From Python or JavaScript:** - `let b = a;` for a heap value (like `String`, `Vec`) **moves** it. `a` is no longer usable. Use `&a` to borrow or `a.clone()` to copy. - No null. `Option<T>` is forced on you. - No exceptions. `Result<T, E>` and `?`. The compiler will not let you ignore errors. - No inheritance. Composition + traits + enums. - Variables are immutable by default. Add `mut` to mutate. Same for references: `&` vs `&mut`. - Integer types are explicit and indexing requires `usize`. They also overflow differently depending on the build: debug "includes checks for integer overflow that cause your program to _panic_ at runtime", while `--release` drops them and performs "_two's complement wrapping_". A test suite that passes can still wrap in production, so use `checked_*`/`saturating_*`/`wrapping_*` where the arithmetic can actually reach the edge, rather than relying on the debug panic to find it. - A `String` is not indexable. `s[0]` does not compile - see `references/ownership-and-types.md` for what to reach for instead. **From Go:** - Errors as values - same instinct, but use `?` instead of `if err != nil`. - No `nil`. `Option<T>`. - No GC and no goroutines: ownership + borrowing, async/await with `tokio`. The async model is cooperative (`await` is an explicit yield point), not preemptive. - `interface{}` becomes traits. Default to generics for static dispatch; `Box<dyn Trait>` only when you need it. - Static linking is the default. Binaries are bigger but self-contained. - `panic!` should be reserved for unrecoverable bugs in app code; do not use it as Go-style "log and continue". **From Java or C#:** - Traits are not interfaces with virtual dispatch by default. `<T: Trait>` is monomorphized. `dyn Trait` is the opt-in dynamic version. - No null references. `Option<T>`. - No exceptions. `Result<T, E>` and `?`. - No class inheritance. Use enums for sum types, traits for shared behavior. - No GC: ownership and borrowing decide lifetimes. `Arc<T>` is the closest thing to a Java reference. - Generics are monomorphized, not type-erased. **From C++:** - Like RAII, but the borrow checker enforces it at compile time. - No copy/move constructors. `Clone` is explicit and `Copy` is a marker trait for cheap bitwise copies. - No undefined behavior in safe code (in theory). - `&` is a compile-time-checked borrow, not a raw pointer. Raw pointers exist (`*const T`, `*mut T`) but require `unsafe` to dereference. - Smart pointers are `Box<T>` (`unique_ptr`), `Rc<T>` (`shared_ptr`, single thread), `Arc<T>` (`shared_ptr`, thread-safe). - Macros are hygienic. Procedural macros (derive, attribute, function-like) are how `serde`, `tokio::main`, etc. work. ## The Crate Shortlist These cover most real apps. Add them as needed; they are not all required. | Crate | What it gives you | |---|---| | `serde` + `serde_json` | Serialization. `#[derive(Serialize, Deserialize)]` and you are done | | `tokio` | Async runtime. `#[tokio::main]`, `tokio::spawn`, async I/O | | `anyhow` | App error type. `anyhow::Result<T>`, `bail!`, `context()` | | `thiserror` | Library error enums. `#[derive(thiserror::Error)]` | | `clap` | CLI argument parsing. `#[derive(Parser)]` and you have a CLI | | `reqwest` | HTTP client. Async by default, blocking feature available | | `tracing` + `tracing-subscriber` | Structured logging. The default for any async code (replaces `log`) | | `axum` | Web framework. Built on `tokio` + `hyper` + `tower`. The 2026 default | | `sqlx` | Database access. Async, compile-time checked queries. PostgreSQL, MySQL, SQLite | | `chrono` | Dates and times. The maintainer announced soft-deprecation in Jan 2026 and recommends `jiff` for new code. `jiff` (BurntSushi) is the successor but still pre-1.0 as of September 2026 (0.2.x, with the 1.0 tracking issue open and no date). Pick `chrono` for `serde`/`sqlx` integration today, `jiff` if you can tolerate pre-1.0 churn | See `references/crate-shortlist.md` for one minimal example each. ## Top Anti-Patterns to Avoid These are the mistakes that show up in every newcomer's code review. Avoid them. 1. **Storing `&str` (or any reference) in a struct.** Causes lifetime annotations to cascade through every caller. Use `String` until you have a profiler-backed reason not to. 2. **Reaching for `Rc<RefCell<T>>` (or `Arc<Mutex<T>>`) to simulate Python/JS object graphs.** First ask whether you need shared mutable state at all - usually plain ownership, or passing data through a channel, is cleaner. When you genuinely do, prefer `Arc<Mutex<T>>` over `Rc<RefCell<T>>` so adding threads later is not a refactor. 3. **`Box<dyn Error>` in library public APIs.** Forces callers to downcast. Define a typed error enum with `thiserror`. `Box<dyn Error>` is acceptable inside a binary, never in a published library. 4. **`.unwrap()` and `.expect()` outside prototypes and tests.** Use `?` and propagate - for an `Option`, `.ok_or_else(|| anyhow!(...))?` does the conversion. When a value genuinely cannot be absent, prefer `.expect("why it cannot fail")` over `.unwrap()`: the message is the comment that documents the invariant. 5. **Brute-force `.clone()` until it compiles.** Sometimes cloning is right, but if you are scattering `.clone()` to silence the borrow checker, the design is wrong. Step back and ask the 3 questions about who owns what. 6. **Trying to inherit via `Deref`**. `Deref` is for smart-pointer-like wrappers, not for OOP-style "extends". Use composition. 7. **Reaching for `unsafe`.** App developers should essentially never need it. `unsafe` does not turn off the borrow checker; it lets you do five specific things (deref raw pointers, call unsafe functions, access mutable statics, implement unsafe traits, access union fields) with the contract that you have manually verified the invariants. 8. **Reading untrusted input with `.lines()` or `read_line`.** These allocate without bound. `BufRead::read_line`'s own docs warn that "it is possible for an attacker to continuously send bytes without ever sending a newline or EOF" - and `.lines()` inherits that behavior, since each item is a `read_line` under the hood. Either way a hostile or malformed peer can drive your process out of memory. Bound the read with `Read::take(n)`, and use `BufRead::skip_until` (stable since 1.83) to discard an over-long line. `for line in reader.lines()` is the first thing every tutorial teaches and almost none mention this. ## What to Defer You do not need these on day 1. Some you may never need. - **Lifetimes in struct fields.** Avoid by using owned types. The day you genuinely need them, you will know. - **`Pin`, `Future` internals, manual `poll` impls.** Just write `async fn` and `.await`. - **`unsafe` and FFI.** Almost never for app code. - **Procedural macros.** Library author territory. - **Higher-ranked trait bounds (`for<'a>`)**, variance, `PhantomData`. Expert territory. - **`Cell`, `OnceCell`, `LazyLock`, `MaybeUninit`.** Reach for these when you have a specific reason. ## Minimal Cargo.toml Single-crate, edition 2024, opinionated lints. Drop into a fresh project. ```toml [package] name = "my-app" version = "0.1.0" edition = "2024" rust-version = "1.85" [dependencies] [dev-dependencies] [profile.release] lto = "thin" codegen-units = 1 # ============================================================================= # Lints. Loose-but-helpful: deny obvious bugs, warn on common smells, leave # room to learn. Upgrade to clippy::pedantic later if you want the full ride. # ============================================================================= [lints.rust] # "deny", not "forbid": forbid cannot be lifted by a local #[allow] with a # SAFETY comment, and that bites on ordinary mmap, not just FFI. unsafe_code = "deny" unreachable_pub = "warn" [lints.clippy] all = { level = "deny", priority = -1 } # Idiomatic helpers # Note: uninlined_format_args moved to clippy::pedantic (allow-by-default) # during the 1.89/1.90 cycle, so an explicit warn keeps the nudge active. uninlined_format_args = "warn" semicolon_if_nothing_returned = "warn" implicit_clone = "warn" # Smells in non-prototype code dbg_macro = "warn" todo = "warn" print_stdout = "warn" # use `tracing::info!` instead in real apps ``` ## rustfmt.toml ```toml style_edition = "2024" edition = "2024" ``` That is enough. rustfmt's defaults are good. Some teams add `use_small_heuristics = "Max"` to keep more code on single lines. Fancy options like `imports_granularity` and `group_imports` are still nightly-only as of September 2026 (rustfmt tracking issues #4991 and #5083). Run `cargo fmt` before you start editing (or commit any pre-existing drift on its own) so formatting noise stays out of your diff, and make `cargo fmt --check` its own CI step. ## rust-toolchain.toml (optional but recommended) Pins the toolchain per-project so everyone on the team uses the same Rust. ```toml [toolchain] channel = "stable" components = ["rustfmt", "clippy", "rust-src"] profile = "minimal" ``` ## .gitignore ``` /target ``` Commit `Cargo.lock`. `cargo new` tracks it, and the Cargo guide's advice is "When in doubt, check `Cargo.lock` into the version control system". The FAQ deliberately stops short of making that universal - "whether you do is dependent on the needs of your package" - but for an application it is unambiguously right, and committing it is now the ordinary default for libraries too. ## Project Structure ``` my-app/ src/ main.rs # binary entry point: fn main() lib.rs # OR a library crate root config.rs # module: declared as `mod config;` in main.rs/lib.rs api/ # nested module mod.rs # OR `api.rs` next to api/ folder (2018+ style preferred) users.rs tests/ # integration tests (each file is its own crate) smoke.rs Cargo.toml Cargo.lock rust-toolchain.toml rustfmt.toml .gitignore ``` Inline modules with `mod { ... }` until a file gets long, then split. Do not pre-split. **Everything is private by default**, including to a parent module. `mod config;` makes the module exist; it does not make anything inside it reachable. That is the source of the most common early "why can't I call this" error, and the fix is a visibility keyword on the item (and on the module, if it is nested): `pub` exposes it to the outside world, `pub(crate)` exposes it only within your own crate. `pub(crate)` is the right default for anything that is not part of a library's published API - it is what lets the `unreachable_pub` lint in the table below tell you something useful. The layout above is one crate. The moment you want a second - a shared library plus a CLI, a server plus its client - you need a Cargo **workspace**, along with the `Cargo.toml` machinery that goes with growing past a single crate: feature flags, build scripts, and what `rust-version` actually controls. That is `references/project-shape.md`. ## Learning Path 1. **The Rust Book** (https://doc.rust-lang.org/book/) - canonical, free, current. The interactive Brown University version (https://rust-book.cs.brown.edu/) adds quizzes and visualizations. 2. **Rustlings** (https://github.com/rust-lang/rustlings) - exercises in parallel with The Book. 3. **100 Exercises to Learn Rust** (https://rust-exercises.com/) - alternative or supplement to Rustlings, slightly newer. 4. **Rust for Rustaceans** (Jon Gjengset) - the post-beginner book. Read after you are comfortable. 5. **Zero to Production in Rust** (Luca Palmieri) - if you are building a backend service. Note: the book uses `actix-web` while `axum` is the 2026 default; the patterns translate cleanly. For looking up syntax: **Rust by Example** (https://doc.rust-lang.org/rust-by-example/). For curated crate recommendations: **blessed.rs** (https://blessed.rs/crates). ## Reference Docs Detailed material lives in `references/`. Read each when you hit the topic. - **ownership-and-types.md** - ownership, borrowing, lifetimes, `String`/`&str`/`Cow`, why a string is not indexable, slices, `HashMap` and the `entry` API, smart pointers, the self-referential struct trap - **error-handling.md** - `Result`, `?`, `anyhow` vs `thiserror` patterns, wrapping at the boundary rather than before it, custom error enums, when `panic!` is appropriate - **traits-and-generics.md** - traits as bounds, `dyn` vs `impl Trait` vs generics, common derives, `From`/`Into`/`Display`/`Debug`, closures and the `Fn`/`FnMut`/`FnOnce` family, blanket impls, the orphan rule - **async-basics.md** - threads and `mpsc` before async, `tokio`, `#[tokio::main]`, `.await`, `Send`/`Sync`, graceful shutdown on SIGTERM, common pitfalls (blocking in async, `MutexGuard` across `.await`) - **crate-shortlist.md** - minimal usage example for each of the 8 crates above - **project-shape.md** - past one crate: workspaces and inherited dependencies, `[workspace.lints]`, feature flags, `build.rs`, what `rust-version` controls, `#[non_exhaustive]` - **testing.md** - what to test and what to skip, pragmatic test organization, keeping the suite fast, the minimal high-value tool kit - **dev-environment.md** - the fast build loop, build caching (kache setup and its quirks, sccache, rust-cache), platform-aware linker guidance, CI, file watchers - **releasing.md** - shipping a binary: `dist` vs a hand-rolled `release-plz` + `cargo-zigbuild` pipeline, `[profile.dist]`, cross-compiling every target from one runner, fanning out to binstall/Homebrew/Nix, and guarding what the published crate contains - **performance.md** - profiling before optimizing, benchmarking with criterion/divan, the real runtime wins
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.