Rust gives you memory safety without a garbage collector. That single idea is why companies like Google, Microsoft, Discord, and Cloudflare run Rust in production, and why it has topped the “most admired language” list in the Stack Overflow survey for nine years running.

This guide takes you from zero Rust knowledge to building LinkShort, a real URL shortener with a REST API, a database, and a command-line client. Every section adds a new concept, and the final section connects them all into one working project — nothing here is a disconnected snippet.

What you will have by the end: a working understanding of ownership and borrowing, comfort with traits, generics, and async Rust, and a real full-stack Rust project with a web API, SQLite database, and CLI, built from scratch.

Two things before you start:

  • No prior Rust experience needed. Some programming background (any language) helps, especially with basic types and functions.
  • Full source code for the capstone project is on GitHub: github.com/kemalcodes/rust-tutorial.

Need a quick syntax lookup instead of a full tutorial? See the Rust Cheat Sheet.

Part 1: Foundations

The core ideas that make Rust different from every other mainstream language: no garbage collector, no null, and a compiler that catches memory bugs before your code ever runs.

Why Learn Rust

For nine years in a row, Rust has been the most admired language in the Stack Overflow Developer Survey — 83% of people who use it want to keep using it. It is used in production at Google, Microsoft, Amazon, Meta, Discord, and Cloudflare, and it is now an official language in the Linux kernel.

Most languages force a choice: garbage collection (safe, but with unpredictable pauses — Java, Kotlin, Go) or manual memory management (fast, but error-prone — C, C++). Rust’s ownership system gives you both: memory safety and C-level performance, with no garbage collector and no runtime.

fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

fn main() {
    let message = greet("kemalcodes");
    println!("{}", message);
}

Rust shines at backend services (Axum, Actix Web), CLI tools (ripgrep, bat, fd, delta all ship in Rust), WebAssembly (Rust code runs in the browser 10-50x faster than the equivalent JavaScript), and embedded/IoT. Discord migrated its backend from Go to Rust specifically to eliminate garbage-collector latency spikes.

The learning curve is real — mainly around ownership and lifetimes. A Go or Kotlin developer typically needs 4-8 weeks to become productive; a Python or JavaScript developer, 8-12 weeks. The first two weeks are the hardest. After that, the compiler stops feeling like an obstacle and starts feeling like a collaborator that catches your bugs before they ship.

Installing Rust and Your First Program

Rust installs through rustup, a single tool that manages the compiler and toolchain:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
rustc --version
cargo --version

This gives you three tools. rustc is the compiler — you almost never call it directly. cargo is the package manager and build tool — you will use it for everything. rustup manages versions (rustup update).

For an editor, install VS Code with the rust-analyzer extension. It gives you autocomplete, inline errors, and go-to-definition with no extra config.

Create and run a project:

cargo new hello
cd hello
cargo run

cargo new generates Cargo.toml (project config and dependencies) and src/main.rs (your code). A minimal program looks like this:

fn main() {
    println!("Hello, world!");
}

The commands you’ll use daily: cargo run (compile and run), cargo check (fast error-checking with no binary output — use this while you code), cargo build --release (optimized build for shipping). Add a third-party library (“crate”) with cargo add <name> — it updates Cargo.toml automatically and pulls the package from crates.io.

One habit worth building early: read the compiler’s error messages. Rust’s errors are unusually good — they often tell you the exact fix:

error[E0384]: cannot assign twice to immutable variable `x`
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

Variables, Types, and Functions

Variables in Rust are immutable by default — the opposite of most languages:

let name = "Alex";
// name = "Sam";     // ERROR: cannot assign twice to immutable variable

let mut count = 0;   // mut makes it changeable
count += 1;

This is deliberate: if a value should never change, immutability lets the compiler catch accidental changes for you. Add mut only when you actually need to reassign.

Rust’s integer types are named by size and signedness — i32 (the default, signed 32-bit), u32 (unsigned), u64, usize (pointer-sized, used for indexing), and so on. Floats default to f64. Booleans are bool, characters use single quotes ('A') and are full Unicode.

Text has two types, and the difference matters:

let greeting: &str = "Hello";           // borrowed, fixed, cheap
let mut name = String::from("Alex");    // owned, growable, heap-allocated
name.push_str(" Smith");

Use &str for text that doesn’t change (like function parameters); use String when you need to build or modify text. Rust never converts types implicitly — let y: f64 = x as f64; for numeric casts, .parse::<i32>() for string-to-number, .to_string() for the reverse. This forces every conversion to be visible in the code.

A quirky but useful feature is shadowing — redeclaring a variable with let creates a brand new variable, and it can even change type:

let input = "42";                          // &str
let input = input.parse::<i32>().unwrap();  // now i32 — a different variable

Functions return their last expression, with no semicolon and no return keyword needed:

fn add(a: i32, b: i32) -> i32 {
    a + b   // no semicolon = this is the return value
}

Adding a semicolon after a + b turns it into a statement and breaks the function — this trips up most beginners at least once. Because if/else and {} blocks are themselves expressions, you can assign directly from them: let status = if score > 50 { "pass" } else { "fail" };.

Ownership

Ownership is the concept that makes Rust different from every mainstream language. It’s why Rust has no garbage collector, yet never leaks memory and never double-frees it. Three rules define it:

  1. Each value has exactly one owner.
  2. When the owner goes out of scope, the value is dropped (memory freed) automatically.
  3. Assignment transfers ownership — the old variable becomes invalid.

Simple stack values (integers, floats, bools) are cheap to duplicate, so they copy on assignment. Anything that owns heap memory — String, Vec<T>, Box<T>moves instead:

fn main() {
    let name = String::from("Alex");
    let other_name = name;             // ownership MOVES to other_name

    // println!("{}", name);           // ERROR: value used after move
    println!("{}", other_name);        // OK
}

Only the pointer on the stack changes hands; the heap data itself doesn’t move. The old variable is marked invalid so the compiler can guarantee only one owner ever frees that memory — this is what prevents the double-free bugs that plague C and C++.

If you genuinely need two independent copies of heap data, call .clone() explicitly:

let name = String::from("Alex");
let other_name = name.clone();   // deep copy — new heap allocation
println!("{} / {}", name, other_name);  // both valid

Rust makes you write .clone() by hand so the cost is visible in the code — cloning is not free.

Passing a value to a function works exactly like assignment: it transfers ownership, and the original variable becomes unusable afterward unless the function hands it back through its return value. This gets clunky fast — which is exactly the problem borrowing (next chapter) solves. Variables are dropped automatically in reverse order of creation when their scope ends (}) — deterministic, with no garbage collector deciding when to run.

Borrowing and References

Moving ownership every time a function only needs to read a value is wasteful. Borrowing lets a function use a value without taking ownership — the value stays with its original owner.

fn calculate_length(s: &String) -> usize {
    s.len()
}

fn main() {
    let name = String::from("Alex");
    let length = calculate_length(&name);   // borrow, not move
    println!("{} has {} characters", name, length);  // name is still valid
}

References are created with & and are immutable by default — you can have as many immutable references (&T) to the same data as you want, since nobody is changing it. To modify borrowed data you need a mutable reference (&mut T), which requires the original variable to be mut:

fn add_greeting(message: &mut String) {
    message.push_str(", welcome!");
}

fn main() {
    let mut name = String::from("Alex");
    add_greeting(&mut name);
    println!("{}", name);   // Alex, welcome!
}

The compiler enforces one rule at compile time: either one mutable reference, or any number of immutable references — never both at once. This single rule eliminates data races (two pointers touching the same data at once, with at least one writing) entirely at compile time, instead of leaving them as a runtime bug that only shows up under load.

let mut data = String::from("hello");
let r1 = &data;
let r2 = &mut data;   // ERROR: cannot borrow as mutable while borrowed as immutable

The compiler is smart about when a reference’s lifetime actually ends — at its last use, not necessarily the end of the block — so borrows that don’t overlap in practice are allowed even if they look like they might conflict. Rust also refuses to compile a function that returns a reference to data that’s about to be dropped (a “dangling reference”) — return the owned value instead. To modify a value through a reference, dereference it with *: *value *= 2;.

Structs and Methods

A struct groups related fields into a custom type, similar to a data class in Kotlin:

struct User {
    name: String,
    email: String,
    age: u32,
    active: bool,
}

fn main() {
    let mut user = User {
        name: String::from("Alex"),
        email: String::from("alex@example.com"),
        age: 28,
        active: true,
    };
    user.age = 29;   // OK because `user` is declared mut
}

Every field needs a value at creation — Rust has no null. Methods are attached in a separate impl block. The first parameter tells you how the method touches the struct: &self (read), &mut self (modify), or self (consume and transform):

struct Rectangle { width: f64, height: f64 }

impl Rectangle {
    fn new(width: f64, height: f64) -> Rectangle {   // associated fn, called as Rectangle::new()
        Rectangle { width, height }
    }
    fn area(&self) -> f64 {
        self.width * self.height
    }
    fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }
}

Functions in impl that don’t take self (like new) are called associated functions, called with :: — this is just a naming convention, not a special keyword, but Type::new() is the near-universal pattern for constructors.

#[derive(...)] auto-generates common trait implementations instead of you writing boilerplate:

