Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Welcome

CodeCora Rust Learning Hub — the internal course for engineers learning Rust the way we actually use it: production-flavored, project-first.

Why this exists

Everything we ship at CodeCora is Rust-shaped: Uteke (search engine), Sinau LMS (Axum + sqlx), nginjen (Axum scraper), vecq, gatehouse, Bifrost tooling — all Axum services, sqlx-backed, tokio-async. This course teaches exactly that stack, in the order the stack demands it.

How this course works

Every module is a project you build and keep, not a tour of language features. Foundational theory shows up just in time, inside the project it serves.

Each chapter follows the same shape:

  1. Goal — one observable capability (“you can X”).
  2. Concepts — the minimum theory, concept-first.
  3. Lab walkthrough — real, verified code from the labs/ directory.
  4. Exercises — concrete tasks with a checkable “done” criterion.

Every code sample in this book compiles and passes cargo test, cargo clippy -D warnings, and cargo fmt on Rust 1.96 (the toolchain we standardize on). The labs live in labs/ — clone, run, break, fix.

Curriculum at a glance

ModuleProject you keepRust you internalize
GateTested CLI binarytoolchain, cargo, panic vs error
M1Log parser CLIownership, borrowing, structs, enums
M2Error-typed parser libResult, ?, Display, From, error codes
M3Search/filter enginetraits, generics, iterators, tests
M4Concurrent fetcherasync/await, tokio, select, timeouts
M5REST API (Axum)routers, extractors, middleware, state
M6Persistence layersqlx, migrations, SQLite/Postgres
M7CI-hardened serviceclippy, fmt, tests, GH Actions gates
M8 + CapstoneMini search serviceworkspace crates, end-to-end assembly

Ground rules

  • Tested means live. Nothing is “done” because it compiles — it is done when the test suite is green and the binary behaves.
  • No unsafe, no unwrap() in production paths. Panics are for programmer bugs; user input problems are values (Result), never crashes.
  • One error enum per crate. The M2 pattern is the house style — you will see it again in every Axum service we run.
  • English artifacts. Code, comments, commits — all English (house standard).

This site is private — internal use only. Do not share links outside the team.

Setup & Toolchain

Goal: you can verify a working Rust toolchain and explain what each core cargo command does — in under 30 minutes.

Install

Use rustup (the official toolchain manager). On Linux/macOS:

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

Verify:

rustc --version   # compiler — we standardize on 1.96.x
cargo --version   # build tool + package manager
rustup update     # run occasionally; Rust ships every 6 weeks

Why rustup, not apt? Distro packages are always stale. Rust’s release cadence is 6 weeks; rustup also manages multiple toolchains (stable / nightly / specific MSRV) and cross-compile targets. Everything CodeCora runs on ARM (aarch64) — rustup handles that transparently.

Editor

Any editor with rust-analyzer works. Minimum setup:

  • rust-analyzer (LSP): completions, inline types, go-to-definition through macros
  • rustfmt on save (the Format check in our CI is cargo fmt --all -- --check)
  • clippy as the lint source (CI runs cargo clippy --all-targets -- -D warnings)

The cargo commands you will use daily

CommandWhat it doesWhen
cargo new <name>scaffold a binary cratestarting any project
cargo runbuild + executeinner loop
cargo build --releaseoptimized buildperf / shipping
cargo testrun all testsbefore every commit
cargo checktype-check without codegenfastest feedback
cargo clippy -- -D warningslint; warnings are errorsbefore every commit
cargo fmtauto-format (rustfmt)before every commit
cargo add <crate>add a dependencywhen a crate earns its place

The house loop (burn this in — it is also our CI gate):

cargo fmt && cargo clippy --all-targets -- -D warnings && cargo test

Crates, packages, modules — 60 seconds

  • Package = a Cargo.toml (what cargo new creates). Builds one or more crates.
  • Crate = one compilation unit: a binary (src/main.rs) or a library (src/lib.rs).
  • Module = a namespace inside a crate (mod parser;src/parser.rs).
  • Dependency = someone else’s crate, from crates.io, pinned in Cargo.toml + locked in Cargo.lock (commit the lockfile for binaries).

Lab: clone and run the gate lab

git clone https://github.com/codecoradev/rust-lms
cd rust-lms/labs/gate-hello
cargo test          # expect: 2 passed
cargo run           # Hello from Rust 0.1.0 — toolchain works!
cargo run -- --add 40 2    # 40 + 2 = 42

