Rust From Scratch (Beginner)¶
Beginner path: never-coded → reading and contributing to real OSS Rust. Includes the borrow checker.
Printing this page
Use your browser's Print → Save as PDF. The print stylesheet hides navigation, comments, and other site chrome; pages break cleanly at section boundaries; advanced content stays included regardless of beginner-mode state.
Rust From Scratch - Beginner to OSS Contributor¶
From "I have never written code" to "I can clone a real Rust project, read most of it, and submit a pull request."
Who this is for¶
- You have never written code, OR
- You have copy-pasted Rust code from tutorials and bounced off the borrow checker.
That's it. If you need to know something, this path will teach it.
Heads up: Rust is harder than the other beginner paths¶
Honest warning: Rust has a steeper learning curve than Go or Python. The borrow checker - the compiler component that enforces Rust's memory safety rules - will refuse to compile code you wrote on autopilot, and the error messages take time to learn to read. Most people who pick up Rust have at least one moment of "this language is fighting me."
This path is paced for that. Page 06 (ownership and borrowing) is the hard one. We take a whole page on it; we don't pretend it's easy.
The reward: when your Rust program compiles, it almost always does the right thing. The class of bugs that haunts C, C++, Go, Java, and Python programs (null derefs, data races, use-after-free, double-free, type confusion) mostly doesn't exist in safe Rust. The compiler eliminates them up front.
What you'll need¶
- A computer (macOS, Linux, Windows).
- A text editor - VS Code with the
rust-analyzerextension. Free, excellent. - A terminal.
- About 5 hours per week. Path is sized for 4-6 months at that pace.
Why Rust¶
- Memory safety without garbage collection. A category of bugs eliminated at compile time, with no runtime cost.
- Performance comparable to C. Used for systems software, browsers (Firefox), kernels (parts of Linux), databases, embedded.
- The package manager (Cargo) is excellent. Best-in-class. Made the language much easier to adopt.
- The community is welcoming. "I'm learning Rust" gets you helpful responses; gatekeeping is rare.
- Hireable. Demand for Rust engineers has grown steadily; supply hasn't kept up.
How this path works¶
Each page does one thing: says what you'll learn, shows code, walks through it line by line, gives an exercise, ends with a Q&A.
The pages¶
| # | Title | What you'll know after |
|---|---|---|
| 00 | Introduction | What we're doing and why |
| 01 | Setup | Rust installed, hello world, cargo |
| 02 | First real program | Variables, mutability, types |
| 03 | Decisions and loops | if (expression!), loop/while/for, basic match |
| 04 | Functions | Including expressions vs statements |
| 05 | Structs and enums | Rust's enums are special |
| 06 | Ownership and borrowing | THE Rust page |
| 07 | Collections | Vec, HashMap, slices, &str vs String |
| 08 | Error handling | Result, ?, panic! |
| 09 | Traits and generics | Rust's substitute for inheritance |
| 10 | Tests | Built into cargo |
| 11 | Crates and Cargo | Packaging |
| 12 | Reading other people's code | The bridge |
| 13 | Picking a project | What "manageable" looks like |
| 14 | Anatomy of a Rust OSS repo | Case study |
| 15 | Your first contribution | Workflow + PR |
Start with Introduction.
00 - Introduction¶
What this session is¶
A 10-minute read. No code yet. Sets expectations honestly.
What you're going to build, eventually¶
By the end of this path:
- Written and run small Rust programs.
- Built a little command-line tool.
- Written tests for your own code.
- Cloned a real Rust open-source project, browsed it, run its tests, understood roughly what it does.
- Submitted a small fix as a pull request.
That last point is the goal.
The deal¶
It's slow on purpose. One concept per page.
It assumes nothing. Every word defined inline.
You have to type the code. Reading without typing doesn't stick.
You will be confused. Often. More than with other languages. Rust has a steep ramp because it asks you to think about memory ownership - something Python, Java, Go all hide. Once you've internalized it, you write code that the compiler proves correct, which is its own kind of magic. The first 6 weeks are the worst; it gets better.
The Rust honesty section¶
Things you should know going in:
-
The borrow checker will reject code that looks fine. This is a feature. The error messages take time to learn to read. The first month is humbling. You'll have moments of "I just wanted to put this in a list, why is the compiler shouting at me." This is normal. Don't quit.
-
Compile times are slower than Go/Python. A medium Rust project takes minutes to build cold, tens of seconds incremental. Tooling has improved (incremental compilation, sccache); it's still slower than you'd like.
-
There are concepts here that don't exist in other languages. Lifetimes. The distinction between
&strandString. Move semantics. Traits as constraints, not interfaces. We'll meet them gently. -
The reward is real. Programs that pass
cargo checkare usually correct. There's a class of bugs (memory unsafety, data races) that compile errors prevent up front. Your CI and your debugging both get shorter.
What you need¶
- A computer (any OS).
- A text editor - VS Code with the
rust-analyzerextension is the standard. Free. - A terminal.
- ~5 hours/week. Path is sized for 4-6 months.
- Patience. Real patience. Maybe slightly more than you've allocated.
What you do NOT need¶
- A C/C++ background. (It helps; not required.)
- A computer-science degree.
- To be "smart enough for Rust." Nobody finds the borrow checker easy at first.
- To know any other language first.
How long this realistically takes¶
4 to 6 months at 5 hours/week to "submit a pull request." The slow page is page 06 (ownership). Plan to spend an extra week there.
What success looks like¶
You'll be able to: - Open a Rust file and read it. - Open a Rust project on GitHub and tell me in two paragraphs what it does. - Find a small bug or missing feature and fix it. - Submit the fix as a PR matching the project's style.
You will not be able to: - Write a kernel module. - Tell people you're "a Rust expert." (Years of work past this.)
A note on safe vs unsafe¶
Rust has an escape hatch: the unsafe keyword. Inside unsafe { ... } blocks, you can do things the borrow checker can't verify (dereference raw pointers, call C functions, etc.). About 95%+ of all Rust code is safe Rust - no unsafe needed. We will not cover unsafe in this beginner path. You may meet it when reading low-level libraries; treat it as "the author is taking responsibility for the safety the compiler can't check here."
One last thing¶
If a page feels too dense, stop and re-read. If still too dense, that's a page bug - note, skip, come back.
Ready? Next: Setup →
01 - Setup¶
What this session is¶
About 30 minutes. You'll install Rust, write your first program, and meet Cargo - Rust's build tool, package manager, test runner, and documentation generator, all in one binary. Rust's tooling is one of its best features; you'll appreciate it immediately.
Step 1: Install Rust via rustup¶
rustup is the official installer. It manages Rust toolchain versions and components.
macOS / Linux:
Follow the default prompts (option 1). When done, restart your terminal or run:
Windows: download rustup-init.exe from rustup.rs, run it, follow the prompts. You may need Visual Studio Build Tools for the C linker - the installer guides you.
Verify:
You should see version numbers around 1.80 or later.
Step 2: Install rust-analyzer in VS Code¶
Open VS Code → Extensions → search "rust-analyzer" → Install. This gives you inline error highlighting, autocompletion, go-to-definition, and "explain this error" - invaluable when learning.
Step 3: Your first program with Cargo¶
Cargo creates a new project skeleton for you:
That creates:
Open src/main.rs:
That's the whole file. Cargo created it for you.
Step 4: Build and run¶
You'll see Cargo compile your project, then:
Compiling hello v0.1.0 (...)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.55s
Running `target/debug/hello`
Hello, world!
That's your first Rust program.
cargo run is the daily driver. It compiles and runs in one step. Other useful commands:
| Command | What it does |
|---|---|
cargo new <name> |
Create a new project. |
cargo build |
Compile (don't run). Output in target/debug/. |
cargo build --release |
Optimized build. Output in target/release/. Slower to compile, faster at runtime. |
cargo check |
Type-check without compiling fully. Much faster than cargo build. Use this during development. |
cargo test |
Run tests. |
cargo doc --open |
Generate and open documentation. |
cargo clippy |
Run the linter. |
cargo fmt |
Auto-format your code. |
cargo update |
Update dependencies (and Cargo.lock). |
What just happened - line by line¶
fn main()- defines a function calledmain. Like Java/C,mainis special - it's where the program starts.println!("Hello, world!");- calls theprintlnmacro with the string"Hello, world!".- The
!makes this a macro, not a function. Macros are code that expands into other code at compile time.println!exists as a macro (instead of a function) because it takes a variable number of arguments with type checking. You'll meet a few macros (vec!,format!,assert_eq!); the!is the visual signal. - The
;ends a statement. - Curly braces
{ }delimit the function body.
Simpler than Java; about as much as Go. Rust gives you a hello world in 3 lines and a project skeleton.
Try changing things¶
-
Change the message;
cargo runagain. (Cargo recompiles, then runs.) -
Add a second line:
-
Try printing a number - note the format string:
The{}is a placeholder, similar to f-strings. We'll see more in page 02. -
Break it on purpose. Remove a semicolon. Run
cargo check- read the error. -
Restore. Now mistype
printlnasprintl. Runcargo check. The compiler error tells you exactly what's wrong, often with a suggested fix.
Rust's compiler errors are widely considered the best in the industry. Read them. They teach you.
The Cargo.toml file¶
Open it. You should see:
name/version- your project's identity.edition- Rust's "edition" system. Lets the language evolve syntax without breaking old code. Current is2021;2024exists; the differences are minor for learners.[dependencies]- empty for now. We'll add things in page 11.
Try cargo check¶
The fastest feedback loop in Rust:
It type-checks your code without generating a final binary. Twice as fast as cargo build. Use it during development; reserve cargo run / cargo build for when you actually want to execute.
What you might wonder¶
"Why both cargo run and cargo build?"
cargo run = build + run. cargo build just produces a binary in target/debug/<projectname>. Useful when you want to ship the binary or run it through other tools.
"Why dev vs release builds?"
Dev builds: fast to compile, slow to run, with debug info. Release builds: slow to compile, much faster to run, optimized. Use dev for development; build release for shipping.
"Why is the binary in target/debug/?"
Cargo organizes output by profile. target/debug/ for dev builds, target/release/ for release. Add target/ to .gitignore (Cargo's cargo new already did).
"Should I use cargo check or cargo build?"
Use cargo check while iterating; cargo build or cargo run when you want to actually execute the program. The speed difference matters as your project grows.
Done¶
You have: - Rust installed via rustup. - Cargo (which came with rustup). - A working "hello world" project. - rust-analyzer in your editor for inline errors.
Next page: the real learning starts.
02 - First Real Program¶
What this session is¶
About 45 minutes. You'll learn variables, mutability (Rust's signature variable design), primitive types, two string types (&str and String - a Rust thing), and the println! format syntax.
A small program¶
Inside your hello project, replace src/main.rs:
Run:
Output:
What's new¶
letcreates a variable. (Unlikevarin Java orx = ...in Python - Rust requireslet.)name = "Alice"- assigns the string"Alice".- No explicit type - Rust infers it.
"Alice"is a&str(string slice; we'll explain shortly);30isi32(32-bit signed integer).
The format string:
{} is a positional placeholder. Each {} consumes one argument from the list, in order. There's also named-argument and indexed-argument syntax; positional is the most common.
You can name them:
This is Rust's f-string equivalent - works since 2021. The variable name in {} matches one in scope; no need to pass it explicitly.
Mutability: variables are immutable by default¶
This is the first surprise:
In Rust, variables can't change by default. To make a variable changeable, mark it mut:
Why? Two reasons: 1. Code is easier to reason about. Reading code where most variables don't change tells you what's stable and what changes. 2. The compiler can optimize harder. Immutability enables aggressive optimizations.
You'll often write let x = ... once and never need to change it. When you do need mutation, the mut is right there making it explicit.
Shadowing: a different kind of "rebind"¶
Even without mut, you can declare a new variable with the same name:
let x = 5;
println!("{x}"); // 5
let x = x + 1; // new variable x, "shadows" the old one
println!("{x}"); // 6
let x = "hello"; // shadowing can change the type
println!("{x}"); // hello
Shadowing creates a new variable; it doesn't mutate the old. Useful for transforming a value through stages. Not the same as mut.
Types: the primitives¶
Rust's basic types:
| Type | What it holds | Example |
|---|---|---|
i32 |
32-bit signed integer | 42, -7 |
i64 |
64-bit signed integer | 1_000_000_000_000 |
u32 |
32-bit unsigned integer (non-negative) | 42 |
u64 |
64-bit unsigned integer | 1u64, 42_000_000_000 |
usize |
platform-sized unsigned int (usually 64-bit) | array indices |
f64 |
64-bit floating-point number | 3.14 |
bool |
true or false |
true |
char |
one Unicode scalar value | 'A', '🦀' |
&str |
string slice (borrowed) | "hello" |
String |
owned, growable string | String::from("hello") |
The default integer is i32. The default float is f64. Use those unless you need something specific.
You can suffix literals to pick a type: 42_u8, 3.14_f32. Underscores anywhere in numbers are ignored, used for readability: 1_000_000.
Type annotations¶
Type inference is good, but you can annotate explicitly:
The : u32 between name and value declares the type. Useful when inference can't figure it out (often after parse):
(We'll meet parse and unwrap in page 08. For now, recognize that the : i32 tells parse which type to return.)
Two string types: &str and String¶
This trips up everyone learning Rust. There are two main string types:
&str("string slice") - a borrowed reference to text that lives somewhere else. String literals like"hello"are&strs. Immutable. Lightweight (just a pointer + length).String- an owned, growable string on the heap. Mutable. Heavier (allocated buffer with capacity).
let literal: &str = "hello"; // &str - points into the program's data
let owned: String = String::from("hello"); // String - allocated
let owned2 = "hello".to_string(); // same thing, alternate syntax
When you need to:
- Print or read text → either works (use &str if you can, it's cheaper).
- Build a string with +, .push_str, .push → use String (the mutable one).
- Parameters that accept either → take &str (a String auto-converts to &str when needed).
fn greet(name: &str) {
println!("Hello, {name}");
}
greet("Alice"); // works - literal is &str
greet(&String::from("Alice")); // works - String coerces to &str
The full story (ownership, borrowing, slicing) lands in page 06. For now: literals are &str, owned strings you build are String.
Arithmetic¶
let x = 10;
let y = 3;
println!("{}", x + y); // 13
println!("{}", x - y); // 7
println!("{}", x * y); // 30
println!("{}", x / y); // 3 - integer division (both operands are integers)
println!("{}", x % y); // 1 - remainder
Integer division drops the remainder. For decimals, use floats:
Rust does not implicitly convert between numeric types. You can't let x: i64 = some_i32. You have to cast explicitly:
let small: i32 = 100;
let big: i64 = small as i64; // explicit cast
let pi_int: i32 = 3.14_f64 as i32; // truncates to 3
This strictness catches bugs (silent narrowing in C is a common source of issues). It's also occasionally tedious.
Building strings: format!¶
println! prints; format! returns a String:
Same syntax, same placeholders. Use format! when you want the string for later use.
Format-spec essentials¶
| Spec | Meaning | Example |
|---|---|---|
{} |
default display | 42, "hello" |
{:?} |
debug display (more raw) | useful for printing structs |
{:.2} |
2 decimal places | 3.14 from 3.14159 |
{:5} |
min width 5, right-aligned | 42 |
{:<5} |
min width 5, left-aligned | 42 |
{:0>3} |
pad with zeros, width 3 | 042 |
You don't need to memorize them; look up when needed. {} and {:?} are the two you'll use 95% of the time.
Reading input from stdin (briefly)¶
use std::io;
fn main() {
let mut input = String::new();
println!("What's your name?");
io::stdin().read_line(&mut input).expect("failed to read line");
let name = input.trim();
println!("Hello, {name}");
}
New things:
- use std::io; - bring the io module into scope.
- let mut input = String::new(); - empty mutable String.
- io::stdin().read_line(&mut input) - read from stdin, append to input. The &mut is a mutable reference - we'll explain in page 06.
- .expect("...") - for now, "crash with this message if it fails." We'll do proper error handling in page 08.
- input.trim() - strips trailing newline.
Lots of new syntax. Don't try to absorb it all - just recognize the pattern when you need stdin.
Exercise¶
In your hello project, replace src/main.rs:
Write a program that:
- Has a variable for your name (
&str). - Has a variable for your favorite number (
i32). - Has a variable for whether it's morning (
bool). - Has a variable for
pi(f64). - Prints a multi-line message using
println!macros: For thepiline, use{:.2}to format to 2 decimals.
Run with cargo run.
Stretch: make name a String instead of &str. Pass it to a function fn greet(name: &str). Test that both name (a String) and a literal like "world" work as arguments.
What you might wonder¶
"Why is everything immutable by default?"
Reading code where variables don't change is easier - you know what's stable. mut makes mutation explicit and grep-able. Most variables in real Rust code don't need mut.
"Why two string types?"
Performance + ownership. &str is a borrowed view - cheap, immutable. String is owned - can grow, must be allocated and freed. The distinction matters when we get to ownership (page 06). For now: literals are &str, owned strings are String.
"Why explicit casts for numbers?" Implicit numeric conversion is a common bug source in C (silent overflow, sign confusion). Rust forces you to acknowledge what's happening. Slightly more typing; fewer bugs.
"Is mut "global" or per variable?"
Per variable. let mut x = 5; let y = 10; - x is mutable, y is not.
"What if I want a constant?"
Use const:
const is immutable, must have a type annotation, available throughout the file's scope.
Done¶
You can:
- Use let and let mut correctly.
- Recognize the primitive types and pick the right one.
- Distinguish &str from String.
- Use println! / format! with positional, named, and formatted placeholders.
- Cast between numeric types explicitly.
Next page: making your program decide and repeat.
03 - Decisions and Loops¶
What this session is¶
About an hour. You'll learn if (an expression in Rust - produces a value), three loop forms (loop, while, for), and the basics of match (Rust's powerful pattern-matching switch).
if is an expression¶
In most languages, if is a statement (does something, returns nothing). In Rust, if is an expression - it produces a value:
Notice no semicolons inside the { } for the branches - the last expression is the value of the branch. Both branches must produce the same type. Then let label = ... captures that value.
The "old" form still works:
Braces are required even for one-line bodies. (Rust prevents the C-style "forgot the braces" bug.)
No parentheses around the condition required:
Comparison operators¶
| Op | Meaning |
|---|---|
== |
equal |
!= |
not equal |
< <= > >= |
numeric |
Strings: == works for content comparison (&str == &str, String == &str). Unlike Java, you don't need .equals - Rust does the right thing.
Chaining: else if¶
let score = 75;
let grade = if score >= 90 { "A" }
else if score >= 80 { "B" }
else if score >= 70 { "C" }
else { "F" };
println!("{grade}");
Combining: &&, ||, !¶
Same as C/Java/Go. Short-circuiting: && skips the right if left is false; || skips if left is true.
Loop 1: loop (infinite)¶
loop runs forever until break. Like while true in other languages but more explicit.
Cool trick: break can return a value:
Loop 2: while¶
Standard "while condition" loop.
Loop 3: for ... in¶
By far the most common. Iterates over anything that produces a sequence:
1..=5 is a range - 1 to 5 inclusive. 1..5 is exclusive (1, 2, 3, 4). You'll see both.
Iterate a collection:
let fruits = ["apple", "banana", "cherry"];
for fruit in fruits.iter() {
println!("{fruit}");
}
// Or simpler:
for fruit in &fruits {
println!("{fruit}");
}
The &fruits is a reference to the array (so for doesn't consume it). We'll explain references properly in page 06. For now, the pattern for x in &collection is idiomatic.
break and continue¶
for i in 1..=10 {
if i == 5 { break; }
if i % 2 == 0 { continue; }
println!("{i}");
}
// prints 1, 3
You can also label loops to break out of nested ones:
Rare; nice when you need it.
match: pattern-matching switch¶
Rust's switch, called match. More powerful than C-style switch - matches on patterns, not just constants. Always exhaustive - the compiler verifies you covered every case.
let day = 2;
let name = match day {
1 => "Mon",
2 => "Tue",
3 => "Wed",
_ => "?", // catch-all (underscore)
};
println!("{name}"); // Tue
The arms (pattern => value) are comma-separated. The whole match is an expression - its value is whatever arm matched.
Multiple values per arm:
Ranges:
let category = match age {
0..=12 => "child",
13..=17 => "teen",
18..=64 => "adult",
_ => "senior",
};
_ is the catch-all pattern - matches anything. Required when the patterns don't cover everything.
match gets much more powerful when matching on enums and structs (page 05).
Exercise¶
In your hello project, replace src/main.rs with FizzBuzz:
For each number 1 to 20:
- Divisible by 3 → print Fizz.
- Divisible by 5 → print Buzz.
- Divisible by both → print FizzBuzz.
- Otherwise → print the number.
Hint: check "both 3 and 5" first.
Then rewrite using match:
match (n % 3, n % 5) {
(0, 0) => println!("FizzBuzz"),
(0, _) => println!("Fizz"),
(_, 0) => println!("Buzz"),
_ => println!("{n}"),
}
(n%3, n%5). (0, 0) means "both zero." (0, _) means "first is zero, second is anything." Powerful pattern.
What you might wonder¶
"Why is if an expression?"
Lets you write let x = if cond { a } else { b }; instead of needing a ternary operator or a separate temp variable. Cleaner; consistent with the "everything is an expression" trend.
"Why does match require _ or full coverage?"
Exhaustiveness checking. If you switch on an enum and forget a variant, the compiler tells you exactly which one. This catches a huge class of "I forgot to handle that case" bugs.
"What about a do-while?"
Use loop with a conditional break at the end:
Done¶
if/elseas expressions.- Three loop forms.
break/continue(and label-break).matchwith multi-value arms, ranges, tuple patterns.
04 - Functions¶
What this session is¶
About 45 minutes. You'll learn to define functions, the distinction between expressions and statements (Rust takes this seriously), and the basics of how to return values without writing return.
The shape¶
Concrete:
Note:
- Parameters need explicit types: x: i32.
- Return type follows ->. For void-equivalent (no return), omit the arrow (or write -> () - () is the "unit" type).
- The function body is x * 2 (no semicolon, no return). The last expression of a block is its value.
Expressions vs statements¶
This is the bit that takes adjusting.
- Statements perform an action and don't return a value.
let x = 5;is a statement. - Expressions produce a value.
5,5 + 5,f(x),if cond { a } else { b }are all expressions.
In Rust, a block ({ ... }) is an expression. Its value is whatever the last expression inside is, as long as that expression doesn't end with a semicolon.
let x = {
let a = 2;
let b = 3;
a + b // no semicolon - this is the block's value
};
println!("{x}"); // 5
If you put a semicolon:
let x = {
let a = 2;
let b = 3;
a + b; // semicolon - makes it a statement; block evaluates to ()
};
x is now () (unit), not 5. The compiler will probably complain elsewhere.
For functions: omit the semicolon on the last expression to return its value.
You can use return explicitly:
Both work. The implicit form is more idiomatic. Use return for early returns:
fn safe_divide(a: i32, b: i32) -> i32 {
if b == 0 {
return 0; // early return
}
a / b // normal return - no semicolon
}
Multiple parameters¶
Each parameter gets its own type annotation.
No defaults; no named arguments¶
Rust doesn't have default parameter values or named arguments. To get default-like behavior, write multiple functions or accept an Option/struct:
fn greet(name: &str) -> String {
format!("Hello, {name}")
}
fn greet_with(name: &str, greeting: &str) -> String {
format!("{greeting}, {name}")
}
(There are libraries that simulate named args via builder patterns - for now, multiple functions is fine.)
Returning nothing¶
Functions that return nothing have no ->:
Equivalently: -> () is the explicit form. () ("unit") is Rust's "nothing" - a type with exactly one value, also written ().
Returning multiple values: tuples¶
Like Python, Rust returns tuples for "several values at once":
fn divide(a: i32, b: i32) -> (i32, i32) {
(a / b, a % b)
}
fn main() {
let (q, r) = divide(17, 5);
println!("quotient {q}, remainder {r}");
}
(i32, i32)is the return type - a 2-tuple of integers.(a / b, a % b)constructs the tuple.let (q, r) = ...destructures it.
Tuples are anonymous - for a return type with named fields, use a struct (page 05).
Functions calling functions¶
fn square(x: i32) -> i32 { x * x }
fn sum_of_squares(a: i32, b: i32) -> i32 {
square(a) + square(b)
}
fn main() {
println!("{}", sum_of_squares(3, 4)); // 25
}
Variable scope¶
Variables exist only inside the block ({ }) they were declared in:
fn double(x: i32) -> i32 {
let result = x * 2;
result
}
fn main() {
println!("{result}"); // ERROR - `result` doesn't exist out here
}
Where to put functions¶
In a single-file program, all functions live at the top level of main.rs. Order doesn't matter - Rust scans the whole file, so functions can call each other regardless of which is defined first.
fn main() {
println!("{}", helper(5)); // OK even though helper is defined below
}
fn helper(x: i32) -> i32 { x + 1 }
In multi-file projects, you'll organize into modules (page 11).
Exercise¶
In your project, replace src/main.rs:
- Write
fn is_even(n: i32) -> bool. Use%. - From
main, printis_even(4)andis_even(7). Expected:true,false. - Write
fn count_evens(max: i32) -> i32that counts even numbers in1..=max. Loop, callis_even. - Print
count_evens(10)→5;count_evens(100)→50. - Stretch: write
fn quotient_and_remainder(a: i32, b: i32) -> (i32, i32). Frommain, destructure:let (q, r) = quotient_and_remainder(17, 5);and print both.
What you might wonder¶
"Why do I have to think about expressions vs statements?"
Rust uses "everything is an expression" pervasively - if, match, blocks. The semicolon-or-no-semicolon distinction is small but consistent. After a week you stop thinking about it.
"Can I omit the return type?"
For functions with no return value, yes (fn foo() { ... }). For functions that return something, no - you must declare it explicitly.
"Can a function call itself?" Yes (recursion). Common for tree traversal etc. The compiler doesn't currently optimize tail calls; very deep recursion can stack-overflow.
"Can I have two functions with the same name?" Not in the same scope. Use different names or methods on different types (page 05).
Done¶
- Define functions with parameters and return types.
- Distinguish expressions from statements.
- Return implicitly (no semicolon) or with
return. - Return multiple values via tuples; destructure with
let (a, b) = ....
Next page: structs and enums - Rust's way to make your own types.
05 - Structs and Enums¶
What this session is¶
About an hour. You'll learn Rust's two ways to define your own types: structs (a bundle of named fields) and enums (a closed list of variants - much more powerful than enums in most languages). You'll also see impl blocks, where you attach methods to types.
Structs¶
The bundle-of-fields type:
struct Person {
name: String,
age: u32,
city: String,
}
fn main() {
let alice = Person {
name: String::from("Alice"),
age: 30,
city: String::from("Lagos"),
};
println!("{}, {}, {}", alice.name, alice.age, alice.city);
}
Notes:
- struct Person { ... } declares the type.
- Fields have explicit types. Field names use snake_case by convention.
- Person { name: ..., age: ..., city: ... } is a struct literal - creates an instance.
- alice.name accesses a field.
- String::from("Alice") makes an owned String from a &str literal. We met this in page 02.
By default, struct fields are immutable. To mutate, the variable holding the struct must be mut:
You can't mutate just one field independently - mutability is on the binding (the variable), not the field.
Field shorthand¶
If a local variable has the same name as the field, you can omit the name: name pair:
fn new_person(name: String, age: u32) -> Person {
Person {
name, // shorthand for `name: name`
age, // shorthand for `age: age`
city: String::from("unknown"),
}
}
Cleaner; you'll see it everywhere.
Methods: impl blocks¶
To attach methods to a struct, use an impl block:
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn scale(&self, factor: f64) -> Rectangle {
Rectangle {
width: self.width * factor,
height: self.height * factor,
}
}
}
fn main() {
let r = Rectangle { width: 5.0, height: 3.0 };
println!("{}", r.area()); // 15
let big = r.scale(2.0);
println!("{}", big.area()); // 60
}
What's new:
- impl Rectangle { ... } - block of methods for Rectangle.
- fn area(&self) - &self means "borrow self immutably." The method reads but doesn't mutate. (Java's this is implicit; Rust's self is explicit.)
- self.width - access a field of the value being operated on.
Three receiver forms you'll see:
| Receiver | Meaning |
|---|---|
&self |
borrow immutably - read-only access |
&mut self |
borrow mutably - can modify fields |
self |
take ownership - the method consumes the value |
(Ownership is page 06. For now: &self for read, &mut self for write.)
impl Rectangle {
fn grow(&mut self, amount: f64) { // &mut self - can mutate
self.width += amount;
self.height += amount;
}
}
let mut r = Rectangle { width: 5.0, height: 3.0 };
r.grow(1.0);
println!("{} {}", r.width, r.height); // 6.0 4.0
Associated functions (no self)¶
Methods without &self are called associated functions - they belong to the type but don't operate on a specific instance. Used for constructors:
impl Rectangle {
fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height }
}
fn square(side: f64) -> Rectangle {
Rectangle { width: side, height: side }
}
}
let r = Rectangle::new(5.0, 3.0); // call as Type::function
let s = Rectangle::square(4.0);
Notice Rectangle::new - the :: is for associated functions (and types-on-types in general). Compare to r.area() - the . is for methods on instances.
There's no built-in "constructor" keyword. The convention is a new associated function (or other named factory like square).
Tuple structs¶
A struct without field names - just types in order:
Useful when fields don't need names (or when you want a type-safe wrapper around a single primitive: struct UserId(u64);).
Unit structs¶
A struct with no fields. Used as marker types.
Rare; recognize when you see it.
Enums: the powerful kind¶
A Rust enum is more than a list of names - it can hold data, different per variant. This is one of Rust's defining features.
Simple enum:
Use match (page 03):
fn describe(d: Direction) -> &'static str {
match d {
Direction::North => "up",
Direction::South => "down",
Direction::East => "right",
Direction::West => "left",
}
}
No _ needed because the four variants cover everything (exhaustive).
(&'static str is a string slice that lives "forever" - string literals do. The 'static is a lifetime. Don't sweat the syntax now.)
Enums with data¶
This is where Rust's enums shine. Each variant can carry different data:
enum Shape {
Circle(f64), // a radius
Square(f64), // a side
Rectangle { width: f64, height: f64 }, // named fields
Empty, // no data
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Square(side) => side * side,
Shape::Rectangle { width, height } => width * height,
Shape::Empty => 0.0,
}
}
fn main() {
let shapes = [
Shape::Circle(2.0),
Shape::Square(3.0),
Shape::Rectangle { width: 4.0, height: 5.0 },
Shape::Empty,
];
for s in &shapes {
println!("{}", area(s));
}
}
The match arms destructure the variants - binding the inner data to names you can use. Powerful and beautiful.
You also see this in Java's pattern-matching switch (page 08 of Java from Scratch), but Java only got it recently; in Rust it's been the way to model "one of several shapes" since day one.
Option<T>: the built-in nullable¶
Rust doesn't have null. Instead, it has Option<T> - a built-in enum:
A function that "might or might not return a value" returns Option<T>:
fn find(list: &[i32], target: i32) -> Option<usize> {
for (i, &x) in list.iter().enumerate() {
if x == target { return Some(i); }
}
None
}
fn main() {
let nums = [10, 20, 30, 40];
match find(&nums, 30) {
Some(i) => println!("found at {i}"),
None => println!("not found"),
}
}
The compiler forces you to handle the None case - if you forget, the match isn't exhaustive and you get a compile error. No more null pointer exceptions.
Option is used everywhere in Rust standard library - anywhere a value might be absent.
A complete example¶
struct Account {
owner: String,
balance: f64,
}
impl Account {
fn new(owner: String, initial: f64) -> Account {
Account { owner, balance: initial }
}
fn deposit(&mut self, amount: f64) {
self.balance += amount;
}
fn withdraw(&mut self, amount: f64) -> Result<(), String> {
if amount > self.balance {
Err(format!("insufficient: have {}, want {}", self.balance, amount))
} else {
self.balance -= amount;
Ok(())
}
}
}
Don't worry about Result<(), String> - that's error handling, page 08. The pattern of fields + impl + methods is what you should recognize.
Exercise¶
In your project, replace src/main.rs:
-
Define
struct Rectanglewithwidth: f64andheight: f64. -
In an
implblock, add: fn new(width: f64, height: f64) -> Rectangle(constructor).fn area(&self) -> f64.fn perimeter(&self) -> f64.-
fn scale(&self, factor: f64) -> Rectangle(returns a new Rectangle). -
In
main, create aRectangle::new(5.0, 3.0). Print area, perimeter. Then create a scaled version (factor 2) and print its area (should be 60). -
Stretch: define
enum Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64) }. Write a functionfn area(s: &Shape) -> f64usingmatch. Test with an array of three shapes.
What you might wonder¶
"Why so many ways to define types (struct, tuple struct, unit struct, enum)?" Because different kinds of data want different shapes. Named-field structs for most things, tuple structs for newtypes, enums for "one of several variants." The variety means you can pick the right one for each case.
"What's &'static?"
A lifetime. &'static str means "a string slice that lives for the entire program." String literals are &'static str. We don't cover lifetimes deeply here; recognize and skim past.
"How is enum-with-data different from inheritance?" Inheritance: "Dog is an Animal; you have an Animal, it's secretly a Dog or Cat." Enums: "Shape IS one of {Circle, Square, Rectangle}; the compiler knows the full list and forces you to handle every case." Enums are closed and exhaustive; inheritance is open. Rust uses enums where other languages use inheritance.
"Why no nulls?"
The most-quoted decision of the language. Tony Hoare called null "my billion-dollar mistake." Rust replaces null with Option<T> - the absence is in the type, the compiler forces handling. Net effect: NullPointerException doesn't exist.
Done¶
- Define structs with named fields, tuple structs, unit structs.
- Attach methods via
implblocks (&self,&mut self). - Define enums - simple and with data.
- Use
matchto destructure enum variants. - Recognize
Option<T>as Rust's null-replacement.
Now you have basic data modeling. Next page is the Rust page: ownership and borrowing.
Next: Ownership and borrowing →
06 - Ownership and Borrowing¶
What this session is¶
This is the Rust page. Plan to spend more time here. It's not actually hard once you see the model; the trouble is that no other mainstream language works this way, so the rules feel arbitrary until they click. Read this page twice if you need to.
The payoff: every error message that confused you in chapter 02 about strings starts making sense, and you'll be able to read Rust function signatures.
The problem ownership solves¶
In C and C++, you manage memory by hand. You allocate, you free. Easy to forget; easy to double-free; easy to use-after-free. Many bugs and security vulnerabilities.
In Python, Java, Go, you have a garbage collector. The runtime tracks references and frees memory when nothing points at it anymore. Easy on the programmer; expensive in some cases (pause times, memory overhead).
Rust does neither. The compiler tracks at compile time, via a small set of rules, when each value should be freed. No garbage collector; no manual free. This is ownership.
The three rules¶
Every value in Rust has an owner - a variable. The rules:
- Each value has exactly one owner.
- When the owner goes out of scope, the value is dropped (freed).
- Values can be moved or borrowed, but the ownership rules are checked at compile time.
That's it. Most of the complexity is corollaries of these three rules.
Drop happens at the end of scope¶
fn main() {
{
let s = String::from("hello");
println!("{s}");
} // s goes out of scope here; the String's memory is freed
}
s owns the String. At the closing } of s's scope, Rust automatically calls a destructor that frees the heap memory. No free(). No delete. No garbage collector.
This is automatic memory management without a runtime cost. You don't manage memory; the compiler does, deterministically.
Move semantics: assignment can transfer ownership¶
let s1 = String::from("hello");
let s2 = s1; // ownership of the String MOVES to s2
println!("{s2}"); // OK
println!("{s1}"); // COMPILE ERROR: borrow of moved value: `s1`
When you assign s2 = s1, the value (the String) is moved to s2. s1 is no longer valid - using it is a compile error.
This is rule 1: each value has one owner. After let s2 = s1;, the owner is s2. The compiler tracks this.
Why? If both s1 and s2 could access the String, freeing it would be ambiguous - when do you free? In C++ this is the source of double-free bugs. Rust avoids it by transferring ownership unambiguously.
Some types are Copy: assignment copies, doesn't move¶
Primitives are cheap to copy and don't allocate, so they implement the Copy trait. Assignment copies them; the original stays valid:
All the primitive number types, bool, char, and tuples of Copy types are Copy. String, Vec, your structs (by default) are not Copy - they move.
When you read let y = x;, ask: "is x a Copy type?" If yes, x is still valid. If no, ownership moved to y and x is gone.
Functions move (or copy) their arguments¶
fn take_string(s: String) {
println!("{s}");
} // s dropped here
fn main() {
let s = String::from("hello");
take_string(s);
println!("{s}"); // COMPILE ERROR: borrow of moved value
}
When you pass s to take_string, ownership moves into the function. After the call, s is gone - you can't use it. Inside the function, s is dropped at the end of the function's scope.
If you want to use the value after the call, you have two options:
-
Return ownership. The function takes the value and gives it back:
Awkward. Almost nobody does this. -
Borrow instead of taking ownership. This is the everyday answer.
Borrowing: references¶
A reference is a way to "use this value temporarily without taking ownership." Mark a parameter with & to borrow:
fn print_it(s: &String) {
println!("{s}");
} // s here is a reference; the String it points to is NOT dropped
fn main() {
let s = String::from("hello");
print_it(&s); // pass a reference
println!("{s}"); // s is still valid here!
}
&s creates a reference to the String - a temporary pointer that doesn't transfer ownership. The function works with the reference; the String remains owned by main.
This is borrowing. Read the syntax:
- &T - an immutable reference to a T.
- &mut T - a mutable reference (can modify the borrowed value).
The borrowing rules (also three)¶
Rust enforces these at compile time:
- At any given time, you can have either:
- One mutable reference (
&mut T), or - Any number of immutable references (
&T). - References must always be valid (the value they point to must outlive them - no dangling pointers).
- (Variant: mutable references and immutable references can't coexist for the same value at the same time.)
These rules prevent data races at compile time. If two parts of your code could simultaneously modify the same value, they'd need two mutable references - which is forbidden. Therefore: no data races in safe Rust.
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1}, {r2}"); // OK - multiple immutable references
let r3 = &mut s; // COMPILE ERROR if r1 or r2 are used after this point
The "after this point" is important - Rust looks at where each reference is last used. If r1 and r2 are no longer used by the time r3 is created, you're fine.
Mutable references¶
fn append_world(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
append_world(&mut s);
println!("{s}"); // hello, world
}
The function takes &mut String - a mutable reference. The caller passes &mut s. Two muts: the variable is mutable (let mut s), and the reference is mutable (&mut s).
You cannot have two &mut to the same value at the same time:
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // COMPILE ERROR
println!("{r1}, {r2}");
This prevents two parts of your code from racing on the same mutation.
&str and String revisited¶
Now &str makes sense: it's a borrowed reference to text owned by something else. The "something else" is usually:
- A string literal (
"hello"), which lives in the program's static memory. - A
String(you can borrow a&strview of anyString).
let owned: String = String::from("hello");
let slice: &str = &owned; // borrow a &str view of the String
println!("{slice}");
&str is shorter to type, doesn't transfer ownership, lets functions accept either literals or strings. Most function parameters that take "some text" use &str.
Function parameters: what to choose¶
A rule of thumb:
&strwhen you just want to read text (most common).&String- almost never. Use&strinstead;Stringcoerces to&strautomatically.Stringwhen you need to take ownership (rare). E.g., a function that stores the string somewhere.&mut Stringwhen you need to mutate the caller's string in place.
Same pattern for other types: &T to read; &mut T to mutate; T to take ownership.
Lifetimes (brief preview)¶
Every reference has a lifetime - how long it's valid. Most of the time the compiler infers them. Occasionally you see explicit lifetime annotations:
'a is a lifetime parameter. It says: "the returned reference lives as long as both inputs." We're not going deeper here. Recognize the syntax ('a, &'a T); skim past in real code; learn properly when you actually need to write a lifetime annotation (which is rare - usually only when writing libraries).
Errors the borrow checker will give you¶
You will see these. They're not bugs; they're the compiler doing its job.
"borrow of moved value" - you used a value after moving it. Fix: clone (.clone()), or borrow instead of moving.
"cannot borrow as mutable because it is also borrowed as immutable" - you have an & reference live while trying to make &mut. Fix: scope the immutable borrow so it ends first.
"cannot borrow as mutable more than once at a time" - two &mut to the same value at the same time. Fix: scope one to end before the other starts.
"borrowed value does not live long enough" - you're trying to use a reference after the thing it points to is gone. Fix: ensure the owner outlives the reference.
When you see these, don't fight. Read the message. Often the compiler suggests the fix. Often the fix reveals a real bug in your thinking about the code.
The escape hatch: .clone()¶
When you really need to keep two owners, you can clone (deep copy):
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{s1}, {s2}"); // both valid - two separate Strings
Clones cost - they allocate. Use sparingly. Borrowing (&) is almost always better.
Exercise¶
This page's exercise is more reading than coding. The point is to internalize, not invent.
- In a new file
src/main.rs:
fn main() {
let s = String::from("hello");
takes_ownership(s);
println!("{s}"); // does this compile? if not, why?
}
fn takes_ownership(s: String) {
println!("{s}");
}
Predict whether it compiles. Run cargo check. Read any error. Now change takes_ownership(s) to takes_ownership(&s) and the function signature to fn takes_ownership(s: &String). Re-check.
- Try this:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s;
println!("{r1}");
println!("{r2}");
}
Predict. Run. Read the error.
- Now reorder:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
println!("{r1}"); // r1's last use
let r2 = &mut s;
r2.push_str(", world");
println!("{r2}");
}
Predict. Run.
- Write a function
fn add_exclamation(s: &mut String)that appends"!"to its argument. Frommain, create a mutable String, call the function, print the result.
What you might wonder¶
"This is a lot." It is. Take a break. Come back tomorrow and re-read.
"Why not just use a garbage collector like Go or Java?" GC is convenient but has costs: pause times (small but noticeable), memory overhead, and you can't run it in environments where GC is unacceptable (kernel modules, embedded, real-time). Rust gets memory safety without those costs.
"Won't I just clone everything to avoid the borrow checker?" You'll be tempted. Resist. The borrow checker is telling you something - often that your code has a structure problem (sharing where it shouldn't). Clone when the alternative is genuinely worse; don't clone to avoid thinking about ownership.
"Can I have shared mutable state at all?"
Yes, via Rc<RefCell<T>> (single-threaded) or Arc<Mutex<T>> (multi-threaded). These "interior mutability" types let you sidestep the rules with runtime checks. We're not covering them here; recognize when you see them in real code.
"Will I really stop getting borrow-checker errors?" Mostly. After a few weeks, your mental model adjusts and you start writing code that compiles first try. Occasionally a tough case still trips you; that's normal even for experts.
Done¶
You can:
- Explain ownership: each value has one owner; drops at end of scope.
- Distinguish moves (non-Copy types) from copies (Copy types).
- Use &T for immutable borrows and &mut T for mutable.
- Recall the borrowing rules: one &mut XOR many &, no dangling.
- Understand &str as a borrowed view, String as an owned value.
- Recognize lifetime annotations.
- Read borrow-checker errors without panic.
You've crossed the worst hill in the Rust learning curve. The rest of the path doesn't have a peak this steep.
07 - Collections¶
What this session is¶
About an hour. You'll meet Rust's standard collections - Vec (growable list), HashMap, slices - and revisit &str vs String with what you learned about ownership.
Vec<T>: growable list¶
fn main() {
let mut nums: Vec<i32> = Vec::new();
nums.push(1);
nums.push(2);
nums.push(3);
println!("{:?}", nums); // [1, 2, 3]
let first = nums[0];
println!("{first}"); // 1
println!("len = {}", nums.len());
}
Notes:
- Vec<i32> - generic: vector of i32s. <T> is the type parameter.
- Vec::new() - empty vector.
- .push(value) - append. The vector must be mut to push.
- nums[0] - index. Panics if out of bounds. Use nums.get(0) for Option<&T>-returning safe access.
- {:?} (debug print) for vectors - {} doesn't work for collections by default.
The vec! macro is shorthand for "create with these initial values":
You'll use vec! constantly.
Iterating a Vec¶
The &nums matters - without &, the for loop would consume (take ownership of) nums, and you couldn't use it afterward.
To iterate with the index:
Slices: &[T]¶
A slice is a reference to a contiguous range of elements. Same idea as &str (which is a slice of bytes).
let nums = vec![10, 20, 30, 40, 50];
let middle: &[i32] = &nums[1..4];
println!("{:?}", middle); // [20, 30, 40]
nums[1..4] is a slice from index 1 (inclusive) to 4 (exclusive). The & borrows it.
Slices are super common as function parameters:
fn sum(items: &[i32]) -> i32 {
let mut total = 0;
for n in items {
total += n;
}
total
}
fn main() {
let v = vec![1, 2, 3, 4, 5];
println!("{}", sum(&v)); // works - &Vec coerces to &[i32]
println!("{}", sum(&[10, 20])); // works - array reference
}
Take &[T], not &Vec<T>, when you only need to read. More flexible - accepts both Vecs and arrays.
HashMap<K, V>¶
use std::collections::HashMap;
fn main() {
let mut ages: HashMap<String, u32> = HashMap::new();
ages.insert(String::from("Alice"), 30);
ages.insert(String::from("Bob"), 25);
if let Some(&age) = ages.get("Alice") {
println!("Alice is {age}");
}
println!("entries: {}", ages.len());
}
Notes:
- use std::collections::HashMap; - bring it in. Not in the prelude (unlike Vec).
- HashMap<K, V> - generic over key and value types.
- .insert(k, v) - add or update.
- .get(k) - returns Option<&V>. The if let Some(&age) = ... pattern destructures it; the &age copies out (since u32 is Copy).
Iterate:
Update or insert pattern:
// Count occurrences:
let text = "the quick brown fox jumps over the lazy dog the end";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
for (word, count) in &counts {
println!("{word}: {count}");
}
entry(word).or_insert(0) - "get a mutable reference to the entry for word, inserting 0 if it didn't exist." The * dereferences to get the value, then += 1 mutates. Idiomatic counting.
&str vs String, revisited with ownership¶
Now that you know about ownership:
let owned: String = String::from("hello");
let borrowed: &str = &owned; // borrow a &str view
fn takes_owned(s: String) { ... } // takes ownership
fn takes_borrowed(s: &str) { ... } // just borrows
Use String when:
- You need to grow / mutate the string.
- You need to own (store, return) the string.
Use &str when:
- You just need to read.
- You want maximum flexibility (works for both literals and Strings).
For function parameters, default to &str. For return types, often String (because you're returning newly-built data the caller will own).
Other collections (briefly)¶
You'll meet these in real code; named for recognition:
HashSet<T>- set of unique values. Usestd::collections::HashSet.BTreeMap<K, V>/BTreeSet<T>- sorted map/set. Slower access but ordered iteration.VecDeque<T>- double-ended queue. Fast push/pop at both ends.LinkedList<T>- almost never the right choice.
For 95% of work, Vec, HashMap, HashSet cover you.
Common iterator patterns¶
Iterators are central in Rust. Three patterns you'll use constantly:
Map (transform every element):
let nums = vec![1, 2, 3, 4];
let squares: Vec<i32> = nums.iter().map(|n| n * n).collect();
println!("{:?}", squares); // [1, 4, 9, 16]
|n| n * n is a closure (anonymous function). .collect() consumes the iterator and builds a collection (Rust infers Vec<i32> from the type annotation).
Filter (keep matching elements):
let nums = vec![1, 2, 3, 4, 5, 6];
let evens: Vec<i32> = nums.iter().filter(|&&n| n % 2 == 0).copied().collect();
println!("{:?}", evens); // [2, 4, 6]
The &&n is "deref twice" - filter gives &&i32, we want i32. The .copied() then unwraps &i32 to i32 (because i32 is Copy). Looks fiddly; you'll learn the patterns.
Sum / count / etc.:
The iterator zoo is large; reach for these and look up the rest when you need them.
Exercise¶
Replace src/main.rs:
Write a word-count program:
- Hardcode the sentence:
"the quick brown fox jumps over the lazy dog the end". - Split into words with
.split_whitespace(). - Build a
HashMap<&str, u32>of counts using theentry().or_insert()pattern. - Print each word and its count.
Expected output (order varies):
Stretch: sort the entries by count (descending) and print. Convert the HashMap to a Vec<(&str, u32)> and sort:
What you might wonder¶
"Why so many &s in iterator chains?"
Iterators give you references by default (avoiding moves). You often need to deref them. After a while it becomes pattern recognition.
"Why {:?} for vectors?"
The {} placeholder uses the Display trait, which most collections don't implement (because there's no obvious single way to "display" a list). {:?} uses Debug, which prints [1, 2, 3]. For your own types: #[derive(Debug)] above the type to get {:?} for free.
"What's a closure (|x| x * 2)?"
An anonymous function. |params| body. Captures variables from the enclosing scope. Like lambdas in other languages.
"Are HashMap keys ordered?"
No (it's a hash map). Use BTreeMap for sorted keys. Iteration order on HashMap is also randomized (per-run) for security.
Done¶
- Use
Vec<T>for growable lists; iterate withfor n in &v. - Use
&[T]slices as function parameters when you just need to read. - Use
HashMap<K, V>for key-value lookups. - Recognize the
entry().or_insert()idiom for counting. - Use
.iter().map(...).collect()and.iter().filter(...).collect().
08 - Error Handling¶
What this session is¶
About an hour. You'll learn Rust's Result<T, E> type, the ? operator, the distinction between recoverable failures (errors) and unrecoverable bugs (panics), and how to build your own error types.
Rust's error story is more like Go's "errors as values" than Java's "throw an exception" - and the type system enforces it.
Result<T, E>: success or failure¶
Rust's built-in enum for fallible operations:
T is the success type, E is the error type. A function that might fail returns Result<T, E>. The caller must handle both cases.
Result is everywhere in the standard library:
use std::num::ParseIntError;
fn parse_age(s: &str) -> Result<u32, ParseIntError> {
s.parse::<u32>() // returns Result<u32, ParseIntError>
}
fn main() {
match parse_age("30") {
Ok(n) => println!("got {n}"),
Err(e) => println!("error: {e}"),
}
match parse_age("hello") {
Ok(n) => println!("got {n}"),
Err(e) => println!("error: {e}"),
}
}
Output:
The compiler forces you to handle the Err case via match (or via methods like .unwrap(), .expect(), .ok() - see below). You can't accidentally ignore the failure.
Quick (and risky) ways to extract a value¶
When you're prototyping:
let n: u32 = "30".parse().unwrap(); // crash on error
let n: u32 = "30".parse().expect("bad age"); // crash with message
.unwrap()- returns theOkvalue; panics onErr..expect("msg")- same, but panics with the given message.
Use these in prototypes, tests, and "this can't fail and if it does the program is broken" situations. In production code, you should usually handle the error properly.
The ? operator: propagating errors¶
Writing nested matches to propagate errors is tedious:
fn double_age(s: &str) -> Result<u32, ParseIntError> {
match s.parse::<u32>() {
Ok(n) => Ok(n * 2),
Err(e) => Err(e),
}
}
The ? operator does the same, much shorter:
fn double_age(s: &str) -> Result<u32, ParseIntError> {
let n = s.parse::<u32>()?; // if Err, return the error
Ok(n * 2) // otherwise continue
}
The ? reads: "if this is Ok, give me the value; if Err, return the Err from the enclosing function." Two characters that change everything.
Chain them:
fn process(input: &str) -> Result<u32, ParseIntError> {
let n = input.trim().parse::<u32>()?;
let doubled = (n * 2).to_string().parse::<u32>()?;
Ok(doubled)
}
Each ? is "early-return on error." Clean.
The ? operator works on Result (returning Err to the caller) and on Option (returning None). The function's return type has to be the right shape.
panic!: when the program is fundamentally broken¶
For situations that should never happen - bugs, invariant violations - use panic!:
Panic unwinds the stack and aborts the program. Use sparingly - for "this represents a bug in my code, the only sane response is to crash with diagnostics."
unwrap and expect panic on Err. Index-out-of-bounds (vec[100]) panics. Division by zero on integers panics. These are conditions you should program around in production code.
Custom error types¶
For a real application, define your own error type - usually an enum:
#[derive(Debug)]
enum BankError {
InsufficientFunds { balance: f64, requested: f64 },
NegativeAmount(f64),
AccountClosed,
}
fn withdraw(balance: f64, amount: f64) -> Result<f64, BankError> {
if amount <= 0.0 {
return Err(BankError::NegativeAmount(amount));
}
if amount > balance {
return Err(BankError::InsufficientFunds {
balance,
requested: amount,
});
}
Ok(balance - amount)
}
fn main() {
match withdraw(100.0, 50.0) {
Ok(new_balance) => println!("new balance: {new_balance}"),
Err(e) => println!("error: {e:?}"),
}
match withdraw(100.0, 200.0) {
Ok(new_balance) => println!("new balance: {new_balance}"),
Err(e) => println!("error: {e:?}"),
}
}
The #[derive(Debug)] on the enum auto-generates the Debug trait so {e:?} works. We'll meet derive in page 09.
For a complete error type that integrates with the standard library, you'd implement Display and std::error::Error. Most projects use the thiserror crate to derive these automatically - we'll meet crates in page 11.
A real example: read a config file¶
use std::fs;
use std::io;
fn load_config(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // ? propagates IO errors
Ok(contents.trim().to_string())
}
fn main() {
match load_config("config.txt") {
Ok(cfg) => println!("loaded: {cfg}"),
Err(e) => println!("config load failed: {e}"),
}
}
fs::read_to_string returns Result<String, io::Error>. The ? propagates the error if it happens. Clean.
When to use what¶
unwrap/expect- prototypes, tests, "impossible in this context" cases.match- when you want to handle both Ok and Err inline with different logic.?- when you want to propagate the error to the caller (most function bodies).Result<T, E>with custom E - when your function can fail in distinct ways.panic!- when a programming invariant is violated.
The full Rust error-handling story (custom Error trait, error contexts via anyhow/thiserror) gets richer. For now, these patterns cover 90%.
Exercise¶
Replace src/main.rs:
Write a function fn parse_positive(s: &str) -> Result<u32, String>:
- Trim the input. If it's empty, return
Err("empty input".to_string()). - Parse as
u32. If parsing fails, returnErr(format!("not a number: {s}")). - If the number is 0, return
Err("must be > 0".to_string()). - Otherwise return
Ok(n).
In main, loop over these inputs:
For each, call parse_positive and print success or error.
Stretch: define a #[derive(Debug)] enum ParseError { Empty, NotNumber(String), Zero }. Change parse_positive to return Result<u32, ParseError>. Use {:?} to print the error.
Bonus stretch: add a fn sum_inputs(inputs: &[&str]) -> Result<u32, ParseError> that parses all of them and returns the sum, using ? to short-circuit on the first error.
What you might wonder¶
"Why no exceptions?"
Exceptions hide control flow - a function call may secretly jump 10 frames up the stack. Rust's design forces every failure path into the type system. The cost: more boilerplate (lots of ?). The benefit: every failure is visible at the call site; the compiler enforces handling.
"What about runtime errors like array index out of bounds?"
Those panic. Panics are for "this code is broken" situations, not "expected failures." For safe indexing, use .get(i) which returns Option<&T>.
"Can ? propagate between different error types?"
Yes, via the From trait. If your function returns Result<T, MyError> and you call a function returning Result<T, IoError>, ? automatically calls MyError::from(io_error) if the conversion is defined. We're not covering trait implementation here; recognize when you see it.
"What about anyhow and thiserror?"
The two popular error-handling crates. anyhow::Result<T> is a flexible Result type for applications ("any error can happen, just give me a string"). thiserror derives Error for your enums in libraries. Real Rust code uses one or both. We'll mention them again in page 11.
Done¶
- Recognize
Result<T, E>as Rust's failure type. - Handle errors with
match,unwrap,expect, or?. - Propagate errors compactly with
?. - Define custom error enums.
- Distinguish recoverable errors from panics.
09 - Traits and Generics¶
What this session is¶
About an hour. You'll learn traits (Rust's interface system) and generics (type parameters). Together they give Rust most of what other languages get from inheritance, but more flexibly.
The problem traits solve¶
In object-oriented languages you'd say "Cat IS-A Animal, Dog IS-A Animal, both have a speak() method." Rust doesn't have inheritance. Instead, it has traits - shared behavior defined as a contract.
trait Animal {
fn speak(&self) -> String;
}
struct Cat { name: String }
struct Dog { name: String }
impl Animal for Cat {
fn speak(&self) -> String {
format!("{} says meow", self.name)
}
}
impl Animal for Dog {
fn speak(&self) -> String {
format!("{} says woof", self.name)
}
}
fn main() {
let c = Cat { name: String::from("Whiskers") };
let d = Dog { name: String::from("Rex") };
println!("{}", c.speak());
println!("{}", d.speak());
}
What's new:
- trait Animal { ... } declares the trait - a set of methods (with signatures, optionally with default bodies).
- impl Animal for Cat { ... } provides the methods for Cat. Read as "implement Animal for Cat."
- Any type with the Animal impl can be treated as an Animal.
You can have multiple traits for the same type (a struct can implement many traits). You can have one trait implemented for many types. This combinatorial freedom replaces inheritance.
Default method implementations¶
Traits can include default implementations:
trait Greeter {
fn name(&self) -> String;
fn greet(&self) -> String {
format!("Hello, {}", self.name())
}
}
greet has a default. Types implementing Greeter only need to provide name; they get greet for free (or can override it).
Functions taking traits¶
Two ways to write "a function that takes anything implementing trait X":
Way 1 - impl Trait:
Way 2 - generic parameter with trait bound:
Both compile to the same thing. The impl Trait form is shorter; the generic form lets you reuse T if mentioned twice.
For taking references to anything implementing a trait:
(Or use trait objects - see "dynamic dispatch" below.)
Generic functions¶
A function that works on any type:
fn largest<T: PartialOrd>(items: &[T]) -> &T {
let mut largest = &items[0];
for item in items {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let nums = vec![10, 25, 3, 99, 42];
println!("{}", largest(&nums)); // 99
let words = vec!["pear", "apple", "banana"];
println!("{}", largest(&words)); // pear (alphabetical)
}
<T: PartialOrd> says: "T is any type that implements PartialOrd" (the trait for <, >, etc.). Inside the function, > works because we required it.
Without the bound, the compiler wouldn't let you use > on T - it doesn't know yet whether T can be compared.
Generic structs¶
You met Vec<T> and HashMap<K, V>. You can write your own:
struct Pair<A, B> {
first: A,
second: B,
}
impl<A, B> Pair<A, B> {
fn new(a: A, b: B) -> Pair<A, B> {
Pair { first: a, second: b }
}
}
fn main() {
let p: Pair<&str, i32> = Pair::new("alice", 30);
println!("{}, {}", p.first, p.second);
}
The <A, B> after impl introduces the type parameters again - needed because the impl block must know what A and B are.
Common standard traits¶
You'll see these often:
| Trait | What it means |
|---|---|
Debug |
type can be printed with {:?} (derivable via #[derive(Debug)]) |
Display |
type can be printed with {} (implement manually) |
Clone |
type can be .clone()d |
Copy |
type can be copied (primitives, etc.) |
PartialEq / Eq |
type supports == |
PartialOrd / Ord |
type supports <, > |
Hash |
type can be a HashMap key |
Default |
type has a default value (T::default()) |
Iterator |
type can be iterated |
The #[derive(...)] attribute auto-implements common traits for your types:
That gives you {:?} printing, .clone(), and == for free. Most structs derive Debug at minimum.
Trait objects: dyn Trait¶
When you want a heterogeneous collection (e.g., a Vec of different types that all implement the same trait), use a trait object:
trait Animal {
fn speak(&self) -> String;
}
// ... Cat, Dog impls ...
fn main() {
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Cat { name: String::from("Whiskers") }),
Box::new(Dog { name: String::from("Rex") }),
];
for a in &animals {
println!("{}", a.speak());
}
}
Box<dyn Animal>- "a heap-allocated something-that-implements-Animal." Thedynkeyword signals dynamic dispatch (the actual method gets resolved at runtime via a vtable, like Java/C++ virtual methods).Box::new(...)puts the value on the heap.
The generic impl Trait form (above) uses static dispatch - the compiler generates a specialized version per concrete type. Faster but produces more code. dyn Trait uses dynamic dispatch - one version, with a small per-call cost.
Rule of thumb: prefer impl Trait when you can; use dyn Trait when you need a heterogeneous collection or to return a value whose type varies.
Lifetimes meet generics (preview)¶
Generic functions returning references need lifetime annotations:
'a is a lifetime parameter. We met this in page 06. Most of the time the compiler infers them; occasionally you write them. Recognize the shape; learn deeply when you encounter a need.
Exercise¶
Replace src/main.rs:
-
Define a trait
Shapewith one required method:fn area(&self) -> f64. -
Define structs
Circle { radius: f64 },Square { side: f64 },Rectangle { width: f64, height: f64 }.#[derive(Debug)]on each. -
Implement
Shapefor each. -
Write a function
fn total_area(shapes: &[Box<dyn Shape>]) -> f64that sums all areas. -
In
main, create a heterogeneousVec<Box<dyn Shape>>with one of each, print each shape with{:?}, and print the total area.
Expected output (numbers may have more decimals):
Circle { radius: 2.0 } area=12.566370614359172
Square { side: 3.0 } area=9.0
Rectangle { width: 4.0, height: 5.0 } area=20.0
total: 41.57
Stretch: define a generic function fn max_by_area<T: Shape>(shapes: &[T]) -> &T that returns the shape with the largest area. Test with a Vec<Circle>.
What you might wonder¶
"Why traits instead of inheritance?" Inheritance forces a hierarchy and tight coupling. A Dog must commit to being-an-Animal at definition time. Traits let any type implement any set of traits in any order. More flexible; fewer accidental dependencies.
"What about default fields, multiple inheritance, etc.?" Rust doesn't have those. Composition (one struct having another as a field) plus traits is the answer.
"Why is Vec<Box<dyn Animal>> so wordy?"
Each part has a purpose: Vec<...> is the list; Box<...> is "owned heap pointer" (needed because trait objects don't have known size); dyn Animal is the trait object. You'll get used to it; it's the cost of explicit memory and dispatch decisions.
"What's the cost of #[derive(Clone)] vs writing it?"
None. Derive generates the same code you'd write. Use it.
Done¶
- Define traits and implement them for types.
- Use trait bounds on generic parameters.
- Use
impl Traitfor function parameters. - Use
Box<dyn Trait>for heterogeneous collections. - Derive
Debug,Clone,PartialEq, etc. with#[derive(...)].
You've now covered the major language features. Next pages: testing, packaging, reading code, contributing.
10 - Tests¶
What this session is¶
About 45 minutes. You'll learn how to write tests in Rust. Like nearly all Rust tooling, the test framework is built into the toolchain - cargo test and you're going. No frameworks to install.
The simplest test¶
In any module, mark a function #[test]:
Run:
You should see:
#[test] marks the function as a test. cargo test auto-discovers and runs them. assert_eq!(actual, expected) checks for equality and panics with a useful message on failure.
Convention: tests in a #[cfg(test)] module¶
Convention: put unit tests at the bottom of the same file as the code, wrapped in a tests module:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn is_even(n: i32) -> bool {
n % 2 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn detects_even() {
assert!(is_even(4));
assert!(!is_even(7));
}
}
What's new:
- #[cfg(test)] - only compile this module when running tests. Keeps test code out of release builds.
- mod tests { ... } - a module named tests.
- use super::*; - bring all of the parent module's items into the test module's scope.
- assert!(condition) - passes if condition is true; panics otherwise.
This is the idiomatic shape. Real Rust libraries put unit tests right next to the code.
Useful assertions¶
assert!(cond)- true.assert_eq!(a, b)- equal.assert_ne!(a, b)- not equal.
With custom messages:
For tests on Result-returning functions:
#[test]
fn parse_works() -> Result<(), Box<dyn std::error::Error>> {
let n: i32 = "42".parse()?;
assert_eq!(n, 42);
Ok(())
}
If you return Result, you can use ? inside the test. Convenient.
Watching a test fail (do this)¶
In your project, change add to a - b. Run cargo test. You should see:
test tests::adds_two_numbers ... FAILED
failures:
---- tests::adds_two_numbers stdout ----
thread 'tests::adds_two_numbers' panicked at src/lib.rs:13:9:
assertion `left == right` failed
left: -1
right: 5
The failure tells you: what was actual (-1), what was expected (5), the line. Restore add; tests pass again.
Testing panics: #[should_panic]¶
For tests that the code should panic in:
expected = "..." requires the panic message to contain that substring. Useful for testing invariant checks.
Testing Results¶
For functions returning Result, two patterns:
#[test]
fn parse_succeeds() {
let r = "42".parse::<i32>();
assert!(r.is_ok());
assert_eq!(r.unwrap(), 42);
}
#[test]
fn parse_fails_on_garbage() {
assert!("hello".parse::<i32>().is_err());
}
Or the Result-returning test from above (cleaner when you want to chain ?s).
Parameterized tests: not built in¶
Unlike Java's @ParameterizedTest, Rust doesn't have built-in parameterization. Two common workarounds:
Manual loop:
#[test]
fn is_even_cases() {
let cases = [(0, true), (1, false), (2, true), (-4, true), (-7, false)];
for (n, expected) in cases {
assert_eq!(is_even(n), expected, "is_even({n}) was wrong");
}
}
Simple and clear. Common in real Rust.
The rstest crate provides #[rstest] and #[case(...)] if you want first-class parameterized tests. We'll meet crates in page 11.
Integration tests¶
Unit tests (above) live in the same file as the code they test. Integration tests live in a top-level tests/ directory and test the public API as an external user would:
tests/integration_test.rs:
cargo test runs both unit and integration tests.
Documentation tests (free, easy, wonderful)¶
Code samples in doc comments are also tests:
/// Returns the sum of two integers.
///
/// # Examples
///
/// ```
/// use my_project::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
cargo test compiles and runs every code block in /// comments. Documentation stays accurate because it's tested. One of Rust's killer features.
Useful cargo test flags¶
| Flag | What it does |
|---|---|
cargo test |
Run all tests. |
cargo test -- --nocapture |
Show println! output from passing tests. |
cargo test foo |
Run only tests whose name contains "foo". |
cargo test -- --test-threads=1 |
Run sequentially (default is parallel). |
cargo test --release |
Run with optimizations on (slower compile, runtime matters for some tests). |
Cargo bench (briefly)¶
For performance benchmarks, the stable approach is the criterion crate (we'll mention crates in page 11). Don't try to use cargo bench on stable Rust - it's nightly-only currently.
Exercise¶
In your project:
-
Make
src/lib.rs(instead ofsrc/main.rs, or in addition - Cargo supports both): -
Update
Cargo.tomlif needed - change the[package]to have a library, or leave the binary structure. (Cargo auto-detectslib.rs.) -
Add a
#[cfg(test)] mod testsat the bottom oflib.rswith: - Parameterized test for
word_count(the manual-loop pattern):""→ 0,"hello"→ 1,"hello world"→ 2," many spaces here "→ 3. -
Parameterized test for
is_palindrome:""→ true,"a"→ true,"racecar"→ true,"hello"→ false,"Racecar"→ true. -
Run
cargo test. -
Break each function. Watch the test fail. Fix. Watch it pass.
Stretch: add a doctest above word_count showing how to use it. Run cargo test and confirm the doctest runs.
What you might wonder¶
"Why are tests in the same file as the code?"
Convention. Unit tests live in #[cfg(test)] mod tests at the bottom; integration tests go in tests/. This lets unit tests access private functions (they're in the same module). Integration tests can only see the public API.
"How do I mock external dependencies?"
Rust mocking isn't as automated as in Java/Python. The idioms: design your code with traits at boundaries (so you can substitute a fake impl), or use crates like mockall. Out of scope for beginner; recognize when you encounter it.
"What about coverage?"
cargo install cargo-tarpaulin then cargo tarpaulin. Reports line/branch coverage. Useful but don't chase 100% - chase confidence.
Done¶
- Write tests in
#[cfg(test)] mod testsmodules. - Use
assert!,assert_eq!,assert_ne!. - Use
#[should_panic]for panic tests. - Write doc tests in
///comments. - Distinguish unit tests from integration tests.
11 - Crates and Cargo¶
What this session is¶
About 45 minutes. You'll learn how Rust code is organized - modules (folders/files), crates (one library or binary, like a "project"), and crates.io (the public package registry). You'll add a third-party dependency and use it.
Vocabulary¶
- Package - what
cargo newcreates: a folder withCargo.tomland one or more crates inside. - Crate - either a library (compiles to
.rlib/.so) or a binary (compiles to an executable). A package usually has one crate. - Module - a unit of organization inside a crate. Maps to files and folders.
For most beginners, one package = one crate. We'll keep it that way here.
Single-file projects: organization with mod¶
For a single binary file, you can declare modules inline:
mod math {
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
}
fn main() {
println!("{}", math::add(2, 3));
println!("{}", math::subtract(10, 4));
}
What's new:
- mod math { ... } - declare a module called math.
- pub fn - make the function public (visible outside the module). Without pub, it'd be private to the module.
- math::add(2, 3) - call a function inside the module with ::.
Multi-file modules¶
Once a module gets big, move it to its own file:
In main.rs:
mod math; // declares that there's a module called math; Cargo finds it in math.rs
fn main() {
println!("{}", math::add(2, 3));
}
In math.rs:
mod math; in main.rs is the declaration. Cargo looks for either math.rs or math/mod.rs. Either works.
For multi-level modules:
src/
├── main.rs
└── math/
├── mod.rs (or src/math.rs with src/math/ for submodules)
├── basic.rs
└── advanced.rs
In main.rs: mod math;. In math/mod.rs: pub mod basic; pub mod advanced;.
Library vs binary projects¶
If your project has src/main.rs, Cargo builds a binary. If it has src/lib.rs, Cargo builds a library. You can have both (a binary that uses the library) by having both files.
For a library you intend to publish or share, the convention is src/lib.rs with everything organized into modules under it.
use for imports¶
To avoid typing full paths everywhere, use use:
use std::collections::HashMap;
fn main() {
let mut h = HashMap::new(); // shorter than std::collections::HashMap::new
h.insert("alice", 30);
}
use std::collections::HashMap; brings HashMap into scope. You can use it unqualified within the file.
Multiple imports from the same path:
use std::collections::{HashMap, HashSet, BTreeMap};
use std::io::{self, Read, Write}; // self brings io itself in too
Glob (rarely used):
use is conventional for everything outside the current file. For your own modules:
crates.io: the public registry¶
crates.io hosts the Rust community's open-source libraries. Hundreds of thousands of crates: HTTP clients, serializers, web frameworks, CLI parsers, database drivers, everything.
To use a crate, add it to Cargo.toml:
Run cargo build (or cargo check) and Cargo downloads the crate + all its dependencies, builds them, and links them.
Convention: pin the major version. serde = "1.0" accepts 1.0.x and 1.y.z (semver-compatible).
A real example using serde and serde_json for JSON:
The features = ["derive"] enables an optional feature of the crate (here, the derive macros for serde).
In src/main.rs:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
age: u32,
}
fn main() {
let alice = Person { name: String::from("Alice"), age: 30 };
let json = serde_json::to_string(&alice).unwrap();
println!("{}", json); // {"name":"Alice","age":30}
let parsed: Person = serde_json::from_str(&json).unwrap();
println!("{:?}", parsed); // Person { name: "Alice", age: 30 }
}
#[derive(Serialize, Deserialize)] makes the struct JSON-convertible. Magic - but at compile time. No runtime reflection.
Run with cargo run.
Useful crates to know exist¶
For recognition only - you'll meet these in real code:
| Crate | What it does |
|---|---|
serde, serde_json, serde_yaml |
Serialization |
tokio |
Async runtime |
reqwest |
HTTP client |
axum, actix-web |
Web frameworks |
clap |
Command-line argument parsing |
anyhow, thiserror |
Error handling helpers |
tracing |
Structured logging |
criterion |
Benchmarking |
rayon |
Easy parallelism |
sqlx, diesel |
Database drivers/ORMs |
rstest |
Parameterized tests |
These are the "standard" crates almost every Rust project uses.
Cargo.toml fields¶
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
authors = ["You <you@example.com>"]
description = "A short description"
license = "MIT OR Apache-2.0"
repository = "https://github.com/you/my-app"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
[dev-dependencies]
rstest = "0.23"
[dev-dependencies] - only used for tests, examples, benchmarks. Not in the final binary.
Cargo.lock¶
After cargo build, you'll see a Cargo.lock file. It records exact versions of every dependency (direct + transitive) used for the build.
- For binary projects (apps): commit
Cargo.lock. Ensures reproducible builds across machines. - For library projects (published crates): don't commit
Cargo.lock. Downstream users will have their own.
Useful Cargo commands recap¶
| Command | What it does |
|---|---|
cargo new --bin <name> |
New binary project. |
cargo new --lib <name> |
New library project. |
cargo add <crate> |
Add a dependency (modifies Cargo.toml). |
cargo remove <crate> |
Remove a dependency. |
cargo update |
Update dependencies (Cargo.lock). |
cargo build / cargo build --release |
Compile. |
cargo run |
Build and run. |
cargo check |
Type-check without building. |
cargo test |
Run tests. |
cargo doc --open |
Generate and open docs. |
cargo clippy |
Run the linter. |
cargo fmt |
Auto-format. |
cargo publish |
Publish to crates.io (needs an account). |
Exercise¶
In your project:
-
Add
(Or editserde(withfeatures = ["derive"]) andserde_json:Cargo.tomldirectly.) -
Define a struct
Bookwithtitle: Stringandauthor: Stringandyear: u32. DeriveSerialize,Deserialize,Debug. -
Create a
Book, serialize to JSON, print it. -
Parse the JSON back, print the result.
-
Now do it for a
Vec<Book>of 3 books. -
Stretch: add
clap(cargo add clap --features derive). Make a small CLI that takes a--nameflag and printsHello, <name>. Look up "clap derive" examples.
What you might wonder¶
"Why are there so many crates instead of a big standard library?" Philosophy. Rust's standard library is intentionally small. Most things (HTTP, async, serialization) live in third-party crates. Pro: stdlib stays small, evolves slowly, won't ship buggy features. Con: you have to pick crates for everything.
"How do I pick a crate?"
Check crates.io for stars, downloads, last update. Check the README. Check open issues on GitHub. For well-known categories: serde (JSON), tokio (async), reqwest (HTTP), clap (CLI) are de facto standards.
"Why are some imports serde:: and some std::?"
std is the standard library - always available. serde is a third-party crate you added. Cargo treats them the same syntactically.
"What's a workspace?"
A multi-package project. The top-level Cargo.toml lists member packages (subdirectories), each with their own Cargo.toml. Common in big projects. Not needed for one-package learning.
Done¶
- Organize code into modules with
mod. - Use
pubto control visibility. - Import with
use. - Add dependencies from crates.io via
Cargo.tomlorcargo add. - Recognize the major crates of the Rust ecosystem.
Next: Reading other people's code →
12 - Reading Other People's Code¶
What this session is¶
About 45 minutes. You'll learn the strategy for reading code you didn't write. Same shape as the equivalent page in Go/Java/Python from scratch - adapted for Rust idioms.
The trick¶
When you open a new codebase, don't try to read it linearly. Pick a small thread; follow only it; let the rest stay fuzzy. A first read of a real Rust project takes 5 minutes and produces a one-paragraph mental map; not three hours and confusion.
Five-minute orientation¶
For any Rust project:
-
Read the README. What does this do? If unclear, the project is too unfinished.
-
List the top-level files/directories:
Cargo.toml- manifest.Cargo.lock- pinned versions (for binaries).src/- source.src/lib.rs- library entry point.src/main.rs- binary entry point.src/bin/- multiple binary entries.tests/- integration tests.examples/- runnable examples.benches/- benchmarks (criterion).docs/- long-form docs (some projects).-
.github/workflows/- CI. -
Open
Cargo.toml. Name? Version? Dependencies? Tells you the ecosystem. -
Find the entry point. For a library:
src/lib.rs. Read it - often a list ofpub mod foo;declarations and re-exports. That tells you the public API. -
Read one test or example. Tests show you what the code is supposed to do, with concrete code.
After this: write a one-paragraph summary. If you can't, repeat.
Tools for reading Rust¶
rust-analyzerin your editor - go-to-definition, find-references, inline type display. Use these constantly.cargo doc --open- generate and open the project's documentation in your browser. Sorted, navigable, includes all public APIs.- docs.rs - the public hosted documentation for every published crate. Search "
docs.rs". cargo test --no-run- compiles tests without running them. Useful for "does this build" before diving in.grep -r 'pattern' src/- old-school but works.cargo expand(cargo install cargo-expand) - shows what macros expand to. Useful when you encounter a#[derive(...)]or other macro you don't understand.
A worked example: reading serde_json::Value¶
serde_json::Value is the dynamic JSON value type. Let's say we just encountered it.
cargo doc --open(in a project using serde_json) - search forValue.- Documentation says: "Represents any valid JSON value." Variants:
Null,Bool(bool),Number(Number),String(String),Array(Vec<Value>),Object(Map<String, Value>). - So
Valueis an enum (page 05 of this path!). Read a few of its methods:is_string(),as_str(), etc. - Look at examples in the docs. Most public docs include them.
Five minutes, mental model.
Things you'll see that look scary in Rust¶
- Lifetime annotations (
<'a>,&'a str) - page 06. Most of the time the compiler infers; you see them in library code. For reading: "this reference must live as long as that." - Macros (
println!,vec!,derive!) - the!marks them. Macros expand to code at compile time. For reading: treat as "a function with magic." Box<dyn Trait>- page 09. Heap-allocated trait object.Pin<Box<...>>- used in async. Means "this can't be moved in memory after creation." Don't worry about it unless you're writing async runtimes.asyncandawait- async/await syntax. Used heavily in web/networking. Functions return a "future" that runs when polled by a runtime (Tokio, async-std).unsafe { ... }- escape hatch. The author takes responsibility for memory safety inside. Recognize; skim.impl Traitin return position -fn foo() -> impl Iterator<Item = i32>. Means "returns some type that implements Iterator." The actual type is hidden from callers.Rc<RefCell<T>>/Arc<Mutex<T>>- shared ownership with interior mutability. Used when you need shared mutable state across functions or threads.- Generic chaos:
<T: Iterator<Item = U>, U: Clone>- complex generic bounds. Skim; understand the gist. - Procedural macros (
#[proc_macro_attribute], etc.) - code that generates code. Used by serde, tokio, actix. Recognize; treat as "magic."
You will hit things you don't recognize. Knowing when to skim past vs dig in is the skill.
Reading vs understanding¶
You can read code without deeply understanding why it's shaped that way. A first PR to a project often involves reading 1000 lines, understanding 100, modifying 5. That ratio is normal.
Exercise¶
No coding this time. Reading.
Pick a small Rust project on GitHub:
fdehau/tui-rsor its successorratatui-org/ratatui- terminal UI library. Reasonable size.BurntSushi/ripgrep- fast grep. Bigger but very well-organized.clap-rs/clap- CLI parser. Substantial but excellently documented.- For something tiny:
seanmonstar/num_cpus- one purpose, one function (num_cpus::get()).
Apply orientation:
1. README - what does it do?
2. Layout - what files exist?
3. Cargo.toml - dependencies?
4. Find the entry point. Trace the most-public function for 5 minutes.
5. Read one test or example.
Write a paragraph: what does this do? How is it organized? What surprised you?
What you might wonder¶
"What if I don't understand?" Note, skip, keep going. Often what confused you on day 1 makes sense after a week.
"What about huge projects like rust-analyzer itself?"
Same technique, just more applied. Read the top-level architecture docs first; then pick one component and orient on just that.
Done¶
- Apply five-minute orientation to any Rust project.
- Use rust-analyzer,
cargo doc, docs.rs. - Distinguish reading from understanding.
- Recognize common "looks scary, isn't" patterns.
13 - Picking a Project¶
What this session is¶
About 30 minutes plus your browsing. What makes a Rust project a good first target, plus a list of real candidates.
Why the wrong project burns you out¶
Common path: 1. Pick something you use (Tokio, say, or Diesel). 2. Three hours setting up the dev environment. 3. Find a "good first issue" untouched for months. 4. Two weeks understanding enough to make a change. 5. Submit PR. 6. Three weeks of silence; then "please rebase and address these 15 comments." 7. Quit.
Fix: pick small, responsive projects first.
"Manageable" criteria¶
- Small enough to comprehend. Under 10k LOC of Rust is ideal. Under 50k is doable.
- Active maintainers. PRs reviewed in days, not weeks.
good first issue/help wantedlabels.CONTRIBUTING.mdexists and is readable.cargo testpasses on fresh clone.- You care or are curious about what it does.
10-minute evaluation¶
| Signal | What you want |
|---|---|
| Stars | 100 - 50000 |
| Last commit | Within a month |
| Open PRs | Some, not 200+ |
| Recent PR merge time | Under 14 days |
good first issue count |
At least 5 |
CONTRIBUTING.md |
Yes, readable |
| CI green on main | Yes |
| Code of conduct | Yes |
Candidates (verify state before committing)¶
Tier 1 - very small, very gentle¶
seanmonstar/num_cpus- tiny one-function crate. Almost any contribution is welcomed.Dirkjan/ical-rs- calendar parser. Small surface area.bluss/scopeguard- tiny utility.assert-rs/assert_cmd- small test-helper crate; maintainers responsive.
Tier 2 - small to medium¶
clap-rs/clap- the CLI parser. Big project but excellent labels and very welcoming.uutils/coreutils- Rust reimplementation of GNU coreutils (ls,cat, etc.). Each utility is small; great labeled issues.ratatui-org/ratatui- terminal UI library. Active and welcoming.mockito-rs/mockito- HTTP mocking library.fdehau/tui-rs- predecessor to ratatui; still maintained.serde-rs/serde- the serialization framework. Big but with very labeled work.
Tier 3 - larger, more visible¶
After 1-2 Tier 1-2 contributions:
BurntSushi/ripgrep- fast grep. Substantial; well-organized.alacritty/alacritty- terminal emulator. C-level work in places.tokio-rs/tokio- async runtime. Huge; very welcoming.rust-lang/cargo- Cargo itself. Long review times but very respected to contribute to.
Tier 4 - massive, don't start here¶
rust-lang/rust- the language itself. CLA required (you sign Rust's CLA on first PR). Slow review. Some areas are fine for first contribution (docs, error messages); most isn't.
Finding issues¶
Project's Issues tab → Labels. Filter by:
- good first issue / E-easy / easy (varies by project)
- help wanted
- documentation
Look for: clear description, contained fix, unclaimed, not open for a year.
Comment: "I'd like to take this. Can you confirm it's still wanted?" Wait for response.
What counts as a contribution¶
Small things absolutely count:
- Fix a typo in README or docs.
- Improve a doc comment.
- Add a doctest.
- Improve an error message.
- Add a missing #[derive(Debug)].
- Small bug with clear repro.
First PR's job: get you through the workflow.
Exercise¶
- Browse three Tier 1-2 projects. Run the 10-minute eval. Take notes.
- Pick the most responsive.
- Read its
CONTRIBUTING.md. - Clone: If tests don't pass on fresh clone, consider another.
- Browse
good first issuetickets; pick two candidates. Don't claim yet.
What you might wonder¶
"What if a project requires a CLA?" Big projects (Rust itself, Mozilla projects) require signing one. Usually a one-time click via a bot. Don't let it stop you.
"What if I find a bug but no issue?" File one first. Describe, wait for ack, then offer to fix.
Done¶
- Articulate what makes a Rust project a good first target.
- Run a 10-minute eval.
- Find appropriately-sized issues.
- Avoid common traps.
Next: Anatomy of a Rust OSS repo →
14 - Anatomy of a Rust OSS Repo¶
What this session is¶
About 45 minutes. Walk through the file layout of a typical Rust open-source project, file by file.
Typical Rust project layout¶
.
├── README.md
├── LICENSE
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── Cargo.toml
├── Cargo.lock
├── .gitignore
├── .github/
│ ├── workflows/
│ ├── ISSUE_TEMPLATE/
│ └── PULL_REQUEST_TEMPLATE.md
├── src/
│ ├── lib.rs (library entry; or main.rs for binary)
│ ├── module1.rs
│ ├── module2/
│ │ ├── mod.rs
│ │ └── inner.rs
│ └── bin/ (additional binaries, optional)
│ └── tool.rs
├── tests/
│ └── integration_test.rs
├── benches/
│ └── benchmark.rs (criterion)
├── examples/
│ └── basic.rs
└── target/ (build output, gitignored)
Not every project has every piece. The roles are consistent.
What each piece is for¶
Root-level files¶
README.md- homepage. One-line description; install; smallest example.LICENSE- usuallyMIT OR Apache-2.0(the Rust community standard double license). Sometimes one, sometimes the other.CONTRIBUTING.md- most important for you. Setup, conventions, PR process.CODE_OF_CONDUCT.md- community standards.Cargo.toml- package manifest. Read it: name, version, dependencies.Cargo.lock- pinned dependency versions. Committed for binaries; not for libraries.rust-toolchain.toml(optional) - pin the Rust version.rustupreads this and uses the right toolchain..gitignore- usually just/targetand.idea/.rustfmt.toml- formatter configuration.clippy.toml- linter configuration.
.github/¶
workflows/*.yml- CI. Almost always runscargo fmt --check,cargo clippy,cargo test. Reading the YAML tells you what your PR will be measured against.ISSUE_TEMPLATE/,PULL_REQUEST_TEMPLATE.md- templates.
src/¶
The source code:
src/lib.rs- library entry point. Public modules listed here withpub mod foo;. The shape of this file is the project's public API.src/main.rs- binary entry point (for a binary crate).src/<module>.rsorsrc/<module>/mod.rs- modules.src/bin/<name>.rs- extra binaries. Run withcargo run --bin <name>.
tests/¶
Integration tests. Each .rs file is compiled as a separate crate. They can only access the library's public API (good - keeps tests honest).
benches/¶
Benchmarks. Usually using the criterion crate. Run with cargo bench.
examples/¶
Runnable usage examples. Run with cargo run --example <name>. Read these first when you're trying to understand how to use a library.
target/¶
Build output. JARs, compiled crates, generated artifacts. Always gitignored. Can grow large; safe to cargo clean (re-downloads + rebuilds dependencies).
Conventions you'll meet¶
#![deny(missing_docs)]at the top oflib.rs- fails build if any public item lacks documentation. Common in well-maintained libraries.#![warn(clippy::all)]- enable extra lint warnings.#[macro_use] extern crate foo;- old syntax, predatesuse. Recognize.pub use module::Thing;inlib.rs- re-export. Makescrate::Thingwork even thoughThingis defined in a submodule.
A worked walkthrough: BurntSushi/ripgrep or serde-rs/serde¶
Pick whichever you cloned in page 12. Apply:
- README - what does it do? Examples?
Cargo.toml- module structure ([lib]?[[bin]]?), dependencies.src/lib.rs(ormain.rs) -pub moddeclarations show the public API.tests/- pick one; trace it..github/workflows/- what does CI run?
Five minutes. Mental map.
Conventions in CONTRIBUTING.md¶
Open the file. Look for:
- Setup. Usually
cargo test. - Formatting. Almost always
cargo fmt. Some projects requirecargo fmt --checkto pass. - Linting. Almost always
cargo clippy. Some require zero warnings. - Commit message format. Some require Conventional Commits.
- CHANGELOG. Some require a line under "Unreleased."
- Sign-off / DCO. Rare in Rust OSS; common in some larger projects.
Follow them. Maintainers will be relieved.
CI configuration: what your PR will be measured against¶
Typical Rust CI:
- run: cargo fmt --all -- --check
- run: cargo clippy --all-targets --all-features -- -D warnings
- run: cargo test --all-features
So locally, before pushing, run:
If those pass locally, your PR (probably) passes CI.
Common helper tools¶
rustfmt- auto-formatter. Comes with rustup.cargo fmt.clippy- linter. Comes with rustup.cargo clippy.cargo-deny- checks for forbidden licenses, banned crates, RUSTSEC advisories.cargo-udeps(nightly) - finds unused dependencies.cargo-watch- re-run on file change:cargo watch -x check -x test.
The first two are universal. The others are project-specific.
Exercise¶
Use the project you picked in page 13.
- Clone locally.
- Walk the layout. Map files to categories above.
- Read CONTRIBUTING.md.
- Open one CI workflow YAML. Identify the commands.
- Run them locally: Adjust to what CONTRIBUTING / CI specifies.
- Open the issue you tentatively picked in page 13. Identify the three files most likely involved (use
grepand your guess).
You're ready to make a change.
What you might wonder¶
"What's rust-toolchain.toml?"
Pins the Rust version. Looks like:
rustup reads this and uses that version in this directory. Useful when a project needs nightly or a specific stable.
"What's [workspace]?"
A multi-package project. Top-level Cargo.toml lists members. Each member is its own crate with its own Cargo.toml. Common in big projects (Tokio, Cargo itself).
Done¶
- Recognize typical Rust project layout.
- Locate every common file/folder.
- Read CONTRIBUTING.md and CI workflows.
- Make a confident guess at which files a change will touch.
Next: Your first contribution →
15 - Your First Contribution¶
What this session is¶
The whole thing. We walk through making a real contribution to a real Rust OSS project end to end.
The workflow¶
- Fork on GitHub.
- Clone your fork.
- Add upstream as remote.
- Branch off main.
- Set up dev env, including formatters/linters.
- Change the code; add a test.
- Run formatter + clippy + tests locally.
- Push to your fork; open the PR.
Step 1: Fork¶
GitHub → Fork button (top right). Creates github.com/<you>/<project>.
Step 2: Clone¶
Step 3: Add upstream¶
Later, to update from upstream:
Step 4: Branch¶
Follow project's branch-name convention if any.
Step 5: Set up the dev environment¶
Read CONTRIBUTING.md. Almost always:
That installs deps + runs tests. Confirms baseline is green on your machine. If not, stop and figure out why before changing anything.
Also confirm formatter + linter work:
If the project pins a Rust version via rust-toolchain.toml, rustup will auto-install it on your first cargo command.
Step 6: Make the change¶
- Small. 5-line diff > 500-line diff.
- Focused. One issue per PR.
- Tested. Any code change needs a test. Docs-only doesn't.
Run cargo fmt after every save (or have rust-analyzer do it).
Step 7: Run CI's commands locally¶
Whatever the CI workflow runs, run those exact commands:
cargo fmt --all -- --check # fail if not formatted
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
All green? Push.
Red? Fix locally. Don't push red CI - it's rude.
For projects with --all-features, run with and without to make sure you didn't break a feature-gated path.
Step 8: Commit and push¶
Commit message:
- First line ≤50 chars, imperative.
- Optional body.
- Reference issue: (#123) auto-links.
If DCO required:
Push:
GitHub prints a URL to open the PR.
Step 9: Open the PR¶
On the upstream repo, you'll see "Compare & pull request" - click.
- Title. Mirror the commit or issue.
- Description. What changed? Why? What you tested.
Closes #123to auto-close issue on merge. - Checklist. Address every item.
Submit. CI runs. Fix anything red by pushing more commits to the same branch.
If a CHANGELOG entry is required, add one.
What happens next: review¶
Maintainer looks. Outcomes:
- "LGTM, merging." Done.
- "Could you change these?" Most common. Inline comments. Address each - change code or reply with reason. Push more commits.
- "Thanks, but we don't want this." Rare for
good first issue. Ask about related. - Silence. After a week: polite check-in. After three: ask elsewhere.
Reviews are not personal. Address efficiently. Argue only on substance.
After the merge¶
- Update your fork's
main(step 3 workflow). - Delete the branch locally + on your fork.
- Take a screenshot.
- Sit with it for a day. Re-read merged code and comments. The learning is in the loop.
Worked example¶
For a small docs fix on example-org/example-repo, issue #42:
git clone https://github.com/<you>/example-repo
cd example-repo
git remote add upstream https://github.com/example-org/example-repo
git fetch upstream
git checkout -b docs/fix-typo-in-readme
cargo test # baseline green
# Edit README.md
cargo fmt --check
cargo clippy --all-targets
cargo test
git add README.md
git commit -m "docs: fix typo in installation section (#42)"
git push origin docs/fix-typo-in-readme
# Open the PR. Wait. Respond.
After your first PR¶
- Pick another issue in the same project. Familiarity compounds.
- After 3-5 PRs, become a regular. Watch issues; help others; review PRs.
- Branch out to Tier 2-3 projects.
- Build your own crate. Publish to crates.io.
- Read the "Rust Mastery" path to go from "I can contribute" to "I can architect and review."
What you might wonder¶
"PR sits for weeks?" Polite check-in after 1 week. After 3, ask elsewhere.
"Maintainer rude?" Disengage. Thousands of projects.
"Disagree with review?" Style: do what they say. Correctness: explain specifically. Stay polite.
"Can't make tests pass?" Re-read CONTRIBUTING. Stuck after an hour: ask in the issue with specifics.
"PR on CV?" Yes - link to specific merged PRs.
Done with the path¶
You've: - Installed Rust and Cargo, written first programs. - Learned every fundamental: types, ownership, borrowing, traits, generics, errors, collections, modules, tests. - Read a real Rust OSS project. - Picked, prepared, and submitted a PR.
What you should not do: claim you "know Rust." You know what you've been taught. Much more remains - async, unsafe, lifetimes in depth, FFI, embedded, performance work. Each is a path of its own.
What you should do: keep contributing. Engineering grows by doing real work on real codebases over time.
Recommended next paths on this site:
- Rust Mastery - 24-week deep dive into ownership, async, unsafe, FFI, traits at depth.
- Linux Kernel - Rust is now an official kernel language. Understanding the kernel helps any systems programmer.
Or just go build something. Programming pays you back when you build.
Congratulations. You are no longer a beginner.