#[derive(Debug, Clone, PartialEq)]
struct Point { x: f64, y: f64 }

Debug enables {:?} printing, Clone enables .clone(), PartialEq enables ==. For nice {} output aimed at end users (rather than developers), implement Display yourself:

use std::fmt;
impl fmt::Display for Rectangle {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}x{}", self.width, self.height)
    }
}

Enums and Pattern Matching

Rust enums are far more powerful than in most languages — each variant can carry its own, different data:

enum Message {
    Quit,                       // no data
    Echo(String),                // one value
    Move { x: i32, y: i32 },     // named fields
    Color(u8, u8, u8),           // multiple values
}

match is how you work with enums — it inspects which variant a value is and destructures its data into local variables:

fn process_message(msg: &Message) {
    match msg {
        Message::Quit => println!("Quit"),
        Message::Echo(text) => println!("Echo: {}", text),
        Message::Move { x, y } => println!("Move to ({}, {})", x, y),
        Message::Color(r, g, b) => println!("Color: rgb({}, {}, {})", r, g, b),
    }
}

match is exhaustive — the compiler forces you to handle every variant, or use _ as a catch-all. This means adding a new enum variant later automatically flags every place in the codebase that needs updating for it — a real safety net, not just style. When you only care about one variant, if let is shorter than a full match:

if let Message::Echo(text) = &msg {
    println!("Got echo: {}", text);
}

Rust has no null. Instead, the built-in Option<T> enum represents a value that might be absent:

enum Option<T> { Some(T), None }

fn find_user(id: u32) -> Option<String> {
    if id == 1 { Some(String::from("Alex")) } else { None }
}

You cannot use an Option<String> as a String directly — you must handle the None case first, via match or if let. This is what makes null-pointer-style crashes impossible: the compiler won’t let you forget the check. A struct with an enum field for status is an extremely common Rust pattern:

enum OrderStatus { Pending, Shipped(String), Delivered, Cancelled(String) }
struct Order { id: u32, item: String, status: OrderStatus }

Error Handling

Rust has no exceptions. Errors are values, represented by types, and the compiler forces you to handle them — there is no way to silently ignore a failure.

There are two categories. panic! is for unrecoverable errors — bugs that should never happen, like an out-of-bounds index. Result<T, E> is for recoverable errors — expected failures like a missing file or bad input:

enum Result<T, E> { Ok(T), Err(E) }

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("cannot divide by zero"))
    } else {
        Ok(a / b)
    }
}

The caller must handle both Ok and Err — there’s no way to accidentally skip the error case. .unwrap() and .expect("msg") extract the value or panic; use them only in prototypes, tests, or when you’re certain the value is Ok/Some.

The ? operator is how real Rust code propagates errors — it unwraps Ok or returns Err immediately from the enclosing function:

fn parse_and_double(input: &str) -> Result<i32, std::num::ParseIntError> {
    let number = input.parse::<i32>()?;   // returns early on Err
    Ok(number * 2)
}

For applications, define your own error enum and implement From so ? can auto-convert other error types into it:

#[derive(Debug)]
enum AppError { InvalidInput(String), OutOfRange(i32) }

impl From<std::num::ParseIntError> for AppError {
    fn from(e: std::num::ParseIntError) -> AppError {
        AppError::InvalidInput(e.to_string())
    }
}

fn parse_age(input: &str) -> Result<i32, AppError> {
    let age = input.parse::<i32>()?;   // ParseIntError converts to AppError automatically
    if age < 0 || age > 150 { return Err(AppError::OutOfRange(age)); }
    Ok(age)
}

Result and Option also support functional-style combinators instead of match: .map() transforms a success value, .and_then() chains fallible operations, .unwrap_or_else() supplies a default on error. Rule of thumb for choosing between the two error strategies: if the caller can reasonably do something about the failure, return Result; if it’s a bug that should be structurally impossible, panic!.

Part 2: Intermediate Rust

With the basics in place, this part covers the tools you need for real programs: sharing behavior across types, writing code once for many types, and running work concurrently.

Traits — Shared Behavior

A trait defines a set of methods a type must implement. Think of it as a contract. If you know interfaces in Java, Kotlin, or TypeScript, traits are similar — but Rust traits also support default methods, operator overloading, and dynamic dispatch.

trait Describable {
    fn describe(&self) -> String;

    // Default method — types can use this or override it
    fn summary(&self) -> String {
        format!("Summary: {}", self.describe())
    }
}

struct User { name: String, age: u32 }

impl Describable for User {
    fn describe(&self) -> String {
        format!("{} (age {})", self.name, self.age)
    }
    // summary() uses the default
}

Rust has built-in traits you can implement automatically with #[derive]: Debug (for {:?} printing), Clone, Copy, PartialEq, Hash, Default. Display (for {} printing) cannot be derived — you always implement it by hand, because Rust does not know how you want your type to look to users.

Trait bounds restrict a generic function to types that implement a trait: fn print<T: Describable>(item: &T). When you need a collection of different types that share a trait, use a trait object instead — generics need one fixed type per Vec, but dyn lets the compiler pick the right method at runtime:

fn describe_all(items: &[&dyn Describable]) -> Vec<String> {
    items.iter().map(|item| item.describe()).collect()
}

&dyn Describable means “a reference to any type that implements Describable.” Prefer generics by default — the compiler generates a specific, fast version of your function for each type (static dispatch, zero runtime cost). Reach for dyn trait objects only when you need a mixed collection or a function that returns different types depending on a runtime condition (Box<dyn Describable>).

From and Into are Rust’s standard way to convert between types. Implement From<A> for B and you get Into<B> for A for free:

struct Celsius(f64);
struct Fahrenheit(f64);

impl From<Celsius> for Fahrenheit {
    fn from(c: Celsius) -> Self {
        Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
    }
}

let f = Fahrenheit::from(Celsius(100.0));       // 212°F
let f2: Fahrenheit = Celsius(100.0).into();     // same thing

One gotcha: the orphan rule blocks you from implementing a foreign trait (like Display) on a foreign type (like Vec<i32>) — both must come from your own crate. The fix is the newtype pattern: wrap the foreign type in your own single-field struct, then implement the trait on the wrapper.

Generics

Generics let you write one function or struct that works with many types, instead of copy-pasting a version per type. You already use them daily — Vec<T>, Option<T>, and Result<T, E> are all generic.

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut biggest = &list[0];
    for item in &list[1..] {
        if item > biggest {
            biggest = item;
        }
    }
    biggest
}

<T: PartialOrd> means “T can be any type that supports comparison.” This works on both numbers and strings. Structs and enums can be generic too:

struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    fn new() -> Stack<T> { Stack { items: Vec::new() } }
    fn push(&mut self, item: T) { self.items.push(item); }
    fn pop(&mut self) -> Option<T> { self.items.pop() }
}

Notice Stack<T> needs no trait bounds — its methods only move data around. Add bounds only when you need to do something specific with T, like printing (Display) or comparing (PartialOrd). When bounds get long, a where clause is easier to read than cramming everything into <>:

fn compare_and_display<T, U>(t: &T, u: &U) -> String
where
    T: fmt::Display + PartialOrd,
    U: fmt::Display + Clone,
{
    format!("T={}, U={}", t, u)
}

Rust makes generics fast through monomorphization: at compile time, the compiler generates a separate, specialized version of your function for every concrete type you actually use. This means zero runtime cost — generics run exactly as fast as hand-written type-specific code — but it does grow your binary size. This is different from Java (type erasure) or Python (duck typing); Rust checks and specializes everything before your program runs.

Sometimes the compiler can’t infer a generic type from context. Use the turbofish ::<Type> to tell it directly: numbers.iter().collect::<Vec<&i32>>() or "42".parse::<i32>().unwrap().

Lifetimes

Lifetimes answer one question: “how long does this reference stay valid?” The compiler uses them to guarantee every reference is valid wherever it’s used — no dangling pointers, no use-after-free.

Most of the time lifetimes are invisible. Three elision rules let the compiler figure them out automatically: (1) each reference parameter gets its own lifetime, (2) if there’s exactly one input lifetime, every output gets that same lifetime, (3) if one parameter is &self, its lifetime goes to every output. You only write lifetime annotations by hand when the compiler can’t apply these rules — typically, a function with two or more reference parameters that returns a reference:

fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() >= s2.len() { s1 } else { s2 }
}

'a does not make anything live longer. It’s descriptive, not prescriptive — it tells the compiler “the returned reference lives at least as long as the shorter of these two inputs,” so the compiler can reject code that would use the result after one of the inputs is dropped.

When a struct holds a reference, the struct itself needs a lifetime parameter, meaning it can never outlive the data it borrows:

struct Excerpt<'a> {
    text: &'a str,
}

'static means “lives for the whole program” — string literals have this lifetime because they’re baked into the binary. Don’t add 'static just to silence the compiler; if the compiler is asking for a lifetime, slapping 'static on it usually means you’re hiding a design problem rather than fixing one.

If you’re fighting the borrow checker, the easiest way out is often to stop borrowing: return an owned String instead of &str, or store String in a struct instead of &'a str. Owned values are simpler and, for most applications, the performance difference versus references is negligible.