Exercises

  1. Verify — run the house loop above in labs/gate-hello. All three must pass.
  2. Break it — change --add to print a - b instead. Re-run the loop.
  3. Explain — out loud, in one sentence each: what cargo check does that cargo build skips, and why that makes it faster.

Done when: the house loop is green after your change, and you ran the binary at least once.

Your First Rust Program

Goal: you can read the gate lab line by line and explain every construct in it — including why one deliberate line panics.

The whole program

labs/gate-hello/src/main.rs (short version):

use std::env;

fn main() {
    let args: Vec<String> = env::args().skip(1).collect();

    match args.first().map(String::as_str) {
        Some("--add") => {
            let a: i64 = args.get(1).expect("usage: gate-hello --add <A> <B>")
                .parse().expect("first operand must be an integer");
            let b: i64 = args.get(2).expect("usage: gate-hello --add <A> <B>")
                .parse().expect("second operand must be an integer");
            println!("{a} + {b} = {}", a + b);
        }
        Some("--crash") => {
            let missing: Option<i64> = env::var("DELIBERATELY_MISSING")
                .ok()
                .and_then(|s| s.parse().ok());
            println!("{}", missing.expect("set DELIBERATELY_MISSING=<number> to avoid this panic"));
        }
        _ => {
            println!("Hello from Rust {} — toolchain works!", env!("CARGO_PKG_VERSION"));
        }
    }
}

What each piece teaches

LineConceptNotes
use std::env;importsbring the env module into scope
env::args().skip(1).collect()iteratorsargs() yields Strings; skip(1) drops the binary name
args.first().map(String::as_str)Optionempty args → None, no out-of-bounds possible
match ... { Some("--add") => ..., _ => ... }matchexhaustive by design — forget a case and it will not compile
let a: i64 = ... .parse().expect(...)turbofish-free inferenceparse::<i64>() via the type annotation; parse returns Result
.expect("...")fail fastfine in labs/CLIs; never in library or service code — that is M2’s topic
env!("CARGO_PKG_VERSION")compile-time envbaked in by cargo from Cargo.toml
{a} + {b} in the stringinline formattingformat captures, stable since 1.58

Panic vs error — the distinction that shapes everything

Run the two failure modes side by side:

cargo run -- --crash                    # panics: process dies, rc=101
DELIBERATELY_MISSING=7 cargo run -- --crash   # value existed: rc=0
  • A panic is for programmer bugs — impossible states, broken invariants. Fail fast, loud, with a message.
  • An error value (Result) is for expected failure — bad input, missing file, unreachable network. The type system forces the caller to handle it.

Why is --crash a panic then? Because it simulates a broken invariant for teaching. In real code, a CLI reading user input should return ExitCode errors (M1) and a library should return Result (M2). Our Axum services never unwrap() user input — that discipline starts here.

Tests live next to the code

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn binary_has_a_version() {
        assert!(!env!("CARGO_PKG_VERSION").is_empty());
    }
}
}
  • #[cfg(test)] — compiled only for cargo test, zero cost in release builds.
  • #[test] — the runner discovers and runs it; a panic inside = failure.
  • assert! / assert_eq! — the workhorses. No separate test framework needed.

Exercises

  1. Trace it — add dbg!(&args); as the first line of main, run all three modes, and write down what dbg! prints (file, line, expression, value).
  2. Extend — add a --echo branch that prints all remaining args joined by spaces.
  3. Feel the compiler — remove the _ => arm. Try to compile. Read the error. Put it back. That error is match exhaustiveness — you will learn to love it.

Done when: all three modes work, the removed-_-arm compile error is something you can explain in one sentence, and the house loop is green.

Ownership, Borrowing, Slices

Goal: you can predict which log-parser designs the borrow checker accepts — and explain why — without compiling first.

The three rules

  1. Each value has exactly one owner; when the owner goes out of scope, the value is dropped (freed). No GC, no manual free.
  2. At any time you may have either one mutable reference (&mut T) or any number of shared references (&T) — never both.
  3. References must never outlive the data they point at (no dangling).

That is the entire model. Everything that feels restrictive in week one becomes a concurrency-safety guarantee by month two: data races are compile errors.

Move, clone, borrow

