Why here, and why after OCaml. The C track ends on one idea: every C program has an ownership discipline, and it lives in comments. Rust is what happens when that discipline becomes part of the type system. Read it after OCaml as well.
Option,Result, sum types and exhaustivematchare ML ideas, and OCaml had them twenty years earlier. With those set aside, Rust adds one new idea: ownership checked at compile time.
This track uses a handful of terms that the C track used informally. They have exact meanings here, so they are defined once, up front:
| Term | Meaning in Rust |
|---|---|
| owner | The one variable responsible for a value. When the owner goes out of scope, the value is freed. |
| move | Passing a value to a new owner. The old variable can no longer be used. |
| borrow | A reference to a value that someone else owns. &T is a shared borrow (read-only, many allowed); &mut T is a mutable borrow (read-write, exactly one allowed). |
| lifetime | The stretch of code during which a borrow is used. Written 'a when it has to be named. |
| crate | The unit of compilation: a whole library or binary, not one file. |
Graydon Hoare started Rust as a personal project in 2006. Mozilla sponsored it from 2009, paying a team to build it as a safer language for browser engines. Version 1.0 shipped on 15 May 2015. MIT Technology Review's history of the project has the dates and the story of the broken lift that prompted it.
The constraint Rust was built under is the one the C track's Q8 describes: a browser is a large C++ program that parses hostile input all day, and most of its serious security bugs are memory bugs. The design goal was C++'s control over memory layout and cost, with the memory bugs made into compile errors. Rust 1.0 has no garbage collector: a collector adds pauses and a runtime, and a browser engine or a kernel cannot accept either.
Rust has no ISO standard. What pins the language is the edition: a crate declares
edition = "2015", "2018", "2021", or "2024" in Cargo.toml, and the compiler
applies that edition's syntax and lint rules to it. The rule that makes this workable is
that crates of different editions link together freely
(Edition Guide). The 2024
edition was stabilised in Rust 1.85,
and this track uses it. The compiler itself ships a stable release
every six weeks; this
machine has 1.98.1.
Who pays for it today: Mozilla handed the project to the independent Rust Foundation in 2021, whose founding members were AWS, Huawei, Google, Microsoft, and Mozilla (announcement). Production users include the Linux kernel, which accepts Rust drivers (kernel docs); Android, where Google reports memory-safety bugs falling from 76% of vulnerabilities in 2019 to 24% in 2024 as new code moved to memory-safe languages (Google Security Blog); AWS's Firecracker microVM monitor; and Cloudflare's proxy layer, which appears again in Q8.
Further reading:
unsafe. The project page links the paper as a free PDF.Cargo is the build tool, the dependency manager, the test runner, and the entry point to the formatter and linter. The C, Java, and OCaml tracks assembled their builds from parts (gcc and flags, javac and a classpath, ocamlc under ocamlrun). Like Go, Rust has one command:
cargo build --release
.\build.ps1 # cargo fmt --check, cargo clippy, cargo build, run on the fixture
.\build.ps1 -Shard 4 # the same, through the --shard path
What that command does:
Compare this with the C pipeline stage by stage. Three differences matter:
.c file alone
and knows other files only through the declarations in headers. rustc sees the whole
crate at once, so there are no headers and no declaration that can drift from its
definition. The cost is compile time. rustc splits the crate into codegen units
(16 by default in release builds, per the
Cargo profile reference) so
LLVM can work on them in parallel.logagg.exe is 1.2 MB against the C
build's 84 KB, because the C build calls into a C runtime DLL and this one carries its
own copy of std. It still links the Windows C runtime for a few functions.The toolchain on this machine is stable-x86_64-pc-windows-gnu, installed without
admin by tools/install-rust.ps1. The usual Windows
default, -msvc, links with link.exe from the Visual Studio Build Tools, which are not
installed here. The GNU target
(platform support) links with
MinGW, and rustup ships a copy of the MinGW linker inside the toolchain (NOTES.md shows
where). One consequence surfaced in objdump -p: this binary imports msvcrt.dll, the old C runtime
that the C track's notes warn against for printf. It does not matter
here, because Rust's formatting does not go through the C runtime's printf.
The build profile turns one default back on:
[profile.release]
overflow-checks = true
Integer overflow panics in debug builds and wraps silently in release builds, by default. Q3 explains why that default exists and why this track overrides it.
Dependencies come from crates.io, recorded in Cargo.toml, with exact
versions locked in Cargo.lock. This track has none. regex would replace the hand-written
digit checks, and a production version would use it; staying on std keeps the line counts
comparable with C and Go. Cargo.lock is committed, as the
Cargo FAQ advises for binaries.
Further reading:
A variable is the owner of a value. In C a variable names a region of memory and says
nothing about who frees it. In Rust every value has exactly one owner at a time, the
compiler tracks ownership through moves, and the value is freed when its owner goes out of
scope. The compiler inserts the free; the programmer never writes it.
Typing is static and strong. There is no implicit conversion between numeric types
(u16 to i64 needs i64::from or as), and there is no cast that turns an integer into
a usable reference outside unsafe code.
The layout is still C's. A String is three machine words on the stack (pointer, capacity,
length) and a heap block of UTF-8 bytes. A &str is two words: a pointer and a length,
pointing into bytes that something else owns. This is logagg part-way through one line:
Put this beside the C version. The memory is
identical: one reused line buffer, fields that point into it, and keys that are copied
because the buffer is about to be overwritten. In C the difference between a borrowed
pointer and an owned one is a comment. In Rust it is the difference between &str and
String, and the compiler checks every use.
Record<'a> says that a record contains a reference that is valid for some lifetime 'a:
struct Record<'a> {
service: &'a str,
status: u16,
micros: i64,
}
fn parse_record(line: &str) -> Result<Record<'_>, Reason>
The '_ in the return type ties the record to the line argument. The caller cannot keep
the record after the line changes. Here is what that means inside the read loop:
The C track's worst plausible bug was storing the borrowed service pointer as the table
key, so that every key silently became the last line of the file. That program is
HashMap<&str, Stats> in Rust. It does not compile:
error[E0502]: cannot borrow `buf` as mutable because it is also borrowed as immutable
--> bug.rs:9:9
|
9 | buf.clear();
| ^^^^^^^^^^^ mutable borrow occurs here
10 | if reader.read_line(&mut buf).unwrap() == 0 { break; }
11 | let service = buf.split_ascii_whitespace().nth(1).unwrap_or("");
| --- immutable borrow occurs here
...
14 | println!("{}", services.len());
| -------- immutable borrow later used here
The full reproduction is in NOTES.md. The rule the compiler enforces is short:
at any point, a value has either one mutable borrow or any number of shared borrows, and
no borrow outlives the owner. Since
non-lexical lifetimes (RFC 2094, 2018), a
borrow lasts until its last use, not until the end of the enclosing block. That change is
why the real logagg needs no extra braces or temporary variables around the lookup.
C has three tiers of "not specified" (C track, Q3). Rust divides the same space differently:
| Term | Meaning | Example |
|---|---|---|
| Panic | Defined. The thread unwinds (or the process aborts), and destructors run | v[10] on a 3-element Vec; .unwrap() on Err |
| Wrap or panic | Defined either way: panic with overflow checks on, two's-complement wrap with them off | i64::MAX + 1 |
| Undefined behaviour | Only reachable inside unsafe |
Dereferencing a dangling raw pointer; creating an invalid &str |
The middle row is a deliberate compromise. RFC 560
made overflow a program error that must either panic or wrap, never undefined behaviour,
and left checks off in release builds for speed. This track turns them on, because a
wrapped sum_micros would produce a plausible wrong mean, which is the failure SPEC 2 was
written to prevent. The complete list of undefined behaviour is in the Reference under
Behavior considered undefined.
It is short, and every item on it requires unsafe.
Further reading:
String layout drawn out.String documentation, "Representation"
— the three-word layout, stated by the standard library.unsafe code must uphold
so that the safe code around it can rely on these rules.Structs, enums, and traits, organised into modules and crates. There are no classes and no inheritance.
struct (all fields) or enum (one of several variants). Reason is an
enum with three variants and no data; Result<Record, Reason> is an enum whose variants
carry data. Enums are OCaml's variants under another name.trait, a named set of methods that a type implements. Reason
implements fmt::Display, and that is all it takes for {reason} to work in a format
string. Traits are how Rust does both generics and dynamic dispatch. A generic function
over T: Display is compiled once per concrete type (monomorphisation, like a C++
template). A &dyn Display holds a pointer to the value plus a pointer to a table of
methods, which is the C vtable from the C track's Q4, built by the compiler.pub. This
replaces C's static, and it works across files.logagg builds its abstractions from three structs (Record, Stats, Tally) and one
enum (Reason). Tally is the one worth noticing. It is the whole result of one pass over
some lines, and it has a merge method. The serial run builds one Tally; the sharded run
builds one per thread and merges them. The same type serves both because it owns everything
it contains: no field points into a line buffer.
At production scale, the pattern to recognise is the trait as the interface boundary.
std::io::Read and BufRead are traits, which is why tally_stream takes
impl BufRead. It would accept a file, a socket, stdin, or an in-memory buffer in a test,
with no change and no runtime cost.
Further reading:
Result<T, E> for failures the caller should handle, and panic for bugs. There are no
exceptions.
Result<T, E> is an enum with two variants, Ok(T) and Err(E). A function that
can fail returns one, and the caller must take it apart to reach the T.? operator returns early with the Err and otherwise unwraps the Ok. It is
the shortest correct way to pass an error up, and it is visible at every call site
(RFC 243).Option<T> (Some or None) is for "there might be no value", with no error to
report..unwrap() on an Err, a failed
assertion. It unwinds the thread, running destructors on the way. The kept-but-
discouraged escape hatch is std::panic::catch_unwind, which can stop the unwinding. It
exists for FFI boundaries and thread pools, not for control flow. A crate built with
panic = "abort" cannot catch panics at all.logagg uses the first three. parse_record is the SPEC 1 rule in executable form:
fn parse_record(line: &str) -> Result<Record<'_>, Reason> {
let [_, service, _, _, status, latency] = split6(line).ok_or(Reason::Fields)?;
...
}
The function parses rather than validates: it returns the typed Record, not a yes or
no, so no later code can use a field that was never checked. Alexis King's
Parse, don't validate
is the standard statement of the idea. The C, Go, and Python tracks validate first and then
parse the same fields a second time.
A malformed line is expected input, so it is an ordinary Err(Reason), and the caller
records it and continues. A file that cannot be opened is an io::Error. It travels up
through ? to main, which prints to stderr and returns ExitCode::from(12). The report
is built in a String first and written in one call, so the fatal path cannot leave half a
report on stdout.
| Track | Line 31 has status 20x |
|---|---|
| C | classify returns "STATUS"; caller checks for non-NULL and continues |
| Go | classify returns "STATUS"; "" means good |
| Python | classify returns "STATUS"; None means good |
| Rust | parse_record returns Err(Reason::Status); match must also handle Ok |
The second clause of the Rust row is the difference. In the other three, a forgotten check
compiles and then uses a bad record. In Rust the good record is only reachable inside Ok.
Further reading:
Result, ?, and when to panic.Operating-system threads, plus two marker traits that the compiler checks. A type is
Send if it is safe to move to another thread, and Sync if it is safe to share a &
reference between threads. The compiler works both out automatically from a type's fields.
thread::spawn requires its closure to be Send, and there are no exceptions outside
unsafe (Rustonomicon: Send and Sync).
The result is that a data race is a compile error. Two threads writing one HashMap
with no lock:
error[E0499]: cannot borrow `services` as mutable more than once at a time
--> race.rs:8:21
|
8 | s.spawn(|| *services.entry("api".into()).or_default() += 1);
| --------^^-------------------------------------------------
| | | |
| | | borrows occur due to use of `services` in closure
| | `services` was mutably borrowed here in the previous iteration of the loop
Compare the C track's Q6, where the same program compiles and is undefined behaviour.
logagg is the first track to implement SPEC 6's --shard N. It uses the per-worker
design the C track recommended: each thread builds its own Tally and nothing is shared
while they run.
thread::scope(|s| {
let workers: Vec<_> = lines
.chunks(per)
.enumerate()
.map(|(i, chunk)| s.spawn(move || { /* build a Tally from chunk */ }))
.collect();
// join in order, merge
})
The workers borrow slices of lines, which borrows text, which main owns. Plain
thread::spawn would reject that, because the compiler cannot know the thread finishes
before text is freed:
error[E0597]: `text` does not live long enough
...
| .map(|chunk| thread::spawn(move || count(chunk))) // no scope
| ----------------------------------- argument requires that `text` is borrowed for `'static`
thread::scope, stable since Rust 1.63
(August 2022), joins every thread spawned inside it before it returns. With that guarantee
the borrows are valid, and no Arc (reference counting) or Mutex is needed. The output
is byte-identical to the serial run for every shard count from 1 to 500.
What Rust does not prevent: deadlocks, lock-ordering bugs, and race conditions in
logic (two correct operations in the wrong order). Those are ordinary program errors in
Rust, as in every other track. Rust also has async/await for I/O-bound work, and it
ships no runtime for it: you choose one, usually Tokio. That is outside logagg's scope,
and it is the reason many Rust services contain two concurrency models.
Further reading:
std::thread::scope and
RFC 3151 — the API, and
the design discussion behind it.Mutex, and Send/Sync.logaggsrc/main.rs · working notes in NOTES.md
PASS rust
| Track | Code lines (no blanks or comments) | Notes |
|---|---|---|
| C | 285 | own hash table, line buffer, and splitter |
| Rust | 267 | includes --shard N: 33 lines for tally_sharded and Tally::merge |
| Go | 189 | |
| OCaml | 183 | |
| Python | 140 | the reference |
Without the shard path, Rust is about 235 lines. That is close to C's length for the
opposite reason. C spends its lines on data structures the standard library does not
provide. Rust spends them on types that make the rules explicit: Record<'a>, Reason,
the Tally that can be merged. The hash map, the line reader, and the splitter each take
one line.
Four decisions that come from Rust:
split6 returns Option<[&str; 6]>. It pulls six items from the whitespace
iterator with ? and checks that a seventh does not exist. No Vec is allocated, and
the caller destructures the array by pattern:
let [_, service, _, _, status, latency] = …. C writes NULs into the buffer to get the
same zero-allocation split. Rust gets it by borrowing.services.entry(key.to_owned()) is the
shortest code, but it allocates a String on every line. get_mut(rec.service) looks up
by &str first. This works because HashMap<String, _> accepts a &str for lookup,
through the Borrow trait.{name:<20.20} pads and truncates. In a Rust format string, precision on a string
is a maximum width, as in C's %-20.20s. Python needs name[:20] and Go needs a slice
for the same thing (std::fmt).\r\n; this track
needs none.And one piece of SPEC 2 confirmed: std::fmt's documentation says float formatting rounds
half to even, the same as C and Python. A {:.3} of an f64 latency would have
disagreed with Java's output, exactly as the spec warns.
Ranked by cost in production, not by how often beginners hit them. The first three are things safe Rust allows by design.
.unwrap() and .expect() turn an Err into a panic.
That is correct for bugs and wrong for input. On 18 November 2025 Cloudflare's FL2 proxy,
written in Rust, panicked across its network when a generated configuration file grew
past a 200-entry limit, and an unwrap() on the Result of that check took down traffic
for about three hours before the main fix
(Cloudflare's post-mortem). The
compiler did its job: the Result was there. Someone chose to unwrap it. logagg has one
expect, on join(), which only fails if a worker thread already panicked.as also truncate silently: 300_i32 as u8 is 44.Send and Sync prevent data races only. Two Mutexes
locked in opposite orders by two threads deadlock in Rust as in C. A RefCell borrowed
mutably twice panics at run time, because it moves the borrow check from compile time to
run time.unsafe blocks that break the rules. Every guarantee above assumes that unsafe
code upholds the invariants. A bug in an unsafe block causes undefined behaviour that
may surface in safe code far away. The RustSec advisory database
records these for published crates. Its memory-corruption entries trace back to unsafe
code, because safe Rust cannot produce memory corruption without a compiler bug.Vec,
Rc<RefCell<T>>, or an arena. Choose one early, because changing it later touches every
function.Rc cycles are never freed, and std::mem::forget is not
unsafe. Rust guarantees memory safety, and a leak is not unsafe.The tooling layer, in the same shape as the C track's:
| Layer | Tool | What it catches |
|---|---|---|
| Compile time | rustc borrow checker; clippy (-W clippy::pedantic in build.ps1) |
Ownership errors; around 800 lints for style, performance, and likely bugs |
Interpreter for unsafe |
Miri | Undefined behaviour in unsafe code, by running MIR under an interpreter that checks every access |
| Concurrency | loom | Explores the thread interleavings of a concurrent test |
| Adversarial input | cargo fuzz (libFuzzer) |
Panics on inputs you did not think of; parse_record is a natural target |
| Supply chain | cargo audit against RustSec |
Dependencies with published advisories |
Professional Rust is the language plus clippy in CI, a rule about where unwrap is
allowed, and Miri for any crate with unsafe. The compiler covers much more than C's does,
but not all of it.
Recommended: the function behind the read_line call that logagg makes in its loop,
append_to_string
in library/alloc/src/io/read.rs. (It lives in alloc and std re-exports it; search the
file for the name, because line numbers move.) About 25 lines of code under a 20-line comment. It is the inside of
the safety guarantee:
unsafe fn. It reaches the String's raw bytes through as_mut_vec(), which
lets it write bytes that might not be UTF-8. The String type promises that its contents
are always valid UTF-8, so for the duration of the call, this function is responsible for
that promise.str::from_utf8 has checked the new bytes.This is where the C track's discipline went. The same care exists, written once in the standard library, and every safe caller relies on it without repeating it.
Alternative, longer: ripgrep's
crates/searcher/src/line_buffer.rs
(about 550 lines of code and 400 of tests). It is a production line buffer: a reused Vec<u8>,
a configurable limit on how far it may grow for one long line, binary-file detection, and a
roll step that moves a partial line to the front before the next read. Read it against
logagg's read_line loop to see what a line reader needs when the input is anything
anyone throws at it.
Ownership is a design tool first and a compiler feature second. Writing Rust teaches you to answer "who owns this, and who is only borrowing it?" for every value. That answer improves a C, Go, or Java design as well, where nothing checks it.
Carry these forward:
&str versus String,
&[T] versus Vec<T>. Even in a language without the checker, naming the difference
(a View type, a Copy suffix, a comment in the signature) removes the C track's worst
bug from the list of possibilities.parse_record returns a Record,
not a bool. Code after the boundary cannot use an unchecked field, because there is no
unchecked field to use. This works in any language with types, including Python with a
dataclass.Tally::merge is why
--shard N needed no locks. The same design works with goroutines, Java executors, and
Python multiprocessing, and it is the easiest design to reason about in all of them.errgroup, Java's
structured concurrency (a preview since Java 21), and
Python's asyncio.TaskGroup are the same idea as thread::scope.unsafe is a boundary, not a mode. Keep the unsafe code small, document what the
caller must guarantee, and wrap it in a safe API, as append_to_string does. In C, the
whole program is the unsafe block. The goal in C is to make that block as small as
possible anyway.