Closures and Iterators

A closure is an unnamed, inline function that can capture variables from its surrounding scope — something a regular fn cannot do:

let offset = 10;
let add_offset = |x| x + offset;  // captures offset
println!("{}", add_offset(5));    // 15

Rust has three closure traits describing how a closure uses what it captures: Fn only reads captured variables (callable many times), FnMut mutates them (callable many times, needs mut), and FnOnce takes ownership of them (callable only once). Every Fn is also FnMut, and every FnMut is also FnOnce, so a function that accepts FnOnce can take any closure. The move keyword forces a closure to take ownership of everything it captures — essential when handing a closure to a thread, since the thread might outlive the variables it would otherwise just borrow:

let data = vec![1, 2, 3];
let handle = std::thread::spawn(move || {
    println!("{:?}", data);  // data is now owned by the thread
});

Iterators are lazy — nothing runs until you consume them. Every collection gives you three ways to iterate: .iter() (borrows each element, collection stays usable), .iter_mut() (mutable borrow, lets you modify in place), and .into_iter() (takes ownership, collection is consumed). Chain adaptors like map and filter with a consumer like collect or sum to build a pipeline:

let result: i32 = (1..=10)
    .filter(|x| x % 2 == 0)   // keep evens: 2,4,6,8,10
    .map(|x| x * x)            // square them: 4,16,36,64,100
    .sum();                    // 220

This reads like a pipeline and, thanks to Rust’s zero-cost abstractions, compiles to the same machine code as an equivalent hand-written for loop — sometimes faster, because the compiler optimizes the whole chain at once. You can implement your own iterator by writing just one method, next(); every other iterator method (map, filter, sum, collect, …) comes for free:

struct Counter { current: u32, max: u32 }

impl Iterator for Counter {
    type Item = u32;
    fn next(&mut self) -> Option<u32> {
        if self.current < self.max {
            self.current += 1;
            Some(self.current)
        } else {
            None
        }
    }
}

let values: Vec<u32> = Counter { current: 0, max: 5 }.collect(); // [1,2,3,4,5]

Smart Pointers

A reference (&T) borrows data without owning it. A smart pointer owns the data it points to and adds extra behavior — heap allocation, reference counting, or interior mutability. String and Vec<T> are smart pointers you already use every day.

Box<T> is the simplest one: it puts a value on the heap instead of the stack. You need it for recursive types, since the compiler can’t compute the size of a type that contains itself:

enum List<T> {
    Cons(T, Box<List<T>>),  // Box makes the size fixed (a pointer)
    Nil,
}

Rc<T> (reference counting) gives you multiple owners for the same data in single-threaded code. Rc::clone() doesn’t copy the data — it just increments a counter; the data is freed only when the count hits zero. Rc<T> is not thread-safe. For multi-threaded sharing, use Arc<T> (“Atomic Reference Counting”) instead — same idea, but the counter updates atomically so it’s safe across threads:

use std::sync::Arc;
let data = Arc::new(vec![1, 2, 3]);
let data_clone = Arc::clone(&data);
std::thread::spawn(move || println!("{:?}", data_clone));

Normally you can’t mutate data behind a shared reference. RefCell<T> moves that rule from compile time to runtime: .borrow() gives a shared reference, .borrow_mut() gives a mutable one, and if you ever violate the “one mutable OR many shared” rule, the program panics at runtime instead of failing to compile. The most common combination is Rc<RefCell<T>> — shared, mutable data on one thread; its thread-safe equivalent is Arc<Mutex<T>>.

Rule of thumb: start with owned values and plain references. Reach for Box when you need heap allocation or a recursive type. Reach for Rc/Arc only when you genuinely need multiple owners. Add RefCell/Mutex only when you need to mutate through a shared reference. Most real Rust programs use Box occasionally and Arc<Mutex<T>> rarely — don’t reach for Rc<RefCell<T>> by default.

Concurrency and Channels

Rust calls this fearless concurrency: the ownership system catches most data races at compile time, instead of letting them surface as random production bugs.

thread::spawn runs a closure on a new OS thread and returns a JoinHandle; .join() blocks until that thread finishes and gives you back its return value. Because a spawned thread can outlive the function that created it, the compiler forces you to move any captured data into the closure — it cannot let the thread borrow something that might be dropped in the meantime.

use std::thread;

let data = vec![1, 2, 3, 4, 5];
let handle = thread::spawn(move || {
    data.iter().sum::<i32>()
});
let result = handle.join().unwrap(); // 15

Threads that need to talk to each other can pass messages through an mpsc channel (multiple producers, single consumer) instead of sharing memory directly:

use std::sync::mpsc;

let (tx, rx) = mpsc::channel();
thread::spawn(move || {
    for msg in ["hello", "from", "thread"] {
        tx.send(String::from(msg)).unwrap();
    }
}); // tx is dropped here when the thread ends
for received in rx {
    println!("Got: {}", received);
}

Clone tx to send from multiple threads, and remember to drop the original if you never move it into a thread yourself — otherwise the receiver’s loop waits forever, because as far as it knows a sender might still show up.

When threads must share the same data instead of passing messages, use Arc<Mutex<T>>: Arc gives multiple threads shared ownership, and Mutex guarantees only one of them touches the data at a time.

use std::sync::{Arc, Mutex};

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        *counter.lock().unwrap() += 1;
    }));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 10

Keep locks held for the shortest scope possible — wrap the .lock() in its own { } block if you have slow work to do afterward, so other threads aren’t blocked waiting on you.

Tokio (the async runtime, covered next) has its own channel family built for async tasks instead of OS threads, and it comes in four flavors:

ChannelSendersReceiversDeliversUse case
mpscmanyoneevery messagetask queues, pipelines
oneshotoneonea single valuerequest/response
broadcastmanymanyevery message, to every subscriberevents, notifications
watchonemanyonly the latest valueshared config/state

Tokio’s mpsc is bounded by a buffer size, which gives you free backpressure: tx.send(...).await simply waits once the buffer is full, until the receiver drains a slot. oneshot is perfect for request/response — embed a oneshot::Sender inside a request struct so the “server” task can reply directly to the caller that sent it:

struct Request { query: String, respond_to: tokio::sync::oneshot::Sender<String> }

// server task: loops on an mpsc receiver, replies via each request's oneshot sender
while let Some(req) = rx.recv().await {
    let _ = req.respond_to.send(format!("Result for: {}", req.query));
}

broadcast sends every message to every subscriber that called .subscribe() — but subscribe before you send, or that receiver misses anything sent earlier. watch never queues messages; it only ever holds the most recent value, which makes it the right tool for propagating config or state changes, where an intermediate value doesn’t matter and only the latest one does.

Async/Await and Tokio

Threads are the right tool for CPU-heavy work. But most real programs spend most of their time waiting — for a network response, a file read, a database query. Spawning a whole OS thread (~2MB of stack) per waiting task doesn’t scale; 1,000 threads costs ~2GB of memory versus ~200KB for 1,000 async tasks. Async lets one thread juggle thousands of waiting tasks at once.

Rust’s standard library gives you the async/.await keywords but no runtime — you add one yourself, almost always Tokio:

[dependencies]
tokio = { version = "1", features = ["full"] }

An async fn doesn’t run when you call it — it returns a Future, a description of work that hasn’t started yet. Calling it does nothing until you .await it, at which point the Tokio runtime polls it, running other tasks whenever this one is waiting instead of blocking the thread:

async fn fetch_user(id: u32) -> String {
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    format!("User(id={})", id)
}

#[tokio::main]
async fn main() {
    let user = fetch_user(1).await;
    println!("{}", user);
}

Never call std::thread::sleep or do CPU-heavy work directly inside an async fn — both block the whole runtime thread, freezing every other task on it. Use tokio::time::sleep(...).await for delays, and hand real CPU work to tokio::task::spawn_blocking(...) so it runs on a separate thread pool instead.

Two independent operations run one after another if you .await them in sequence, but at the same time if you run them with tokio::join! — total wait time becomes the longest one instead of the sum of all of them:

let (user, order) = tokio::join!(
    fetch_user(1),
    fetch_order(42),
); // ~100ms total, not 180ms

tokio::spawn goes further than join! — it starts an independent task on the runtime that can outlive the code that spawned it, but the future you spawn must be 'static (own everything it touches, via move, rather than borrow). tokio::select! waits for whichever of several futures finishes first and cancels the rest — the standard way to implement a timeout:

tokio::select! {
    result = fetch_user(1) => println!("got {result}"),
    _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => println!("timed out"),
}

If you need to share mutable state between async tasks and might hold the lock across an .await point, use tokio::sync::Mutex instead of std::sync::Mutex — the standard one isn’t safe to hold across an await. If a lock is always released before the next .await, std::sync::Mutex is fine and faster. Test async functions with #[tokio::test] in place of #[test].

Part 3: Data and Web Services

Rust’s standard collections, its built-in test framework, and the three crates almost every Rust backend uses: Serde for JSON, Reqwest for outgoing HTTP calls, Axum for the web server itself, and SQLx for the database.

Collections — HashMap, BTreeMap, VecDeque, BinaryHeap

The entry API is the most useful HashMap feature. It lets you insert or update a value without looking up the key twice.