#![allow(unused)]
fn main() {
let s1 = String::from("boot");
let s2 = s1;            // MOVE: s1 is now invalid (String owns heap memory)
// println!("{s1}");    // ← compile error: borrow of moved value
let s3 = s1.clone();    // explicit deep copy — visibility over hidden cost
let len = strlen(&s3);  // BORROW: s3 still usable afterwards

fn strlen(s: &str) -> usize { s.len() }
}

i64, bool, char and friends are Copy types — assignment copies, no move semantics. Strings and Vecs move. That asymmetry is the #1 week-one confusion; name it early.

Slices: borrow a view, not a copy

#![allow(unused)]
fn main() {
let line = String::from("2026-09-19T09:30:00Z INFO boot complete");
let ts: &str = &line[..20];        // borrowed view into line's buffer
let msg: &str = line.split_once(' ').unwrap().1; // same buffer, different window
}

A slice is a pointer + length — no allocation. The parser lab leans on this: split a line into &str views, and only to_string() the pieces that must outlive the input.

The lab: what moves where

labs/m1-logpar — parse 2026-09-19T09:30:00Z INFO boot complete lines and count levels. The ownership decisions, annotated:

#![allow(unused)]
fn main() {
struct LogEntry {
    ts: String,   // OWNED — entries outlive the parse() borrow of the input
    level: Level, // COPY — a tiny enum, no heap
    msg: String,  // OWNED
}

fn parse_line(line: &str) -> Option<LogEntry> {  // borrows the line...
    let mut parts = line.splitn(3, ' ');
    let ts = parts.next()?;                      // ...slices are views into it
    let msg = parts.next()?.trim().to_string();  // ...only this outlives the fn
    Some(LogEntry { ts: ts.to_string(), level, msg })
}

fn parse(input: &str) -> Vec<LogEntry> {         // one borrow of the whole doc
    input.lines().filter_map(parse_line).collect()
}
}

Design logic:

  • parse_line(&str) -> Option — borrow in, None for garbage (a log must skip junk, not crash). .to_string() exactly where data must escape the borrow.
  • parse(&str) -> Vec<LogEntry> — the input String in main is never moved; the function borrows it, then hands back fully-owned entries.
  • counts_by_level(&[LogEntry]) — borrows the vec to count; main keeps ownership and prints error lines afterwards. Two borrows, zero copies.

Run it and watch the exit criterion:

cargo run -p m1-logpar sample.log
# INFO 2 / WARN 1 / ERROR 1 / total 4, then each ERROR line, exit code 1

ExitCode instead of a bare exit() keeps main honest and testable — the same reason Axum handlers return values instead of writing to the response directly.

Exercise

  1. Change parse_line to take line: String (owned). Does parse still compile? Why does input.lines() force you back to borrows?
  2. Make counts_by_level take Vec<LogEntry> by value. What breaks in main, and why is the &[LogEntry] version strictly better?
  3. Add a Debug-print of one entry ({:?}). Which derive made that possible?

Done when: cargo test -p m1-logpar is green (4 tests) and you can say, for each struct field, whether it is owned or borrowed — and why.

Structs, Enums, and match

Goal: you can model a small domain (log levels, entries, summaries) with structs and enums, and dispatch with match — no inheritance, no exceptions.

Structs: data with named fields

#![allow(unused)]
fn main() {
struct LogEntry {
    ts: String,
    level: Level,
    msg: String,
}
}
  • Construct: LogEntry { ts, level, msg } (field-init shorthand when names match — clippy enforces it).
  • Methods live in a separate impl LogEntry { ... } block — data and behavior are textually apart, unlike OO class bodies.
  • #[derive(Debug, PartialEq, Eq)] auto-implements printing and comparison — most structs in our services derive at least Debug.

Enums: closed sets of variants

Rust enums are sum types — each variant can carry its own data:

#![allow(unused)]
fn main() {
enum Level { Info, Warn, Error }        // simple, C-like — but type-checked

enum LineOutcome {                       // variants carry different payloads
    Ok(LogEntry),
    Skipped { reason: &'static str },
    Empty,
}
}

That second shape is exactly how std defines Option<T> and Result<T, E> — and how our services define error enums (M2). One type, finite variants, compiler-checked handling everywhere.

match: the only dispatch you need

#![allow(unused)]
fn main() {
impl Level {
    fn as_str(&self) -> &'static str {
        match self {
            Level::Info => "INFO",
            Level::Warn => "WARN",
            Level::Error => "ERROR",
        }
    }
}
}
  • Exhaustive: add a Debug variant later and every match on Level fails to compile until handled. Refactoring with a net.
  • Expressions: match produces a value (no return needed per arm).
  • Patterns do the unwrapping: Some(x) =>, Ok(v) =>, Err(e) => — destructuring built into the language.

