Skip to content

Day 15 · Hardening and the Phase 2 Bridge

Day 14 finished the features. askr asks, streams, retrieves. Today is about everything between “it runs” and “I’d let someone else depend on it”: configuration, error handling, automated checks, and knowing where the time goes. It’s the quiet half of engineering — and it’s where the trait-first design you’ve been building pays off, because the whole thing is already testable. Then we look past Phase 1 to the chains you’ll build next.

Configuration: read it at the edges, fail loudly

Section titled “Configuration: read it at the edges, fail loudly”

askr has exactly two knobs, both environment variables, both read in one small config module:

  • ANTHROPIC_API_KEY — required to reach the real API; absent, the program falls back to the mock and tells you.
  • ANTHROPIC_MODEL — the model id, with a clearly-labelled placeholder default so the binary runs but you know to override it.

The principle: parse configuration once, at the boundary, into typed values — never reach for env::var deep inside business logic. A missing key becomes a typed AskrError::MissingEnv with a message that names the variable, not a panic three layers down.

let client: Box<dyn LlmClient> = if args.mock || config::api_key().is_err() {
if !args.mock {
eprintln!("note: ANTHROPIC_API_KEY not set — using the offline mock model.\n");
}
Box::new(MockLlm::echo())
} else {
Box::new(ClaudeClient::new(config::api_key()?))
};

Every failure in the crate funnels into AskrError, and main is a thin shell that turns it into a process exit code and a human message — no stack traces dumped at the user:

#[tokio::main]
async fn main() {
if let Err(e) = run().await {
eprintln!("error: {e}");
std::process::exit(1);
}
}

This is the shape every CLI should have: run() returns Result<()>, main decides what a failure means to the operating system (non-zero exit) and to the human (a one-line message). Because ? propagates and thiserror gives each variant a Display, you get that for free.

One structural change earns a lot here: askr is split into a library (src/lib.rs, all the logic) and a thin binary (src/main.rs, just arg-parsing and wiring). Two payoffs:

  • The logic is importable and unit-testable on its own — cargo test drives LlmClient, Embedder, VectorStore, and the RAG functions directly, never through main.
  • pub items are a real public API, so the compiler stops nagging about “unused” helpers that only tests call — the API is legitimately exposed.

Three checks should run on every push and pull request, and a human should never have to remember them:

.github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust/askr
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Format
run: cargo fmt --all -- --check
- name: Lint
run: cargo clippy --all-targets -- -D warnings
- name: Test
run: cargo test --all

Each line earns its place:

  • cargo fmt --all -- --check fails if the code isn’t formatted canonically. Formatting stops being an opinion; it’s just true or false.
  • cargo clippy --all-targets -- -D warnings runs Rust’s linter and treats every warning as an error, so lint debt can’t accumulate.
  • cargo test --all runs the suite. And here is where the trait-first design closes the loop: because every test uses MockLlm / MockEmbedder, CI needs no API key and no network. The pipeline is fast, free, and deterministic — exactly what a green check should be.

askr has one obvious hot path — the cosine loop in Day 14 — and the honest engineering move is to measure before assuming it’s slow. A few habits:

  • Always profile a --release build. A debug build can be 10–50× slower; numbers from it are meaningless. cargo build --release / cargo run --release.
  • Benchmark the hot function in isolation with criterion (a dev-dependency) — it runs many iterations and reports stable timings with confidence intervals, so you can tell a real change from noise.
  • Find the hot spot, don’t guess it. cargo flamegraph shows where wall-clock time actually goes. For askr, you’ll find the answer is almost always “waiting on the network,” not the cosine math — which is exactly why streaming (Day 13) did more for the felt experience than any micro-optimization could.

The recurring thread, one last time: what does building this force you to understand? That correctness came first and for free — the borrow checker proved no chunk dangles, no error is swallowed, no stream corrupts a character — so by the time you reach performance, you’re tuning code you already trust. That ordering is Rust’s quiet superpower.

Phase 1 is done. You’ve shipped four real programs and, with them, the Rust that matters: ownership and borrowing, errors that scale, traits and generics and lifetimes, iterators, fearless concurrency, async, HTTP both as server and client, and the production habits above. That’s a foundation, not a finish line.

Phase 2 (Days 16–30) turns that foundation toward the question you actually came here for — why do blockchains exist, and how would I build one? You’ll build three mini-chains from scratch, each answering “what problem did it solve?”:

Day 16–30: build the idea, smallest version that teaches it
Bitcoin-style → UTXO ledger + Proof-of-Work
"how do strangers agree on history with no central server?"
Ethereum-style → account model + a tiny VM
"what if the ledger could run programs, not just move coins?"
Solana-style → Proof-of-History clock + parallel execution
"what if we could order and execute transactions far faster?"

Each is a Rust crate in code/, built with the exact muscles you trained in Phase 1 — enums and pattern-matching for transactions, Vec and hashing for the ledger, traits for pluggable consensus, concurrency for parallel execution. The compiler keeps protecting you from the same bugs, now in a domain where a single data race or unchecked error is also a financial bug.

  1. Confirm config is read only in the config module; confirm main returns a clean exit on error.
  2. Add the CI workflow above and watch fmt, clippy, and test go green — with no secrets configured.
  3. Run cargo build --release and try cargo flamegraph (or just time a RAG query) to see where the time really goes.
  4. Skim cargo audit; add it to CI if you like.
  5. Take a breath — you finished Phase 1. Phase 2 starts with a UTXO ledger.
  1. What’s the principle behind reading ANTHROPIC_API_KEY and ANTHROPIC_MODEL only in the config module, and what does a missing key become instead of a panic deep in the code?
  2. The CI runs fmt --check, clippy -D warnings, and test. Why can the test step run with no API key and no network — what design choice makes that possible?
  3. Why does cargo clippy -- -D warnings use -D warnings rather than just running clippy plain?
  4. Name two profiling habits the chapter insists on, and why profiling a debug build is a mistake.
  5. Phase 2 builds Bitcoin-, Ethereum-, and Solana-style chains. In one phrase each, what problem does each style answer — and how does building a Solana-style runtime by hand help with the reader’s Anchor goal?
Show answers
  1. Parse configuration once, at the boundary, into typed values — never call env::var deep in business logic. A missing key becomes a typed AskrError::MissingEnv that names the variable (and lets askr fall back to the mock), rather than a panic several layers down.
  2. Because every test injects MockLlm / MockEmbedder instead of the real network clients. Hiding the network behind the LlmClient / Embedder traits makes the suite hermetic — no key, no socket — so CI is fast, free, and deterministic.
  3. -D warnings promotes every clippy warning to a hard error, failing the build so lint debt can’t silently accumulate over time. Plain clippy would only print warnings, which are easy to ignore.
  4. Two of: profile a --release build (debug is 10–50× slower and its numbers are meaningless); benchmark the hot function in isolation with criterion; find the hot spot with cargo flamegraph rather than guessing. Profiling a debug build is a mistake because the optimizer is off, so the timings don’t reflect what ships.
  5. Bitcoin-style: how strangers agree on one history with no central server (UTXO + Proof-of-Work). Ethereum-style: making the ledger run programs, not just move coins (accounts + a tiny VM). Solana-style: ordering and executing transactions much faster (Proof-of-History + parallel execution). Building the Solana-style runtime by hand makes Anchor’s model click, because you’ll have implemented the ideas Anchor abstracts — leaving the language as the easy part.

Prev: Day 14 · Vector Search · You’ve finished Phase 1. Next up: Phase 2 (Days 16–30) — building BTC → ETH → SOL mini-chains, then a bridge into real Solana/Anchor.