use std::collections::HashMap;

fn word_count(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word.to_lowercase()).or_insert(0) += 1;
    }
    counts
}

entry() returns an Entry. or_insert(0) gives you a mutable reference to the value — the existing one, or a fresh 0. Other useful variants: or_insert_with(Vec::new) for lazy defaults, or_default(), and and_modify(|v| *v += 1).or_insert(1).

Any type can be a key if it implements Hash and Eq (derive both). Note: f32/f64 do not implement these traits, because NaN != NaN breaks equality — convert floats to integers first if you need them as keys.

BTreeMap has the same API as HashMap but keeps keys sorted, and supports range queries HashMap cannot do:

use std::collections::BTreeMap;

let mut map = BTreeMap::new();
map.insert("apple".to_string(), 1);
map.insert("banana".to_string(), 2);
map.insert("cherry".to_string(), 3);

let range: Vec<_> = map.range("apple".to_string().."cherry".to_string()).collect();

Rule of thumb: use HashMap for O(1) average lookups (the default choice). Use BTreeMap only when you need sorted keys or ranges.

HashSet stores unique values and supports set math — union(), intersection(), difference(). A neat trick: HashSet::insert() returns false if the value was already present, which makes duplicate detection one line:

fn has_duplicates<T: std::hash::Hash + Eq>(items: &[T]) -> bool {
    let mut seen = std::collections::HashSet::new();
    items.iter().any(|item| !seen.insert(item))
}

VecDeque is a double-ended queue — fast push/pop at both front and back, unlike Vec which is slow at the front. It’s the right tool for sliding windows and bounded history buffers:

use std::collections::VecDeque;

fn sliding_window_average(numbers: &[f64], window_size: usize) -> Vec<f64> {
    let mut window: VecDeque<f64> = VecDeque::new();
    let mut averages = Vec::new();
    for &num in numbers {
        window.push_back(num);
        if window.len() > window_size { window.pop_front(); }
        if window.len() == window_size {
            averages.push(window.iter().sum::<f64>() / window_size as f64);
        }
    }
    averages
}

BinaryHeap is a max-heap — .pop() always returns the largest element. Implement Ord/PartialOrd on a struct to use it as a priority queue (job schedulers, pathfinding):

use std::collections::BinaryHeap;
use std::cmp::Ordering;

#[derive(Debug, Eq, PartialEq)]
struct Task { priority: u32, name: String }

impl Ord for Task {
    fn cmp(&self, other: &Self) -> Ordering { self.priority.cmp(&other.priority) }
}
impl PartialOrd for Task {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
}

Quick picks: key-value → HashMap (sorted keys → BTreeMap); unique items → HashSet (sorted → BTreeSet); FIFO queue → VecDeque; “give me the biggest” → BinaryHeap; plain list → Vec.

Modules and Crates

A module is a named container for functions, structs, and enums. It controls organization, visibility, and namespacing. Everything in a module is private by default — add pub to expose it.

mod math {
    pub fn add(a: i64, b: i64) -> i64 { a + b }
    fn internal_helper() -> i64 { 42 }  // private — module-only
}

fn main() {
    println!("{}", math::add(2, 3));  // OK
}

Private fields let you enforce invariants — the only way to build a User is through a constructor that can validate input, so no code outside the module can create an invalid one.

Visibility levels: no keyword (private to current module), pub (public to everyone), pub(crate) (public within the crate only), pub(super) (public to the parent module only).

use brings items into scope so you don’t repeat the full path:

use models::{User, Task};       // import multiple items
use models::User as AppUser;    // rename to avoid conflicts
pub use models::User;           // re-export — expose from a shorter path

As a project grows, move modules into files. In src/main.rs, mod math; tells Rust to look for src/math.rs (or src/math/mod.rs if it’s a directory with sub-modules). The file itself contains no mod keyword — the file is the module; the declaration in the parent connects it.

src/
├── main.rs      // mod math; mod models; mod utils;
├── math.rs
├── models.rs
└── utils/
    ├── mod.rs   // pub mod strings; pub mod validation;
    ├── strings.rs
    └── validation.rs

super:: accesses the parent module — useful when a sibling module needs a type from config: use super::config::AppConfig;.

A project can have both src/lib.rs (reusable library code) and src/main.rs (the binary, which imports from the library by crate name). Add external dependencies in Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
rand = "0.8"

For large projects, a Cargo workspace holds multiple crates that share one target directory and one Cargo.lock:

# Cargo.toml at the workspace root
[workspace]
members = ["core", "api", "cli"]

Three tools to run on every project: cargo fmt (formats code), cargo clippy (lints for common mistakes), cargo doc --open (generates docs).

Common mistakes: forgetting pub on an item you need from outside the module; creating a file without adding the mod declaration for it (the file alone does nothing); circular dependencies between two modules (fix by moving shared types into a third module both depend on).

Testing in Rust

Rust has testing built into the language — no separate framework to install. Write #[test], run cargo test, done.

fn add(a: i32, b: i32) -> i32 { a + b }

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

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }
}

#[cfg(test)] means this module only compiles when running tests. use super::* imports everything from the parent module, including private items — tests can call private functions directly.

Three assert macros: assert_eq!(a, b) (shows both values on failure), assert_ne!(a, b), and assert!(condition). All accept an optional formatted message as extra arguments.

For floating-point comparisons, never use assert_eq! — compare within a tolerance instead: assert!((value - 3.333).abs() < 0.01).

A test function can return Result instead of calling .unwrap() everywhere:

#[test]
fn test_parse_returns_result() -> Result<(), String> {
    let value = parse_number("42")?;
    assert_eq!(value, 42);
    Ok(())
}

If the function under test returns Err, the ? fails the test with that error message — cleaner than .unwrap().

Test that a function panics with #[should_panic], optionally checking the message so unrelated panics don’t cause a false pass:

#[test]
#[should_panic(expected = "Value must be positive")]
fn test_panic_message() {
    assert_positive(-5);
}

Mark slow tests with #[ignore] — they’re skipped by default, run explicitly with cargo test -- --ignored.

Unit tests live in the same file behind #[cfg(test)], can reach private functions, and are fast. Integration tests live in a tests/ directory at the project root, each file compiled as its own crate, and can only reach your crate’s public API — no #[cfg(test)] needed, since the whole file is a test file:

// tests/integration_test.rs
use my_project::math;

#[test]
fn test_math_operations() {
    assert_eq!(math::add(10, 20), 30);
}

Rust can also run the code examples in your doc comments as tests:

/// Adds two numbers.
///
/// ```
/// use my_project::math;
/// assert_eq!(math::add(2, 3), 5);
/// ```
pub fn add(a: i64, b: i64) -> i64 { a + b }

Run with cargo test --doc. If the example code breaks, the test fails — your docs can never silently go stale.

Rules worth keeping: test the happy path and edge cases separately, name tests after the behavior they check (test_empty_name_returns_error, not test1), and never let tests depend on each other’s state or execution order — cargo test runs tests in parallel by default.

Advanced Error Handling — thiserror and anyhow

Hand-written error types (implementing Display, From, and std::error::Error yourself) get tedious fast — every new error variant means touching three places. thiserror and anyhow are the two crates every production Rust project uses to fix this.

thiserror generates the boilerplate via derive macros:

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("{0} not found")]
    NotFound(String),
}

#[error("...")] generates Display. #[from] generates a From implementation, so ? auto-converts the inner error type into your enum. Use #[source] instead of #[from] when you want to keep the original error as the cause without an automatic From conversion (for example, when you need to attach an extra message alongside it).

anyhow takes the opposite approach: instead of a custom enum, anyhow::Error is a catch-all that can hold anything implementing std::error::Error, and ? converts into it automatically — no From impls needed anywhere:

use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .context(format!("failed to read config file '{}'", path))?;
    let config: Config = serde_json::from_str(&content)
        .context("failed to parse config as JSON")?;
    Ok(config)
}

.context() is the standout feature: it wraps the low-level error (“No such file or directory”) with what your code was actually trying to do (“failed to read config file ‘config.toml’”). Use .with_context(|| ...) instead when building the message is expensive — the closure only runs on the error path. bail!("message") returns an error immediately; ensure!(condition, "message") is like assert! but returns an Err instead of panicking.

The convention that matters: libraries use thiserror, because callers need to match on specific error variants. Applications use anyhow, because the app is the final consumer and just needs to report the error. Many real projects use both — thiserror for the public API boundary, anyhow inside internal application logic, with a #[from] anyhow::Error variant to bridge the two:

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("not found: {0}")]
    NotFound(String),
    #[error("internal server error")]
    Internal(#[from] anyhow::Error),
}

Print the full error chain with {:?} (or iterate e.chain()) to see every layer of context during debugging — invaluable for tracing what the code was doing when it failed. Never use bare String as an error type; it doesn’t implement std::error::Error, so it can’t compose with ? and context chains.

Serde and JSON

Serde converts Rust structs and enums to and from data formats — JSON, TOML, YAML, and more. You derive it once and the type works with any supported format:

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct User { name: String, email: String, age: u32 }

let json = serde_json::to_string(&user).unwrap();
let parsed: User = serde_json::from_str(&json).unwrap();