Counting with an array instead of a HashMap — allocation-free and total:

#![allow(unused)]
fn main() {
fn counts_by_level(entries: &[LogEntry]) -> [usize; 3] {
    let mut counts = [0usize; 3];
    for e in entries {
        match e.level {
            Level::Info => counts[0] += 1,
            Level::Warn => counts[1] += 1,
            Level::Error => counts[2] += 1,
        }
    }
    counts
}
}

(For large or open-ended level sets you would reach for HashMap<Level, usize> — the lab keeps it deliberately small.)

Exercise

  1. Add a summary_text(&[LogEntry]) -> String function building "2 info, 1 warn, 1 error" — with a unit test.
  2. Introduce enum Priority { Low, High } and add fn priority(&Level) -> Priority (errors are High). Use it to filter before printing.
  3. Replace counts_by_level’s match with an index lookup (match e.level { l => counts[l as usize] += 1 } after adding #[repr(usize)]-style discriminants). Which version reads better? Which survives adding a variant?

Done when: tests green for summary_text, and you can articulate why exhaustive match beats a default arm for domain enums.

Result, ?, and Error Enums

Goal: you can build the error architecture every CodeCora service uses — one error enum per crate, Display for humans, From for ? interop, and a stable mapping to outward-facing codes.

Result<T, E> — errors as values

#![allow(unused)]
fn main() {
enum Result<T, E> { Ok(T), Err(E) }
}

No exceptions. Failure is a value in the return type, so a function that can fail announces it in its signature, and the compiler refuses to let you ignore the Err side. That is why Rust services fail gracefully: the handling path is typed, not forgotten.

The ? operator

? means: if this is Err, return the error from the current function; if Ok, unwrap the value. It composes across layers — with one condition: the error types must connect via From.

#![allow(unused)]
fn main() {
fn read_config(path: &str) -> Result<String, ParseError> {
    let text = std::fs::read_to_string(path)?;   // io::Error -> ParseError via From
    Ok(text)
}
}

The house pattern: one error enum per crate

This is the exact shape of AppError in our Axum services (Sinau, nginjen) — learned on a tiny parser so nothing distracts:

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
enum ParseError {
    Empty,
    MissingField(&'static str),
    BadLevel(String),
}

impl fmt::Display for ParseError {            // human-facing message
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ParseError::Empty => write!(f, "input is empty"),
            ParseError::MissingField(name) => write!(f, "missing field: {name}"),
            ParseError::BadLevel(tok) => write!(f, "unknown level `{tok}` (want INFO|WARN|ERROR)"),
        }
    }
}

impl std::error::Error for ParseError {}      // interop with the ecosystem

impl From<std::io::Error> for ParseError {    // makes `?` work across the IO boundary
    fn from(e: std::io::Error) -> Self {
        ParseError::MissingField(match e.kind() {
            std::io::ErrorKind::NotFound => "file",
            _ => "readable input",
        })
    }
}
}

Three traits, three jobs:

TraitJobWho consumes it
Debugdeveloper view ({:?})logs, unwrap, test failures
Displayhuman message ({})CLI output, error pages
From<E>conversion for ?every caller crossing a layer

Status codes are a rendering of errors

The mistake to un-learn: sprinkling HTTP codes through business logic. The error type stays transport-agnostic; the boundary renders it:

#![allow(unused)]
fn main() {
impl ParseError {
    fn to_status(&self) -> (&'static str, u16) {
        match self {
            ParseError::Empty => ("422 Unprocessable Entity", 422),
            ParseError::MissingField(_) => ("400 Bad Request", 400),
            ParseError::BadLevel(_) => ("400 Bad Request", 400),
        }
    }
}
}

In an Axum service this method becomes impl IntoResponse for AppError and returns StatusCode — same architecture, bigger stage (M5).

Fail well: the lab’s CLI behavior

$ m2-errlib mixed.log
ok   [INFO] all good
skip 400 Bad Request: unknown level `TRACE` (want INFO|WARN|ERROR)
parsed 1 line(s), skipped 1

