Software Engineering Wiki

Languages

Rust

Ownership and borrowing in practice, error handling, async, and the cargo commands that matter day to day.

Cheatsheet #

TaskCommand
Check without codegen (fast)cargo check --all-targets
Build optimisedcargo build --release
Run tests, show outputcargo test -- --nocapture
One testcargo test path::to::test_name
Lint hardcargo clippy --all-targets -- -D warnings
Formatcargo fmt --all (check: --check)
Explain an error coderustc --explain E0502
Expand a macrocargo expand
Dependency treecargo tree -d (duplicates)
Audit advisoriescargo audit
Update within semvercargo update
Add a dependencycargo add serde --features derive
Benchmarkcargo bench (or criterion)
Docs for this crate and depscargo doc --open
Binary size breakdowncargo bloat --release
MSRV / toolchain pinrust-toolchain.toml

Ownership in practice #

Every value has one owner; the value is dropped when the owner goes out of scope. You may have many shared references (&T) or exactly one mutable reference (&mut T), never both at once. The compiler enforces this at compile time, which is why there is no data race and no use-after-free.

let s = String::from("hello");
let t = s;                  // move: s is no longer usable
let u = t.clone();          // explicit copy when you really need two owners

fn read(v: &[u8]) {}        // borrow: caller keeps ownership
fn consume(v: Vec<u8>) {}   // take ownership: caller cannot use it afterwards
ErrorWhat it meansUsual fix
E0382 use of moved valueYou gave the value awayBorrow instead, or clone deliberately
E0502 mutable borrow while borrowedTwo overlapping borrowsShorten the first borrow’s scope, or restructure
E0499 two mutable borrowsAliasing mutationSplit the data (split_at_mut), or use indices
E0597 does not live long enoughReference outlives its ownerOwn the data, or tie lifetimes explicitly
E0308 mismatched typesUsually String vs &str, T vs &T&x, x.as_str(), x.to_owned()

Take &str and &[T] in function arguments, return String and Vec<T>. That gives callers the most freedom and avoids forcing an allocation to call you.

Errors #

Result<T, E> is the mechanism; ? propagates. Libraries define a concrete error type, applications flatten everything into one.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum StoreError {
    #[error("user {0} not found")]
    NotFound(i64),
    #[error("database: {0}")]
    Db(#[from] sqlx::Error),      // From impl, so ? converts automatically
}

pub fn get(id: i64) -> Result<User, StoreError> {
    let row = query(id)?;          // sqlx::Error converts via #[from]
    row.ok_or(StoreError::NotFound(id))
}
use anyhow::{Context, Result};

fn main() -> Result<()> {
    let cfg = std::fs::read_to_string("config.toml")
        .context("reading config.toml")?;     // adds context, keeps the source
    Ok(())
}

thiserror for libraries (typed, matchable), anyhow for binaries (contextual, printable). unwrap() in production code is a deliberate assertion that the case is impossible — expect("reason") at least records why.

Options and iterators #

let name: Option<&str> = map.get("name").map(String::as_str);

let total: u64 = items.iter().filter(|i| i.active).map(|i| i.bytes).sum();
let first_err = results.iter().find_map(|r| r.as_ref().err());
let parsed: Result<Vec<i32>, _> = inputs.iter().map(|s| s.parse::<i32>()).collect();  // short-circuits
let (ok, bad): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok);

value.unwrap_or_default();
value.unwrap_or_else(|| expensive());
value.ok_or(Error::Missing)?;

Iterators are lazy and compile to the same code as a hand-written loop; chain them freely. collect() into Result<Vec<_>, E> is the idiom for “all or nothing”.

Structs, traits and generics #

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Config {
    pub name: String,
    #[serde(default = "default_port")]
    pub port: u16,
}

pub trait Store {
    fn get(&self, id: i64) -> Result<User, StoreError>;
}

fn handler<S: Store>(store: &S) {}          // static dispatch, monomorphised
fn handler_dyn(store: &dyn Store) {}        // dynamic dispatch, one copy of the code

Use generics by default and dyn Trait when the type must be chosen at runtime or code size matters. impl Trait in argument position is shorthand for the generic form.

Shared state and concurrency #

NeedType
Single owner, single threadPlain value
Shared, single threadRc<T> / Rc<RefCell<T>>
Shared across threads, read-onlyArc<T>
Shared and mutable across threadsArc<Mutex<T>> or Arc<RwLock<T>>
CounterAtomicU64
Message passingstd::sync::mpsc or crossbeam/tokio::sync::mpsc
let state = Arc::new(Mutex::new(HashMap::new()));
let s = Arc::clone(&state);
std::thread::spawn(move || {
    s.lock().unwrap().insert("k", 1);      // lock poisoned only if a holder panicked
});

A MutexGuard held across an .await deadlocks an async runtime — use tokio::sync::Mutex there, or drop the guard before awaiting.

Async #

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;

    let (a, b) = tokio::try_join!(fetch(&client, "/a"), fetch(&client, "/b"))?;

    let results = futures::future::join_all(ids.iter().map(|id| fetch_one(&client, *id))).await;

    tokio::select! {
        res = work() => res?,
        _ = tokio::time::sleep(Duration::from_secs(5)) => anyhow::bail!("timeout"),
    }
    Ok(())
}

Futures do nothing until awaited. Blocking calls (file I/O, std::thread::sleep, CPU-bound work) inside an async task stall the whole worker thread — move them to tokio::task::spawn_blocking.

Tests #

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_valid_input() {
        assert_eq!(parse("a=1").unwrap(), Config { name: "a".into(), port: 1 });
    }

    #[test]
    fn rejects_empty() {
        assert!(matches!(parse(""), Err(ParseError::Empty)));
    }

    #[tokio::test]
    async fn fetches() { /* ... */ }
}

Unit tests live beside the code in mod tests; integration tests live in tests/ and can only use the public API — which makes them the honest check of your interface.

Cargo and builds #

[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"      # smaller and faster, but no unwinding and no catch_unwind
strip = "symbols"
cargo build --release --target x86_64-unknown-linux-musl   # static binary for a scratch image
cargo test --workspace --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo tree -i openssl                                      # who pulls this in
cargo update -p serde --precise 1.0.203

Commit Cargo.lock for binaries, omit it for libraries. Pin the toolchain with rust-toolchain.toml so CI and laptops agree.

Oneliners #

# Explain the error you just got
rustc --explain E0502

# Fastest feedback loop
cargo watch -x check -x test

# Show where time goes in a slow build
cargo build --release --timings && open target/cargo-timings/cargo-timing.html

# Duplicate dependency versions bloating the build
cargo tree -d

# Unused dependencies
cargo +nightly udeps

# Which features are enabled for a crate
cargo tree -e features -i tokio

# Expand a derive or macro to see what it generates
cargo expand --lib path::to::module

# Static musl binary, no glibc
cargo build --release --target x86_64-unknown-linux-musl && ldd target/x86_64-unknown-linux-musl/release/app

# Check the whole workspace without building artefacts
cargo check --workspace --all-targets --all-features

# Fail CI on formatting
cargo fmt --all -- --check

# Security advisories in the lock file
cargo audit --deny warnings

# Miri for undefined behaviour in unsafe code
cargo +nightly miri test

Last updated 15 September 2026 · Edit this page