Add features = ["derive"] in Cargo.toml — without it the derive macros don’t exist.

Attributes customize the mapping. #[serde(rename = "statusCode")] on one field, or #[serde(rename_all = "camelCase")] on the whole struct, bridges Rust’s snake_case and JSON’s camelCase. #[serde(default)] (or #[serde(default = "fn_name")]) fills in a value when the field is missing from the input instead of erroring. #[serde(skip_serializing)] drops a field from output entirely (good for password hashes); #[serde(skip_serializing_if = "Option::is_none")] drops it only when empty. #[serde(flatten)] merges a nested struct’s fields into the parent object instead of nesting them.

Enums have four serialization shapes, and picking the right one matters for API design:

// Internally tagged — the common one for APIs: {"type":"Circle","radius":5.0}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

The others: externally tagged (default, {"Circle":{"radius":5.0}}), adjacently tagged (#[serde(tag = "type", content = "data")]), and untagged (#[serde(untagged)], Serde tries each variant until one parses — use only when the JSON has no type field).

When you don’t know the JSON shape at compile time, use serde_json::Value with the json! macro:

let value = json!({ "name": "Alex", "address": { "city": "Berlin" } });
let city = value.get("address").and_then(|a| a.get("city")).and_then(|c| c.as_str());

Prefer .get() over direct indexing (value["key"]) — indexing silently returns Value::Null for a missing key instead of giving you an Option you have to handle.

TOML works identically since it shares Serde’s traits — toml::to_string_pretty(&config) and toml::from_str(&toml_str). You can even convert JSON straight to TOML by deserializing into your struct from one format and serializing back out in the other.

To reject unexpected fields instead of silently ignoring them, add #[serde(deny_unknown_fields)].

HTTP with Reqwest

Reqwest is the standard async HTTP client for Rust:

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let body = reqwest::get("https://httpbin.org/get").await?.text().await?;
    println!("{}", body);
    Ok(())
}

Notice the two separate .await points: one to send the request and get headers, one to actually read the body. This lets you inspect the status code before downloading a potentially large response.

Parse JSON responses directly into a struct with .json(), which uses Serde’s Deserialize under the hood:

#[derive(Debug, Deserialize)]
struct GithubUser { login: String, name: Option<String>, public_repos: u32 }

let user: GithubUser = client.get(&url).send().await?.json().await?;

For real applications, build a reqwest::Client once and reuse it — it manages a connection pool internally and is cheap to Clone across your app. Creating a new client per request throws away that pool:

let client = reqwest::Client::builder()
    .timeout(Duration::from_secs(10))
    .user_agent("my-app/1.0")
    .build()?;

POST JSON with .json(&data), which serializes the body and sets Content-Type automatically. Add query params with .query(&[("page", "1")]) and headers with .header(name, value). Always set a timeout — an unbounded client can hang forever on a slow server — and check response.status() before parsing the body, or use .error_for_status() to turn any 4xx/5xx into an Err automatically.

A realistic API client wraps the client and base URL in a struct, and maps HTTP status codes onto a thiserror enum:

struct GithubClient { client: reqwest::Client, base_url: String }

impl GithubClient {
    async fn get_user(&self, username: &str) -> Result<GithubUser, GithubError> {
        let url = format!("{}/users/{}", self.base_url, username);
        let response = self.client.get(&url).send().await?;
        match response.status().as_u16() {
            200 => Ok(response.json().await?),
            404 => Err(GithubError::NotFound(username.to_string())),
            429 => Err(GithubError::RateLimited),
            code => Err(GithubError::Server(code)),
        }
    }
}

PUT, PATCH, and DELETE follow the identical builder pattern (client.put(url).json(&data).send(), etc).

Web API with Axum

Axum is a web framework on top of Tokio and Tower. Handlers are plain async functions — no macros — that return anything implementing IntoResponse:

use axum::{routing::get, Router};

async fn hello() -> &'static str { "Hello, World!" }

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(hello));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

This section builds a small in-memory Todo API — the same domain the next section (SQLx) swaps onto a real database, so the two build on one continuous example.

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo { id: u64, title: String, completed: bool }

#[derive(Debug, Deserialize)]
struct CreateTodo { title: String }

Shared state uses Arc<RwLock<...>>Arc lets every handler share the same data, RwLock lets many GET requests read at once but gives POST/PUT/DELETE exclusive write access:

#[derive(Clone)]
struct AppState {
    todos: Arc<RwLock<HashMap<u64, Todo>>>,
    next_id: Arc<RwLock<u64>>,
}

Extractors parse the request and appear as handler parameters — Axum fills them in for you: State(state): State<AppState> for shared state, Path(id): Path<u64> for URL segments (/todos/{id} in Axum 0.8), Json(input): Json<CreateTodo> for the request body, Query(params): Query<PaginationParams> for ?page=1&limit=10.

A handler returning a tuple sets both the status and body:

async fn create_todo(State(state): State<AppState>, Json(input): Json<CreateTodo>) -> impl IntoResponse {
    if input.title.trim().is_empty() {
        return (StatusCode::BAD_REQUEST, Json(json!({"error": "Title cannot be empty"})));
    }
    let mut next_id = state.next_id.write().await;
    let id = *next_id;
    *next_id += 1;
    let todo = Todo { id, title: input.title.trim().to_string(), completed: false };
    state.todos.write().await.insert(id, todo.clone());
    (StatusCode::CREATED, Json(json!({"data": todo})))
}

Wire handlers to routes, chaining methods on the same path, and attach state and middleware last:

fn create_router(state: AppState) -> Router {
    let cors = CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any);
    Router::new()
        .route("/health", get(health_check))
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
        .layer(cors)
        .with_state(state)
}

For production, replace Any in CORS with a specific origin — an open CORS policy on a browser-facing API is a real security gap. Always add a /health route for load balancers.

The one gotcha that causes real production incidents: never hold a write lock across an .await point — it blocks every other handler until that await resolves. Do the slow async work first, acquire the lock last, and let it drop at the end of scope.

Database with SQLx

SQLx is an async database library supporting PostgreSQL, MySQL, and SQLite. Unlike an ORM, you write plain SQL, but it’s still type-safe and can check queries at compile time against a real database. This section swaps the Axum Todo API’s in-memory HashMap for a real SQLite table — same Todo struct, same routes, real persistence.

use sqlx::FromRow;

#[derive(Debug, Clone, Serialize, Deserialize, FromRow, PartialEq)]
struct Todo { id: i64, title: String, completed: bool }

#[derive(FromRow)] maps a database row onto the struct by matching column names to field names.

Always use a connection pool — it reuses connections instead of opening one per query:

use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};

async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
    SqlitePoolOptions::new().max_connections(5).connect(database_url).await
}

Create tables with a plain CREATE TABLE IF NOT EXISTS, or for real projects use SQLx’s migration system (sqlx migrate add ..., then sqlx::migrate!("./migrations").run(&pool).await? at startup).

Always bind user input — never format it into the SQL string. .bind() is what makes this safe from SQL injection:

async fn insert_todo(pool: &SqlitePool, title: &str) -> Result<Todo, sqlx::Error> {
    let result = sqlx::query("INSERT INTO todos (title, completed) VALUES (?, FALSE)")
        .bind(title)
        .execute(pool)
        .await?;
    let id = result.last_insert_rowid();
    sqlx::query_as::<_, Todo>("SELECT id, title, completed FROM todos WHERE id = ?")
        .bind(id)
        .fetch_one(pool)
        .await
}

SQLite and MySQL use ? placeholders; PostgreSQL uses $1, $2. Three fetch methods matter: fetch_one (errors on zero or multiple rows), fetch_optional (returns Option<T> — the right choice when a row might legitimately not exist), and fetch_all (Vec<T>). Use sqlx::query_as when mapping to a FromRow struct; use plain sqlx::query with row.get("column") for ad-hoc values like a COUNT(*).

Updates and deletes report rows_affected() — check it to know whether the target row actually existed:

async fn delete_todo(pool: &SqlitePool, id: i64) -> Result<bool, sqlx::Error> {
    let result = sqlx::query("DELETE FROM todos WHERE id = ?").bind(id).execute(pool).await?;
    Ok(result.rows_affected() > 0)
}

When several queries must all succeed together, wrap them in a transaction. Pass &mut *tx instead of pool to each query; if the function returns before tx.commit(), the transaction rolls back automatically when tx drops:

async fn create_multiple_todos(pool: &SqlitePool, titles: &[&str]) -> Result<Vec<Todo>, sqlx::Error> {
    let mut tx = pool.begin().await?;
    let mut todos = vec![];
    for title in titles {
        let result = sqlx::query("INSERT INTO todos (title, completed) VALUES (?, FALSE)")
            .bind(title).execute(&mut *tx).await?;
        let id = result.last_insert_rowid();
        todos.push(sqlx::query_as::<_, Todo>("SELECT id, title, completed FROM todos WHERE id = ?")
            .bind(id).fetch_one(&mut *tx).await?);
    }
    tx.commit().await?;
    Ok(todos)
}

For compile-time checked queries, use the query_as! macro (note the !) with a DATABASE_URL environment variable set — it validates column names and types against a real database at cargo build time. Start with the runtime-checked functions shown above; add the macros once the schema stabilizes.