Note what the skip line does: names the category (400), quotes the offending token, states the accepted values. Errors are UX. A panic is never UX.

Exercises

  1. Add a LineTooLong(usize) variant (cap: 256 chars) with Display + a 413 status, enforce it in parse_line, extend the tests.
  2. Write fn parse_all(input: &str) -> Result<Vec<Entry>, ParseError> that fails on the FIRST bad line (all-or-nothing), with tests. Contrast with the skip-and-continue CLI. Which policy fits a batch importer? Which fits an HTTP endpoint?
  3. Implement From<ParseError> for String and use it in a map_err chain. Then defend or attack String as an error type (hint: what can BadLevel(String) do that String cannot?).

Done when: cargo test -p m2-errlib is green (6+ tests) and you can explain, in order, what happens when ? hits a mismatched error type.

Traits, Generics, and Testing Discipline

Goal: you can define a trait contract, write two implementations plus a test fake, and choose correctly between generics (impl Trait/bounds) and trait objects (dyn).

The trait is the contract

#![allow(unused)]
fn main() {
pub trait MatchStrategy {
    fn matches(&self, haystack: &str, needle: &str) -> bool;
    fn name(&self) -> &'static str;
}
}

This is the shape our production code uses for swap-able behavior: uteke’s index traits, Sinau’s StorageBackend (local vs S3 behind one interface). Callers depend on the contract, not an implementation — which is also what makes testing cheap: the fake IS an implementation.

Static vs dynamic dispatch

StyleSyntaxChoose when
Static (generics)fn query<S: MatchStrategy>(s: &S) or SearchEngine<S>strategy known at compile time; zero-cost, but one monomorphized copy per type
Dynamic (trait object)fn query(docs: &[Document], s: &dyn MatchStrategy) or Box<dyn MatchStrategy>strategy chosen at runtime (config, request param); one copy, tiny vtable hop

The M3 lab’s engine is generic over S: MatchStrategy but main stores Box<dyn MatchStrategy> selected from CLI args — both styles in one program, and the tests exercise both.

Iterator chains over manual loops

#![allow(unused)]
fn main() {
self.docs.iter()
    .filter(|d| s.matches(&d.body, q))
    .map(|d| (d.id, score_of(d)))
    .filter(|(_, score)| *score > 0)
    .collect()
}

Reads top-to-bottom, composes, and lazily evaluates. Reach for itertools only when a chain genuinely needs it; most filters/maps don’t.

Testing discipline (the house rules)

  • Tests live in the same file: #[cfg(test)] mod tests — they compile against private items, and nobody forgets to update them.
  • Arrange–Act–Assert, one behavior per test, name = the behavior (word_strategy_respects_boundaries).
  • Every public capability gets: a happy path, an empty/edge case, and a failure mode.
  • A trait contract gets a conformance test per implementation — plus a fake for tests of code that depends on the trait.

Lab

labs/m3-search — three strategies (ExactSearch, CaseInsensitiveSearch, WordSearch), a generic SearchEngine<S> with naive title-doubled ranking, and the &dyn variant query_dyn. Queries: cargo run -p m3-search deploy ci.

Done when: cargo test -p m3-search is green (6 tests), and you can say when dyn costs you anything (it doesn’t here) and why the fake-free design still tests all strategies (conformance tests).

Async Rust: Tasks, Timeouts, Backpressure

Goal: you can spawn bounded concurrent work with tokio, impose per-job timeouts, and aggregate results — without unbounded spawn or blocking the reactor.

What async actually buys

Async = many tasks waiting on IO concurrently on a few threads. The executor (tokio) polls futures that yield at .await points. What it does not do: make CPU work parallel (that’s rayon/threads) or make slow dependencies fast.

The vocabulary you need daily

ConceptIn the lab
async fnFutureprocess_job — nothing runs until polled
.await = yield pointtokio::time::sleep(...).await
#[tokio::main] / #[tokio::test]starts the runtime around main / the test
task = spawned futureset.spawn(async move { ... })
JoinSetcollect results as they finish (not in spawn order)
Semaphorethe concurrency cap — acquire before spawn, permit released on drop
tokio::time::timeoutper-job deadline; the whole point of “fail on time”

Backpressure or it didn’t happen

