Rust earns its place when two things must hold at once: the code must be fast and it must be correct under
concurrency — without a garbage collector to bail you out. This track moves from cargo new to ownership,
Result/Option, traits, an async axum service, and finally profiling — tagged by level so you can read
only as deep as you need.
Install Rust and create a project
BeginnerInstall the toolchain with rustup, then scaffold a project with cargo new.
Why rustup and cargo, not a bare compiler
rustup installs and manages the toolchain (compiler, cargo, rustfmt, clippy) and lets you switch
between stable and nightly. cargo is Rust’s build tool and package manager in one — it compiles, runs,
tests, and resolves dependencies from crates.io. cargo new lays down a Cargo.toml
manifest and a src/main.rs, so you have a runnable binary from the first command.
Install and scaffold
# Install the toolchain (https://rustup.rs)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Verify, then create a project
rustc --version
cargo new photon-cli
cd photon-cli
cargo runFeel ownership and the move
BeginnerBind a String to one variable, then to another, and let the compiler explain what just happened.
Ownership is the whole idea
Every value in Rust has exactly one owner. When you assign a heap value like String to another
variable, ownership moves — the original binding is no longer usable. This is how Rust frees memory
deterministically with no garbage collector: when the owner goes out of scope, the value is dropped. To use
a value without taking ownership, you borrow it with & (shared, read-only) or &mut (exclusive,
mutable). The rule “many readers XOR one writer” is what eliminates data races at compile time.
A move, then a borrow
fn main() {
let a = String::from("photon");
let b = a; // ownership MOVES from a to b
// println!("{a}"); // <- uncomment: compile error, a was moved
let len = char_count(&b); // borrow, don't move
println!("{b} has {len} chars"); // b is still usable here
}
fn char_count(s: &str) -> usize {
s.chars().count()
}Model absence with Option and failure with Result
BeginnerWrite a function that returns Option<T> when a value may be missing, and Result<T, E> when an operation
may fail.
No null, no exceptions — values instead
Rust has no null and no exceptions. Absence is Option<T> (Some(v) or None); fallibility is
Result<T, E> (Ok(v) or Err(e)). Because they are ordinary enums, the compiler forces you to handle
both arms — you cannot accidentally use a missing value. The ? operator propagates an Err (or None)
up to the caller in one character, so the happy path stays flat and readable.
Option, Result, and the ? operator
fn first_word(s: &str) -> Option<&str> {
s.split_whitespace().next()
}
fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
let port: u16 = s.parse()?; // ? returns the Err early on failure
Ok(port)
}
fn main() {
match first_word("photon arena") {
Some(w) => println!("first: {w}"),
None => println!("empty"),
}
println!("{:?}", parse_port("8080")); // Ok(8080)
println!("{:?}", parse_port("oops")); // Err(...)
}Agent prompt — paste into an agent with repo access
Role: Senior Rust engineer working in this repo (the photon-cli crate).
Context: Stable Rust, a binary crate with src/main.rs. No external dependencies.
Task: Add a module src/parse.rs with `fn parse_pair(input: &str) -> Result<(String, u16), String>`.
Requirements:
- Input format is "host:port"; return the host and the parsed u16 port.
- On a missing colon, return Err("missing ':' separator".to_string()).
- On a non-numeric or out-of-range port, return Err with a message mentioning the bad value.
- Use the ? operator internally where it reads cleanly; do not panic on bad input.
- Declare the module in main.rs with `mod parse;`.
Tests / acceptance:
- Add #[cfg(test)] tests covering: "localhost:8080" -> Ok(("localhost".into(), 8080)),
"localhost" -> Err, "localhost:notaport" -> Err, "localhost:70000" -> Err.
- `cargo test` passes; `cargo clippy -- -D warnings` is clean.
Output: a unified diff plus a one-paragraph summary of the error model.Branch with pattern matching
BeginnerUse match to handle every variant of an enum, and let the compiler enforce that you covered them all.
Exhaustiveness is checked for you
match is Rust’s primary control-flow tool. It destructures values and binds their contents in one step,
and it is exhaustive: if you add a variant to an enum later and forget to handle it, the code stops
compiling. That turns a whole class of “forgot a case” bugs into build errors. Use if let when you care
about only one variant, and _ as a catch-all only when a default is genuinely correct.
Exhaustive match over an enum
enum Event {
Connect { user: String },
Disconnect,
Message(String),
}
fn describe(e: &Event) -> String {
match e {
Event::Connect { user } => format!("{user} joined"),
Event::Disconnect => "left".to_string(),
Event::Message(text) => format!("said: {text}"),
}
}Add dependencies and (de)serialize with serde
IntermediateAdd serde to Cargo.toml, derive Serialize/Deserialize on a struct, and round-trip it through JSON.
serde is the (de)serialization standard
serde is the de-facto framework for turning Rust data into formats and back. You
#[derive(Serialize, Deserialize)] on a struct and a companion crate (serde_json, serde_yaml, …) does
the format-specific work. Derives are checked at compile time, so a field-name or type mismatch is a build
error, not a runtime surprise. cargo add writes the dependency into Cargo.toml for you, pinning a
compatible version from crates.io.
Derive and round-trip JSON
cargo add serde --features derive
cargo add serde_jsonuse serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct Player {
id: String,
#[serde(rename = "displayName")]
name: String,
score: u32,
}
fn main() -> Result<(), serde_json::Error> {
let p = Player { id: "p1".into(), name: "Nova".into(), score: 42 };
let json = serde_json::to_string(&p)?; // serialize
let back: Player = serde_json::from_str(&json)?; // deserialize
println!("{json}\n{back:?}");
Ok(())
}Test in-crate with cargo test
IntermediateAdd a #[cfg(test)] module beside your code and run the whole suite with cargo test.
Tests live next to the code they cover
Rust’s test runner is built into cargo. Unit tests live in the same file under a #[cfg(test)] module
(compiled only for tests) and may call private functions. Mark each test with #[test] and assert with
assert_eq! / assert!. cargo test builds the test binary and runs every test in parallel; a panicking
assertion fails just that test. Integration tests go in a top-level tests/ directory and exercise only the
public API.
A unit test module
pub fn clamp_score(raw: i64) -> u32 {
raw.clamp(0, 1000) as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamps_low_and_high() {
assert_eq!(clamp_score(-5), 0);
assert_eq!(clamp_score(42), 42);
assert_eq!(clamp_score(5000), 1000);
}
}cargo testAgent prompt — paste into an agent with repo access
Role: Senior Rust engineer in this repo (the photon-cli crate).
Context: Stable Rust. A library module with a public function `clamp_score(raw: i64) -> u32`.
Task: Add a thorough #[cfg(test)] module for clamp_score and one property-style boundary test.
Requirements:
- Cover below-range (-1 -> 0), in-range (0, 500, 1000), and above-range (1001, i64::MAX -> 1000).
- Add a test asserting the result is always within 0..=1000 for a fixed set of sampled inputs.
- Do not change the function's behaviour or signature.
Tests / acceptance:
- `cargo test` passes.
- `cargo clippy -- -D warnings` is clean.
Output: a unified diff plus a one-line note on which boundary was most likely to break.Serve HTTP with axum on tokio
IntermediateAdd axum and tokio, then start an async web server with a GET /healthz route.
Why tokio and axum together
Rust’s async is built on futures that do nothing until a runtime polls them; tokio
is the dominant runtime that drives them and provides async I/O, timers, and tasks.
axum is the tokio team’s web framework — handlers are plain async fns,
routing is a typed Router, and extractors pull typed data out of a request. #[tokio::main] sets up the
runtime so main itself can be async. The pairing gives you C-class throughput with the borrow checker
still guarding shared state.
A minimal axum server
cargo add tokio --features full
cargo add axumuse axum::{routing::get, Router};
async fn healthz() -> &'static str {
"ok"
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/healthz", get(healthz));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
.await
.unwrap();
println!("listening on :8080");
axum::serve(listener, app).await.unwrap();
}cargo run
# in another terminal:
curl -i localhost:8080/healthzAgent prompt — paste into an agent with repo access
Role: Senior Rust web engineer in this repo.
Context: Stable Rust. Dependencies: axum, tokio (features = ["full"]), serde + serde_json (derive).
Task: Build an axum server in src/main.rs with a typed JSON route GET /players/{id}.
Requirements:
- Define `#[derive(Serialize)] struct Player { id: String, name: String, score: u32 }`.
- The handler uses the Path extractor to read {id} and returns axum::Json<Player>.
- Keep the GET /healthz route returning the string "ok".
- Bind 0.0.0.0:8080 with tokio::net::TcpListener and axum::serve.
- No .unwrap() in request handlers; only main may unwrap during startup.
Tests / acceptance:
- `cargo build` succeeds; `cargo clippy -- -D warnings` is clean.
- `curl -s localhost:8080/players/p1` returns JSON containing "id":"p1".
- `curl -s -o /dev/null -w "%{http_code}" localhost:8080/healthz` prints 200.
Output: a unified diff plus a one-paragraph summary of how the Path extractor is typed.Make lifetimes explicit when borrows escape
AdvancedAnnotate a function that returns a reference so the compiler knows how long the borrow stays valid.
Lifetimes name relationships the compiler already tracks
A lifetime is the span during which a reference is valid. The borrow checker tracks them automatically;
you only write annotations when a function returns a reference and the compiler cannot infer which input it
borrows from. fn longest<'a>(x: &'a str, y: &'a str) -> &'a str says “the returned reference lives at least
as long as both inputs”. This is purely a compile-time contract — there is no runtime cost — and it is what
makes returning a borrow into a caller’s data provably safe with no dangling pointers.
An explicit lifetime annotation
// The returned &str is tied to both inputs; the caller's data must outlive it.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() >= y.len() { x } else { y }
}
fn main() {
let a = String::from("photon");
let b = String::from("arena!");
println!("{}", longest(&a, &b)); // borrows a and b; valid while both live
}Chat prompt — paste into a chat to get the code
Role: Rust teacher. The reader has no repo access — return complete, compiling code.
Task: Implement `fn first_field<'a>(line: &'a str, sep: char) -> Option<&'a str>` that returns the slice
before the first `sep`, borrowing from the input (no allocation), or None if `sep` is absent.
Requirements:
- The returned &str must borrow from `line` (explicit lifetime 'a), not a freshly allocated String.
- Use str::find and slicing; do not pull in any external crate.
- Include #[cfg(test)] tests: ("a,b,c", ',') -> Some("a"); ("nocomma", ',') -> None; (",x", ',') -> Some("").
- Include a `fn main` that prints first_field("photon:arena", ':').
Tests / acceptance (describe, since no repo):
- `cargo test` would pass with the included tests.
Output: the complete main.rs, no commentary.Build for release and profile the hot path
AdvancedCompile with --release, write a benchmark with criterion, and find where the time actually goes before
optimising.
Measure before you optimise — and always benchmark in release
A debug build skips optimisations; never judge performance from it. cargo build --release turns on the
optimiser (opt-level = 3 by default), often a 10–100x difference on hot numeric code.
criterion is the standard statistical benchmarking crate: it
runs many iterations, accounts for variance, and reports confidence intervals, so a “faster” claim is real
and not noise. Profile with a sampling profiler (e.g. perf + a flame graph) to find the hot function, then
optimise only what the data proves is hot.
Release build and a criterion benchmark
# Cargo.toml
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "scoring"
harness = false// benches/scoring.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use photon_cli::clamp_score; // pub fn from the crate
fn bench_clamp(c: &mut Criterion) {
c.bench_function("clamp_score", |b| {
b.iter(|| clamp_score(black_box(5000)))
});
}
criterion_group!(benches, bench_clamp);
criterion_main!(benches);cargo build --release
cargo benchAgent prompt — paste into an agent with repo access
Role: Senior Rust performance engineer in this repo.
Context: Stable Rust. The crate exposes `pub fn clamp_score(raw: i64) -> u32`. criterion is a dev-dependency
and a [[bench]] named "scoring" (harness = false) is declared in Cargo.toml.
Task: Add benches/scoring.rs benchmarking clamp_score across representative inputs and report results.
Requirements:
- Use criterion with black_box to prevent the optimiser folding the call away.
- Benchmark at least three inputs: below range, in range, above range.
- Do not change clamp_score's behaviour; correctness tests must still pass.
Tests / acceptance:
- `cargo bench` runs and prints timing with confidence intervals.
- `cargo test` is still green.
Output: a unified diff plus the criterion summary numbers and a one-line interpretation.Where to take it next
- Put this to work in Concord, where Rust drives the conflict-free (CRDT) merge engine — the convergence a GC pause or a data race would ruin.
- Want the same backend job with less ceremony and a GC you can live with? Compare against Go.
- Prefer one language across front and back, trading raw speed for reach? See TypeScript.
- Weigh all the backend options side by side on the Compare page.