Testing is easy: create a fresh sqlite::memory: pool per test, run migrations, and go — each test is isolated even when tests run in parallel.

Part 4: Tools, Systems, and the Capstone Project

CLI tools, file handling, macros, embedded systems, AI/ML, and WebAssembly — Rust’s reach outside the typical web backend. The guide closes with the capstone: a real project that uses almost everything covered above.

CLI Tools with Clap

Clap is the standard library for building command-line tools in Rust. Fast tools like ripgrep, bat, and fd all use it. Writing your own argument parser means you also write your own help text, error messages, and validation. Clap generates all of that from a struct.

Add it with the derive feature, which lets you define a CLI as a plain struct:

[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "greeter", about = "A simple greeter")]
struct Cli {
    /// Name of the person to greet
    name: String,
    #[arg(short, long, default_value = "1")]
    count: u32,
    #[arg(short, long)]
    uppercase: bool,
}

fn main() {
    let cli = Cli::parse();
    for i in 1..=cli.count {
        let msg = format!("Hello, {}! (#{}/{})", cli.name, i, cli.count);
        println!("{}", if cli.uppercase { msg.to_uppercase() } else { msg });
    }
}

A field with no #[arg] attribute becomes a required positional argument. #[arg(short, long)] creates both a -c and --count flag. A bool field becomes a flag: present means true. Option<T> fields are optional. Doc comments (///) become the --help text automatically — you never write help text by hand.

Real tools need subcommands, like git commit or cargo build. Model them with an enum:

use clap::{Parser, Subcommand, Args};

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
    #[arg(short, long, global = true)]
    verbose: bool,
}

#[derive(Subcommand)]
enum Commands {
    Add(AddArgs),
    List(ListArgs),
    Search { query: String },
}

#[derive(Args)]
struct AddArgs {
    title: String,
    #[arg(short, long)]
    content: Option<String>,
}

#[derive(Args)]
struct ListArgs {
    #[arg(short, long, default_value = "10")]
    max: usize,
}

fn main() {
    let cli = Cli::parse();
    match &cli.command {
        Commands::Add(args) => println!("Adding: {}", args.title),
        Commands::List(args) => println!("Listing (max {})", args.max),
        Commands::Search { query } => println!("Searching for: {}", query),
    }
}

#[arg(global = true)] on verbose makes it work with any subcommand (notetool -v list). When the user must pick from a fixed set of values, use #[derive(ValueEnum)] on an enum instead of a String — Clap validates the input and rejects anything outside the set with a clear error, no manual match needed.

Validate numeric ranges directly in the attribute:

#[arg(short, long, value_parser = clap::value_parser!(u16).range(1024..=65535))]
port: u16,

Test parsing without running the binary — try_parse_from returns a Result instead of exiting the process:

#[test]
fn test_list_defaults() {
    let cli = Cli::try_parse_from(["notetool", "list"]).unwrap();
    match cli.command {
        Commands::List(args) => assert_eq!(args.max, 10),
        _ => panic!("expected List"),
    }
}

File I/O and Path Handling

Every file operation in Rust returns a Result. There is no silent failure — you always handle the error case. Two path types matter: Path is a borrowed reference (like &str), PathBuf is owned and growable (like String). Use Path in function parameters, PathBuf when you build or store a path.

use std::path::{Path, PathBuf};

let path = Path::new("/home/user/report.txt");
println!("{:?}", path.file_name());   // "report.txt"
println!("{:?}", path.extension());   // "txt"

let mut buf = PathBuf::from("/home/user");
buf.push("documents");
buf.push("report.txt");

Never build paths with string concatenation — use .join(). It handles the right separator for Linux, macOS, and Windows automatically:

let full = Path::new("/home/user").join("documents").join("report.txt");

For small files, read the whole thing into memory. For large files, wrap the file in a BufReader so you don’t make a system call per byte:

use std::fs;
use std::io::{BufRead, BufReader};

let content = fs::read_to_string("config.txt")?;   // whole file as String
let bytes = fs::read("image.png")?;                 // whole file as bytes

let file = fs::File::open("large_file.txt")?;
let reader = BufReader::new(file);
let mut count = 0;
for line in reader.lines() {
    let _line = line?;   // each line is its own Result
    count += 1;
}

Writing follows the same shape. fs::write() creates or overwrites a file in one call. For many lines, use BufWriter and always call .flush() at the end — a BufWriter that gets dropped without flushing can silently lose its last chunk:

use std::io::{BufWriter, Write};

let file = fs::File::create("report.csv")?;
let mut writer = BufWriter::new(file);
writeln!(writer, "Name,Score")?;
for (name, score) in &data {
    writeln!(writer, "{},{}", name, score)?;
}
writer.flush()?;

To append instead of overwrite, use OpenOptions:

use std::fs::OpenOptions;

let mut file = OpenOptions::new().append(true).create(true).open("app.log")?;
writeln!(file, "Server started")?;

fs::read_dir() only lists one level of a directory. To walk a full tree, recurse manually:

fn walk_recursive(dir: &Path, result: &mut Vec<PathBuf>) -> std::io::Result<()> {
    if dir.is_dir() {
        for entry in fs::read_dir(dir)? {
            let path = entry?.path();
            if path.is_dir() {
                walk_recursive(&path, result)?;
            } else {
                result.push(path);
            }
        }
    }
    Ok(())
}

A useful gotcha: match on ErrorKind to handle specific failures differently, like creating a file only if it’s missing:

use std::io::ErrorKind;

match fs::read_to_string(path) {
    Ok(content) => Ok(content),
    Err(e) if e.kind() == ErrorKind::NotFound => {
        fs::write(path, default)?;
        Ok(default.to_string())
    }
    Err(e) => Err(e),
}

Rule of thumb: if a file might be larger than a few MB, use BufReader/BufWriter instead of the one-shot fs::read_to_string/fs::write.

Macros — Writing Code That Writes Code

A macro expands into code at compile time. You already use them: println!(), vec![], format!(). Functions run at runtime and take fixed argument lists; macros run at compile time and can take a variable number of arguments or generate whole struct definitions — something a function can never do.

macro_rules! greet {
    ($name:expr) => {
        format!("Hello, {}!", $name)
    };
}

let msg = greet!("Alex");   // "Hello, Alex!"

$name:expr captures any expression. The :expr part is a fragment specifier — it tells the macro parser what kind of syntax to expect (expr, ident for names, ty for types, tt for any single token, literal).

A macro can have multiple match arms, just like match:

macro_rules! calculate {
    (add $a:expr, $b:expr) => { $a + $b };
    (sub $a:expr, $b:expr) => { $a - $b };
}

calculate!(add 5, 3);   // 8

Repetition is what makes macros powerful. $(...)+ means one or more, $(...)* means zero or more. Here is a simplified rebuild of vec!:

macro_rules! my_vec {
    () => { Vec::new() };
    ($($element:expr),+ $(,)?) => {{
        let mut v = Vec::new();
        $(v.push($element);)+
        v
    }};
}

let nums = my_vec![1, 2, 3];   // [1, 2, 3]

$(v.push($element);)+ expands once per captured element, so my_vec![1, 2, 3] becomes three push calls. The same pattern works for key-value macros (building a HashMap) or for generating a whole struct with a constructor:

macro_rules! make_struct {
    ($name:ident { $($field:ident : $type:ty),+ $(,)? }) => {
        #[derive(Debug, Clone)]
        struct $name { $($field: $type,)+ }
        impl $name {
            fn new($($field: $type),+) -> Self { Self { $($field,)+ } }
        }
    };
}

make_struct!(Point { x: f64, y: f64 });
let p = Point::new(3.0, 4.0);

stringify! is a built-in macro that turns an expression into a string literal at compile time, without evaluating it — useful for debug helpers:

macro_rules! debug_var {
    ($var:expr) => { println!("{} = {:?}", stringify!($var), $var); };
}

Rust macros are hygienic: a variable created inside a macro never leaks into the code that called it, even if they share a name. This prevents accidental collisions.

Rule of thumb: start with a function. Reach for a macro only when a function truly cannot do the job — variable-length arguments, generating code, or compile-time string manipulation. For debugging a macro, cargo install cargo-expand then cargo expand shows exactly what your macro produced.

Embedded Rust — no_std and Embassy

Embedded systems (microcontrollers) have zero tolerance for bugs — a crash in a pacemaker or a car’s brakes is not recoverable. C has been the default embedded language, but it gives no protection against buffer overflows or null pointers. Rust’s compile-time checks catch these before the code ever ships, with zero runtime cost.

Microcontrollers have no operating system, so the full standard library (std) does not work — it needs heap allocation, threads, and file I/O. no_std means “use only core”, the subset of the standard library that has no OS dependency: basic types, Option, Result, iterators, traits, and const generics all still work. Vec, String, HashMap, println!, and threads do not exist without an allocator or an OS.

Without Vec, embedded code uses fixed-size buffers, sized at compile time with const generics:

struct FixedBuffer<const N: usize> {
    data: [u8; N],
    len: usize,
}

impl<const N: usize> FixedBuffer<N> {
    fn new() -> Self { Self { data: [0u8; N], len: 0 } }