The #1 async bug in services: for x in items { tokio::spawn(job(x)) } — 100k items = 100k in-flight tasks, memory gone, upstream hammered. The fix is a cap before spawn:

#![allow(unused)]
fn main() {
let permit = Arc::clone(&sem).acquire_owned().await.unwrap(); // waits when full
set.spawn(async move {
    let _permit = permit; // held until the task completes
    // ... work ...
});
}

JoinSet::join_next() drains outcomes; sort by id afterwards if order matters.

Timeouts are part of the design

#![allow(unused)]
fn main() {
match tokio::time::timeout(Duration::from_millis(500), job).await {
    Ok(Ok(ms))  => JobOutcome::Done { id, ms },
    Ok(Err(())) | Err(_) => JobOutcome::TimedOut { id },
}
}

Nested Results (timeout’s + the job’s) collapse into one explicit outcome enum — errors as values again, now async-shaped.

The one blocking trap

Never call std::thread::sleep, heavy CPU loops, or std::fs on a runtime worker — you stall every task sharing that thread. Use tokio::time::sleep, spawn_blocking, or tokio::fs. In M6 the only sync moment is pool setup; queries are .awaited.

Lab

labs/m4-batch — 8 simulated jobs, concurrency 4, per-job timeout; prints each outcome plus the wall-time vs serial-sum comparison. Tests cover: all-complete, timeout-catch, 20 jobs through a limit of 3 (no losses), and the zero-concurrency clamp.

Done when: cargo test -p m4-batch is green (4 tests) and you can explain what happens to the permit when a task panics (dropped — the limit self-heals).

HTTP Services with Axum

Goal: you can build a JSON REST API with axum: nested routes, extractors, shared state, and one error enum rendered to status codes via IntoResponse.

The moving parts

PieceWhat it isIn the lab
Routerpath → handler table, nestable/healthz, /notes, /notes/{id}
Extractortyped argument a handler receivesState, Path, Json
Return valueimpl IntoResponseJson<T>, (StatusCode, Json<T>), or your error type
with_statewires shared state into every handlerAppState { notes: Arc<Mutex<Vec<_>>> }

Handlers are plain functions — the same function works under the router and in a #[tokio::test] with no HTTP at all. That is why the lab tests call create_note(...) directly: transport-free unit tests on the exact production code path.

State: cheap clones, inner mutability

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppState { notes: Arc<Mutex<Vec<Note>>> }
// Router::with_state(state) — every request gets a clone (Arc bump, not a copy)
}

Mutex::lock() is std (sync) and fine for short critical sections; under real concurrency you’d reach for dashmap or a sqlx pool (M6). The rule that matters: never hold a lock across an .await.

Errors: the M2 pattern on the big stage

#![allow(unused)]
fn main() {
enum ApiError { NotFound, BadRequest(String) }

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "note not found".into()),
            ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
        };
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}
type ApiResult<T> = Result<T, ApiError>;   // handlers just `?` or return Err
}

Business logic returns Err(ApiError::BadRequest(...)); the boundary renders it. Validation lives at the edge (trim, length cap) — and the 400s carry a reason.

Route syntax

axum 0.8 path params: "/notes/{id}" (curly braces replaced the old :id syntax — old tutorials lie). One handler can serve several methods: get(list).post(create).

Lab

labs/m5-notes-apicargo run -p m5-notes-api, then:

curl -s localhost:3000/healthz
curl -s localhost:3000/notes
curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"title":"ship it"}'
curl -s localhost:3000/notes/1

Tests (4): roundtrip with trim, monotonic ids, empty title → 400 value, missing → 404.

Done when: tests green, and you can curl the running server through all four calls — plus explain why the handler tests need no HTTP at all.

Persistence with sqlx

Goal: you can store and query data with sqlx on SQLite: typed parameterized queries, schema-as-code, and constraints enforced by the database itself.

Why sqlx (and not an ORM)

House decision: explicit SQL, compile-time checked when used with the query macro, no DSL to learn. Sinau LMS runs on exactly this. The lab uses the runtime API (sqlx::query(...).bind(...)) so everything works offline; the production pattern adds the query! macro checked against a real schema at build time.

Connect + schema-as-code

#![allow(unused)]
fn main() {
let opts = SqliteConnectOptions::from_str("sqlite:/tmp/app.db")?.create_if_missing(true);
let pool = SqlitePoolOptions::new().max_connections(5).connect_with(opts).await?;
sqlx::query("CREATE TABLE IF NOT EXISTS notes (...)").execute(&pool).await?;
}