    fn push(&mut self, byte: u8) -> Result<(), BufferError> {
        if self.len >= N { return Err(BufferError::Full); }
        self.data[self.len] = byte;
        self.len += 1;
        Ok(())
    }
}

A ring buffer is the same idea with wraparound — it lets an interrupt handler write data while the main loop reads it, common for UART or audio buffers:

fn write(&mut self, byte: u8) -> Result<(), BufferError> {
    if self.count >= N { return Err(BufferError::Full); }
    self.data[self.write_pos] = byte;
    self.write_pos = (self.write_pos + 1) % N;   // wraps around at N
    self.count += 1;
    Ok(())
}

The embedded-hal crate defines traits for hardware peripherals (OutputPin, InputPin, and similar). Every chip vendor implements these traits for their own hardware, so your driver code is written once and runs on any supported chip:

struct Led<P: OutputPin> { pin: P }

impl<P: OutputPin> Led<P> {
    fn on(&mut self) { self.pin.set_high(); }
    fn off(&mut self) { self.pin.set_low(); }
}

State machines model device behavior cleanly with enums and an exhaustive match — the compiler forces you to handle every state/event combination, unlike a C switch where a missing case fails silently:

enum DeviceState { Idle, Initializing, Running, Error(&'static str), Shutdown }
enum Event { Start, Ready, Tick, Stop, Fault(&'static str), Reset }

fn transition(&mut self, event: Event) -> Result<(), &'static str> {
    self.state = match (&self.state, event) {
        (DeviceState::Idle, Event::Start) => DeviceState::Initializing,
        (DeviceState::Initializing, Event::Ready) => DeviceState::Running,
        (DeviceState::Running, Event::Fault(msg)) => DeviceState::Error(msg),
        (DeviceState::Error(_), Event::Reset) => DeviceState::Idle,
        (_, _) => return Err("Invalid state transition"),
    };
    Ok(())
}

Interrupt handlers and the main loop share data safely through atomics — no mutex needed, and no risk of a data race:

use core::sync::atomic::{AtomicU32, Ordering};

static COUNT: AtomicU32 = AtomicU32::new(0);
// called from the interrupt handler
COUNT.fetch_add(1, Ordering::Relaxed);
// called from the main loop
let n = COUNT.swap(0, Ordering::Relaxed);

Embassy brings async/await to embedded Rust — instead of one big interrupt-driven main loop, you spawn independent async tasks that .await on timers or pin events. It compiles down to the same efficient code as manual interrupt handling, with no heap and no OS:

#[embassy_executor::task]
async fn blink_led(mut pin: AnyPin) {
    loop {
        pin.set_high();
        Timer::after_millis(500).await;
        pin.set_low();
        Timer::after_millis(500).await;
    }
}

To run real embedded Rust, you need a supported board (Raspberry Pi Pico/RP2040 is the cheapest starting point), the right compile target (rustup target add thumbv7em-none-eabihf for a Cortex-M4), a HAL crate for your chip (embassy-rp, embassy-stm32, embassy-nrf), and probe-rs to flash and debug it.

Rust for AI and Machine Learning

Python dominates AI/ML, but Rust is growing in this space for four reasons: it is 10-100x faster for raw computation, it has no garbage collector so memory use is predictable, it gives safe parallelism with no data races, and it deploys as a single binary with no Python runtime needed. Notably, several popular Python ML libraries (Polars, pydantic, ruff) are already written in Rust underneath.

Every ML algorithm relies on matrices. Store one as a flat Vec<f64> in row-major order (element (i, j) lives at i * cols + j — the same layout NumPy uses):

struct Matrix { data: Vec<f64>, rows: usize, cols: usize }

impl Matrix {
    fn get(&self, row: usize, col: usize) -> f64 { self.data[row * self.cols + col] }
    fn set(&mut self, row: usize, col: usize, value: f64) { self.data[row * self.cols + col] = value; }
}

fn multiply(&self, other: &Matrix) -> Matrix {
    assert_eq!(self.cols, other.rows);
    let mut result = Matrix::new(self.rows, other.cols);
    for i in 0..self.rows {
        for j in 0..other.cols {
            let mut sum = 0.0;
            for k in 0..self.cols { sum += self.get(i, k) * other.get(k, j); }
            result.set(i, j, sum);
        }
    }
    result
}

This is the naive O(n³) multiply — real libraries like ndarray use optimized BLAS routines instead. Activation functions add non-linearity to neural networks:

fn sigmoid(x: f64) -> f64 { 1.0 / (1.0 + (-x).exp()) }   // squashes to [0, 1]
fn relu(x: f64) -> f64 { if x > 0.0 { x } else { 0.0 } }  // simple, used in most modern nets

A single neuron with sigmoid activation can already learn simple logic through backpropagation — compute the error, compute the gradient, nudge the weights:

fn train(&mut self, inputs: &[f64], target: f64) -> f64 {
    let prediction = self.forward(inputs);
    let error = target - prediction;
    let raw_sum = /* weighted sum + bias */;
    let gradient = error * sigmoid_derivative(raw_sum);
    for (w, x) in self.weights.iter_mut().zip(inputs) {
        *w += self.learning_rate * gradient * x;
    }
    self.bias += self.learning_rate * gradient;
    error.powi(2)
}

Trained for 2000 rounds on AND-gate data, this neuron correctly outputs near 0 for [0,0], [0,1], [1,0] and near 1 for [1,1]. K-Nearest Neighbors is the simplest classifier — it has no training phase at all, it just stores the data and, at prediction time, finds the k closest points by distance and takes a majority vote:

fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
    a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::<f64>().sqrt()
}

Feature scaling matters because raw values like “age: 25, salary: 80000” give salary far too much weight. Min-max normalization scales to [0, 1] when you know the data range; z-score normalization centers on 0 with unit standard deviation when you don’t:

fn min_max_normalize(data: &[f64]) -> Vec<f64> {
    let min = data.iter().cloned().fold(f64::INFINITY, f64::min);
    let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    data.iter().map(|v| (v - min) / (max - min)).collect()
}

To judge a trained classifier, use a confusion matrix: accuracy is the fraction of correct predictions, precision is how many predicted-positive items were actually positive, recall is how many actual positives you found, and F1 is the harmonic mean of precision and recall.

For production work, reach for the real ecosystem instead of hand-rolled code: Polars for DataFrames (10-100x faster than pandas, with lazy query optimization), Burn for a PyTorch-like deep learning framework with pluggable CPU/GPU backends, PyO3 to expose fast Rust functions to Python, and candle for LLM inference. Prototype in Python where iteration speed matters most; move performance-critical paths to Rust once the design is proven.

WebAssembly with Rust

WebAssembly (WASM) is a binary format that runs in the browser alongside JavaScript, at near-native speed. Rust is a strong fit for it: no garbage collector (WASM has none either), tiny compiled binaries, and Rust’s memory safety carries over into the browser sandbox.

Functions marked #[wasm_bindgen] become callable from JavaScript. The most common use case is heavy computation that would be slow in JavaScript:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
    if n <= 1 { return n as u64; }
    let (mut a, mut b) = (0u64, 1u64);
    for _ in 2..=n { let t = a + b; a = b; b = t; }
    b
}

Strings and complex data cross the Rust/JavaScript boundary through shared memory, handled automatically by wasm-bindgen for simple types, and by serde + serde-wasm-bindgen for structured JSON data. A WASM module can also hold its own state between calls — JavaScript never touches that memory directly, only through the exported functions you choose to expose:

impl WasmState {
    fn increment(&mut self) -> i32 { self.counter += 1; self.counter }
}

Frameworks like Leptos build a full UI in Rust using a virtual DOM and fine-grained reactive signals — a value that automatically re-renders only the exact DOM node that depends on it, which is more efficient than React’s component-level re-render:

use leptos::*;

#[component]
fn Counter() -> impl IntoView {
    let (count, set_count) = create_signal(0);
    view! {
        <button on:click=move |_| set_count.update(|n| *n += 1)>
            "Clicked: " {count} " times"
        </button>
    }
}

Image and pixel processing is one of WASM’s best use cases — filters that take seconds in JavaScript run in milliseconds compiled to WASM:

fn grayscale(&self) -> ImageBuffer {
    let pixels = self.pixels.iter().map(|p| {
        let gray = (0.299 * p.r as f64 + 0.587 * p.g as f64 + 0.114 * p.b as f64) as u8;
        Pixel { r: gray, g: gray, b: gray, a: p.a }
    }).collect();
    ImageBuffer { pixels, width: self.width, height: self.height }
}

To build a real WASM project: cargo new --lib my-app, set crate-type = ["cdylib"] in Cargo.toml, add wasm-bindgen, mark exported functions #[wasm_bindgen], then run wasm-pack build --target web. It compiles to .wasm, generates the JavaScript glue code, and produces an npm-installable package. Load it from an HTML page with a small async init() call, then call your Rust functions like normal JavaScript functions.

Good fits for WASM: image/video processing, games, cryptography, Canvas/WebGL visualization, and full apps built with Leptos. Poor fits: simple DOM manipulation (plain JavaScript is fine) and SEO-heavy content pages (server-rendered HTML wins there) — the WASM module itself has real loading overhead.

Unsafe Rust

unsafe does not mean “dangerous” — it means “the programmer, not the compiler, is responsible for correctness here.” Everything else about Rust (the borrow checker, the type system, lifetimes) still applies inside an unsafe block. The keyword unlocks exactly five things: dereferencing raw pointers, calling unsafe fn, accessing mutable statics, implementing unsafe traits, and reading union fields.

Creating a raw pointer is safe; dereferencing it is unsafe, because the pointer might be null, dangling, misaligned, or aliased by another mutable pointer:

let x = 42;
let r1 = &x as *const i32;
unsafe { println!("{}", *r1); }

The single most important pattern in unsafe Rust is the safe wrapper: do the unsafe operation inside a function that validates its precondition first, so callers never need to write unsafe themselves.

unsafe fn split_at_unchecked(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let ptr = slice.as_mut_ptr();
    unsafe {
        (std::slice::from_raw_parts_mut(ptr, mid),
         std::slice::from_raw_parts_mut(ptr.add(mid), slice.len() - mid))
    }
}

fn split_at_safe(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    assert!(mid <= slice.len(), "mid out of bounds");
    unsafe { split_at_unchecked(slice, mid) }
}

This is exactly how the standard library works — Vec, String, and HashMap all use unsafe internally but expose a 100% safe public API. A minimal Vec-like type shows the pattern: ptr.add(n).write(value) writes without dropping the old value, ptr.add(n).read() moves a value out, and the Drop impl must manually run each destructor and free the memory block, or you leak resources:

impl<T> Drop for SimpleVec<T> {
    fn drop(&mut self) {
        for i in 0..self.len { unsafe { self.ptr.add(i).drop_in_place(); } }
        unsafe { std::alloc::dealloc(self.ptr as *mut u8, layout); }
    }
}

FFI (Foreign Function Interface) lets Rust call C code, which is inherently unsafe since C makes no safety guarantees at all:

extern "C" {
    fn strlen(s: *const std::os::raw::c_char) -> usize;
}
unsafe { let len = strlen(c_string.as_ptr()); }

std::mem::transmute reinterprets the raw bits of one type as another — powerful but easy to misuse. Prefer the safe, purpose-built alternative when one exists, like f32::to_bits() instead of transmuting a float to a u32. The same logic applies to global mutable state: static mut requires unsafe on every access and is a real data-race risk under multiple threads — use AtomicI32 or Mutex instead, which need no unsafe at all.

Rules for writing unsafe code: keep the unsafe {} block as small as possible, document exactly what invariant the caller must uphold, always wrap it in a safe public API, and test it with cargo +nightly miri test — Miri detects undefined behavior that a normal test run would miss.

Capstone: Building LinkShort — a URL Shortener

This project ties the whole series together: ownership, error handling, async, web APIs, databases, and CLI tools, in one working application called LinkShort. It has two parts that talk to each other over HTTP — a REST API built with Axum and SQLx, and a CLI built with Clap and Reqwest — sharing one library crate for data types.

Workspace setup. A Cargo workspace with three members keeps the API, the CLI, and the shared types in separate crates that build together:

[workspace]
members = ["shared", "api", "cli"]
resolver = "2"

The shared crate holds the types both binaries need, so no struct gets defined twice:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Link {
    pub id: i64,
    pub short_code: String,
    pub target_url: String,
    pub clicks: i64,
    pub created_at: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct CreateLink {
    pub target_url: String,
    pub short_code: Option<String>,
}

The API. SQLite needs no separate server to install — ?mode=rwc in the connection URL creates the database file on first run:

pub async fn create_pool() -> SqlitePool {
    let pool = SqlitePool::connect("sqlite:linkshort.db?mode=rwc").await.expect("connect failed");
    sqlx::query(
        "CREATE TABLE IF NOT EXISTS links (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE NOT NULL,
            target_url TEXT NOT NULL,
            clicks INTEGER DEFAULT 0,
            created_at TEXT DEFAULT (datetime('now'))
        )"
    ).execute(&pool).await.expect("create table failed");
    pool
}

Every handler returns a Result — Axum converts the Ok/Err into the right HTTP response automatically. The redirect handler is the interesting one: it increments the click counter and reads the target URL in the same SQL statement using RETURNING, so there is no race condition between two concurrent requests:

pub async fn redirect_link(
    State(pool): State<SqlitePool>,
    Path(code): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    let link = sqlx::query_as!(
        Link,
        "UPDATE links SET clicks = clicks + 1 WHERE short_code = ?
         RETURNING id, short_code, target_url, clicks, created_at",
        code
    ).fetch_optional(&pool).await
     .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

    match link {
        Some(link) => Ok(Redirect::temporary(&link.target_url)),
        None => Err((StatusCode::NOT_FOUND, "Link not found".to_string())),
    }
}

Wiring the routes together in main.rs:

#[tokio::main]
async fn main() {
    let pool = db::create_pool().await;
    let app = Router::new()
        .route("/api/links", post(handlers::create_link))
        .route("/api/links", get(handlers::list_links))
        .route("/api/links/{code}", delete(handlers::delete_link))
        .route("/api/stats", get(handlers::get_stats))
        .route("/{code}", get(handlers::redirect_link))   // short URL redirect
        .layer(CorsLayer::permissive())
        .with_state(pool);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.expect("bind failed");
    axum::serve(listener, app).await.expect("server failed");
}

API routes live under /api/; the short-link redirect lives at the root — a standard layout for URL shorteners.

The CLI. It never touches the database directly — it only speaks HTTP to the API, which means it can run from any machine that can reach the server:

#[derive(Parser)]
struct Cli {
    #[arg(long, default_value = "http://localhost:3000")]
    server: String,
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Create { url: String, #[arg(short, long)] code: Option<String> },
    List,
    Stats,
    Delete { code: String },
}

async fn create_link(client: &reqwest::Client, server: &str, url: &str, code: Option<String>) -> Result<(), String> {
    let body = CreateLink { target_url: url.to_string(), short_code: code };
    let response = client.post(format!("{server}/api/links")).json(&body).send().await
        .map_err(|e| format!("Request failed: {e}"))?;
    if !response.status().is_success() {
        return Err(format!("Server error: {}", response.text().await.unwrap_or_default()));
    }
    let link: Link = response.json().await.map_err(|e| format!("Invalid response: {e}"))?;
    println!("Created: {server}/{}", link.short_code);
    Ok(())
}

How one request flows through every crate: running linkshort-cli create https://example.com triggers Clap (parses the arguments) → Reqwest (POSTs a JSON body, serialized by serde) → Axum (receives the request, extracts Json<CreateLink>) → SQLx (inserts the row into SQLite and returns it) → Axum (wraps the row in Json<Link>, serialized by serde) → Reqwest (deserializes the response) → the CLI prints the result. Serde is the glue on both ends of the wire.

Validating input before it hits the database — a HEAD request confirms the target URL is actually reachable, without downloading the full page:

async fn validate_url(url: &str) -> Result<(), String> {
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(5)).build()
        .map_err(|e| format!("Client error: {e}"))?;
    let response = client.head(url).send().await
        .map_err(|_| format!("URL is not reachable: {url}"))?;
    if response.status().is_success() || response.status().is_redirection() {
        Ok(())
    } else {
        Err(format!("URL returned status {}: {url}", response.status()))
    }
}

Call it as the first line of create_link, before generating a code or touching the database, so bad input never gets stored.

Error handling across the project follows three patterns seen throughout the series: Axum handlers return Result<T, (StatusCode, String)> for simple, explicit HTTP errors; CLI functions return Result<(), String> and propagate with ?; and Option becomes a Result right where it needs to (fetch_optional returning None becomes a 404). For a bigger project you would define a proper error type with thiserror and implement IntoResponse for it — but at this size, plain tuples stay readable.

Testing the API in-memory with axum-test, no real network involved:

#[tokio::test]
async fn test_create_and_list() {
    let server = setup().await;
    let body = CreateLink { target_url: "https://example.com".to_string(), short_code: Some("test1".to_string()) };
    let response = server.post("/api/links").json(&body).await;
    assert_eq!(response.status_code(), StatusCode::CREATED);
}

Final project layout:

linkshort/
├── Cargo.toml            # workspace definition
├── shared/src/lib.rs      # Link, CreateLink, LinkStats types
├── api/src/
│   ├── main.rs             # router + server
│   ├── db.rs                # connection pool + schema
│   └── handlers.rs          # create, list, delete, redirect, stats
└── cli/src/main.rs          # Clap parser + reqwest calls

This single project exercises nearly every concept from the series at once: ownership and borrowing (passing references into handlers), Result/?/map_err error handling, structs and enums for the domain model, the Serialize/Deserialize/IntoResponse/Parser traits, async/await end to end, and generics (Json<T>, State<T>, Result<T, E>). From here, natural extensions are JWT authentication, rate limiting with Tower middleware, or a web frontend built with Leptos.

Where to Go From Here

You have gone from cargo new to a working web API with a database and a CLI client — the same shape as a small production service. A few natural next steps, each with its own dedicated guide:

The complete, working code for the capstone project (LinkShort) is on GitHub: github.com/kemalcodes/rust-tutorial.