A Pool is cloneable and shared — hand one to your axum State. Real projects put the DDL into ordered migrations/*.sql (that is literally Sinau’s server/migrations/ layout); the idea is the same: schema is versioned code, not a GUI adventure.

The query patterns you need

#![allow(unused)]
fn main() {
// insert and get the id back — RETURNING saves a round trip
let id: i64 = sqlx::query("INSERT INTO notes (title) VALUES (?1) RETURNING id")
    .bind(title).fetch_one(&pool).await?.get(0);

// list — map rows by position (or .try_get by column name)
let rows = sqlx::query("SELECT id, title FROM notes ORDER BY id")
    .fetch_all(&pool).await?;

// optional single row
let maybe = sqlx::query("... WHERE id = ?1").bind(id).fetch_optional(&pool).await?;
}

Always .bind() values — never format them into the string. Positional ?1 ?2 keeps the mapping visible.

Constraints: the DB has the last word

title TEXT NOT NULL CHECK (length(trim(title)) > 0)

The app validates for friendly 400s (M5); the constraint guarantees it even if a future code path forgets. Defense in depth — the same reason Sinau’s migrations carry real REFERENCES ... ON DELETE CASCADE clauses.

Lab

labs/m6-notes-db — embedded SQLite file DB, RETURNING inserts, ordered list, optional find, and a demo of the CHECK constraint rejecting empty titles. Tests (4): sequential ids, insert-order listing, constraint rejection (empty AND whitespace), missing-row None.

Done when: tests green, and you can explain what fetch_one vs fetch_optional vs fetch_all each guarantee — and which one you reach for when “not found” is a normal outcome.

Production Hygiene: CI Gates

Goal: you can wire the house loop into GitHub Actions so nothing merges unless fmt, clippy, and tests pass — and know the release checklist by heart.

The CI file (our actual pattern)

name: CI
on:
  pull_request: { branches: [develop, main] }
  push: { branches: [main] }
jobs:
  check:   { runs-on: ubuntu-latest, steps: [ { uses: actions/checkout@v4 },
             { uses: dtolnay/rust-toolchain@stable },
             { uses: Swatinem/rust-cache@v2 },
             { run: cargo check --all-targets } ] }
  format:  { runs-on: ubuntu-latest, steps: [ { uses: actions/checkout@v4 },
             { uses: dtolnay/rust-toolchain@stable },
             { run: cargo fmt --all -- --check } ] }
  clippy:  { runs-on: ubuntu-latest, steps: [ { uses: actions/checkout@v4 },
             { uses: dtolnay/rust-toolchain@stable },
             { uses: Swatinem/rust-cache@v2 },
             { run: cargo clippy --all-targets -- -D warnings } ] }
  test:    { runs-on: ubuntu-latest, steps: [ { uses: actions/checkout@v4 },
             { uses: dtolnay/rust-toolchain@stable },
             { uses: Swatinem/rust-cache@v2 },
             { run: cargo test --workspace } ] }

(Condensed for reading — real repos list the steps in full YAML; the jobs and the commands are exactly these. Swatinem/rust-cache is what keeps the re-runs fast.)

Why five separate jobs

Check/Format/Clippy/Test/Build run in parallel and report separately — a format failure doesn’t hide behind a 40-minute test queue, and the red X tells you which gate broke. Required-checks settings then refuse merges unless all five are green.

Release checklist (uteke/Sinau pattern, memorize)

  1. CHANGELOG.md updated — every user-visible change, since v0.0.1
  2. Docs in sync (README, .env.example)
  3. PR develop → main via PR, never direct push
  4. All 5 required checks green on the PR
  5. Cora review/scan before declaring done
  6. Tag v0.x.y after merge — 0.x.x forever: minor = feature, patch = fix
  7. Push tag; CI artifacts/release from the tag

Local discipline that makes CI boring

  • cargo fmt before every commit (rustfmt may reformat files you didn’t touch — let it)
  • New route/module in a binary crate? Register it in both the real router and the test router — a missed registration is a 404 that only shows up in tests
  • Every fix ships with the test that would have caught it

Done when: you can reproduce this CI file from memory (jobs + commands), and you can recite the release checklist without looking — that is the bar for “knows how we ship”.

Capstone: Assemble the Mini Service

Goal: combine M5 + M6 into one small but complete service: an Axum notes API backed by sqlx/SQLite, structured as a workspace — a miniature of the real fleet.

The target architecture

notes-service/
├── Cargo.toml            # [workspace] members = ["server"]
└── server/
    ├── Cargo.toml        # axum, sqlx, serde, tokio
    ├── migrations/       # 0001_create_notes.sql
    └── src/
        ├── main.rs       # pool setup + router + serve
        ├── errors.rs     # AppError + IntoResponse   (M2/M5 pattern)
        ├── routes.rs     # handlers                  (M5 pattern)
        └── db.rs         # typed sqlx queries        (M6 pattern)

Why a workspace for one crate: it is the seam where the capstone grows — add crates/search (M3 engine behind a trait) or crates/ingest (M4 batch loader) without restructuring. Real repos (uteke, sinau) all grow this way.

The assembly steps

  1. Schema as migration: move the lab’s CREATE TABLE into migrations/0001_create_notes.sql; at boot, run migrations before serving (sqlx migrate! macro, or the manual loop from M6 for the offline version).
  2. Share the pool through state: AppState { pool: SqlitePool } — M5’s state slot, M6’s pool. Handlers become async and take State<AppState>.
  3. Swap the in-memory Vec for queries: create_noteINSERT ... RETURNING id; get_notefetch_optionalok_or(ApiError::NotFound); list_notesfetch_all. Keep the validation (trim, length) — now the DB CHECK backs it up.
  4. Keep every M5 test: they call handlers directly; only the state constructor changes (temp-file pool instead of the in-memory Vec).
  5. Add the integration test: boot the router with a temp DB, Router::oneshot() a POST + GET through the whole app — that is the “Tested means live” bar.
  6. CI: the M7 file, unchanged. Green = done.

Evidence of done (the definition, not a feeling)

  • cargo test --workspace green: unit (handlers) + integration (oneshot roundtrip)
  • cargo clippy --all-targets -- -D warnings clean
  • Live transcript: server running, four curl calls with correct status codes (201 → GET 200 → GET 999 → 404)
  • Repo with the CI file; PR merged via the checklist

Where to go next

  • Add crates/search: port m3-search’s MatchStrategy behind the notes table
  • Add crates/ingest: use m4-batch’s semaphore+JoinSet to bulk-load notes
  • Point sqlx at Postgres: only the connect options change — that is the point

You have now built, in order: the mental model (ownership), failure handling (errors), abstraction (traits), concurrency (async), transport (axum), storage (sqlx), and the delivery pipeline (CI). That is the full loop every CodeCora service runs in.

Roadmap & Lab Index

Status

ModuleChapter(s)LabStatus
GateSetup & Toolchain · First Programgate-hello✅ live
M1 — OwnershipOwnership · Structs/Enumsm1-logpar✅ live
M2 — ErrorsErrors as Valuesm2-errlib✅ live
M3 — Traits & TestingTraits, Generics, Testingm3-search✅ live
M4 — AsyncTasks, Timeouts, Backpressurem4-batch✅ live
M5 — HTTP ServicesAxum APIm5-notes-api✅ live
M6 — Persistencesqlx + SQLitem6-notes-db✅ live
M7 — Production HygieneCI Gates & Release(CI file)✅ live
M8 — CapstoneAssemble the Mini Serviceyour repoguided build

Also on lms.codecora.dev → course Rust Foundations (CodeCora Engineering Track) (private; login required): the same pilot content as structured chapters with enrollment tracking.

Capstone follow-ups (post-course, pick your depth)

  • crates/search — M3 engine behind a trait, indexing the notes table
  • crates/ingest — M4 semaphore + JoinSet bulk loader
  • Postgres backend — swap connect options, keep every query pattern
  • Axum middleware layer — rate limiting + request IDs (Sinau’s hardening pattern)

House rules recap

  1. Tested means live: green tests + behaving binary, not just “it compiles”.
  2. The loop: cargo fmt && cargo clippy --all-targets -- -D warnings && cargo test.
  3. No unsafe, no unwrap() on user input in production paths.
  4. One error enum per crate; render status codes at the boundary only.
  5. Backpressure or it didn’t happen: cap concurrency before spawning.
  6. English artifacts, versioned schema, CI gates everything.