Skip to content

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 PrintSave 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-analyzer extension. 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 &str and String. Move semantics. Traits as constraints, not interfaces. We'll meet them gently.

  • The reward is real. Programs that pass cargo check are 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-analyzer extension 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:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Follow the default prompts (option 1). When done, restart your terminal or run:

source "$HOME/.cargo/env"

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:

rustc --version
cargo --version

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:

cd ~/code        # or wherever
cargo new hello
cd hello

That creates:

hello/
├── Cargo.toml      # the project manifest
└── src/
    └── main.rs     # the source

Open src/main.rs:

fn main() {
    println!("Hello, world!");
}

That's the whole file. Cargo created it for you.

Step 4: Build and run

cargo 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() {
    println!("Hello, world!");
}
  • fn main() - defines a function called main. Like Java/C, main is special - it's where the program starts.
  • println!("Hello, world!"); - calls the println macro 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

  1. Change the message; cargo run again. (Cargo recompiles, then runs.)

  2. Add a second line:

    fn main() {
        println!("Hello, world!");
        println!("this is my second line");
    }
    

  3. Try printing a number - note the format string:

    println!("{}", 42);
    
    The {} is a placeholder, similar to f-strings. We'll see more in page 02.

  4. Break it on purpose. Remove a semicolon. Run cargo check - read the error.

  5. Restore. Now mistype println as printl. Run cargo 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:

[package]
name = "hello"
version = "0.1.0"
edition = "2021"

[dependencies]
  • name / version - your project's identity.
  • edition - Rust's "edition" system. Lets the language evolve syntax without breaking old code. Current is 2021; 2024 exists; 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:

cargo check

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.

Next: First real program →

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:

fn main() {
    let name = "Alice";
    let age = 30;
    println!("{} is {} years old", name, age);
}

Run:

cargo run

Output:

Alice is 30 years old

What's new

let name = "Alice";
let age = 30;
  • let creates a variable. (Unlike var in Java or x = ... in Python - Rust requires let.)
  • name = "Alice" - assigns the string "Alice".
  • No explicit type - Rust infers it. "Alice" is a &str (string slice; we'll explain shortly); 30 is i32 (32-bit signed integer).

The format string:

println!("{} is {} years old", name, age);

{} 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:

println!("{name} is {age} years old");

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:

let x = 5;
x = 10;        // COMPILE ERROR: cannot assign twice to immutable variable `x`

In Rust, variables can't change by default. To make a variable changeable, mark it mut:

let mut x = 5;
x = 10;        // OK now
println!("{x}");   // 10

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:

let count: u32 = 42;
let pi: f64 = 3.14159;
let active: bool = true;

The : u32 between name and value declares the type. Useful when inference can't figure it out (often after parse):

let n: i32 = "42".parse().unwrap();

(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:

let q: f64 = 10.0 / 3.0;
println!("{}", q);    // 3.3333333333333335

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:

let name = "Alice";
let age = 30;
let msg: String = format!("{name} is {age}");
println!("{msg}");

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:

  1. Has a variable for your name (&str).
  2. Has a variable for your favorite number (i32).
  3. Has a variable for whether it's morning (bool).
  4. Has a variable for pi (f64).
  5. Prints a multi-line message using println! macros:
    Hi, I'm Victor.
    My favorite number is 7.
    Is it morning? true
    Pi is approximately 3.14
    
    For the pi line, 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 PI: f64 = 3.14159;
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.

Next: Decisions and loops →

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:

let age = 18;
let label = if age >= 18 { "adult" } else { "minor" };
println!("{label}");        // adult

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:

let age = 18;
if age >= 18 {
    println!("adult");
} else {
    println!("minor");
}

Braces are required even for one-line bodies. (Rust prevents the C-style "forgot the braces" bug.)

No parentheses around the condition required:

if age >= 18 { ... }       // idiomatic
if (age >= 18) { ... }     // works but feels noisy

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.

let age = 25;
let has_license = true;
if age >= 18 && has_license {
    println!("can drive");
}

Loop 1: loop (infinite)

let mut n = 0;
loop {
    n += 1;
    if n == 5 { break; }
    println!("{n}");
}

loop runs forever until break. Like while true in other languages but more explicit.

Cool trick: break can return a value:

let result = loop {
    // ... compute ...
    if done { break 42; }
};
println!("{result}");    // 42

Loop 2: while

let mut n = 10;
while n > 0 {
    println!("{n}");
    n -= 1;
}

Standard "while condition" loop.

Loop 3: for ... in

By far the most common. Iterates over anything that produces a sequence:

for i in 1..=5 {
    println!("{i}");
}
// prints 1, 2, 3, 4, 5

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:

'outer: for i in 0..5 {
    for j in 0..5 {
        if i + j == 6 { break 'outer; }
    }
}

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:

let kind = match day {
    1 | 2 | 3 | 4 | 5 => "weekday",
    6 | 7 => "weekend",
    _ => "invalid",
};

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}"),
}
That's matching on a tuple of (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:

loop {
    do_stuff();
    if !condition { break; }
}

Done

  • if/else as expressions.
  • Three loop forms.
  • break/continue (and label-break).
  • match with multi-value arms, ranges, tuple patterns.

Next: Functions →

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

fn name(parameters) -> ReturnType {
    // body
}

Concrete:

fn double(x: i32) -> i32 {
    x * 2
}

fn main() {
    let r = double(5);
    println!("{r}");      // 10
}

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.

fn double(x: i32) -> i32 {
    x * 2              // no semicolon - value of the function
}

You can use return explicitly:

fn double(x: i32) -> i32 {
    return x * 2;
}

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

fn add(a: i32, b: i32) -> i32 {
    a + b
}

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 ->:

fn say_hi(name: &str) {
    println!("Hi, {name}");
}

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:

  1. Write fn is_even(n: i32) -> bool. Use %.
  2. From main, print is_even(4) and is_even(7). Expected: true, false.
  3. Write fn count_evens(max: i32) -> i32 that counts even numbers in 1..=max. Loop, call is_even.
  4. Print count_evens(10)5; count_evens(100)50.
  5. Stretch: write fn quotient_and_remainder(a: i32, b: i32) -> (i32, i32). From main, 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.

Next: Structs and enums →

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:

let mut alice = Person { ... };
alice.age = 31;        // OK because `alice` is 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:

struct Point(f64, f64);

let p = Point(3.0, 4.0);
println!("{}, {}", p.0, p.1);

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.

struct Marker;

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:

enum Direction {
    North,
    South,
    East,
    West,
}

let d = Direction::North;

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:

enum Option<T> {
    Some(T),
    None,
}

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:

  1. Define struct Rectangle with width: f64 and height: f64.

  2. In an impl block, add:

  3. fn new(width: f64, height: f64) -> Rectangle (constructor).
  4. fn area(&self) -> f64.
  5. fn perimeter(&self) -> f64.
  6. fn scale(&self, factor: f64) -> Rectangle (returns a new Rectangle).

  7. In main, create a Rectangle::new(5.0, 3.0). Print area, perimeter. Then create a scaled version (factor 2) and print its area (should be 60).

  8. Stretch: define enum Shape { Circle(f64), Rectangle(f64, f64), Triangle(f64, f64) }. Write a function fn area(s: &Shape) -> f64 using match. 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 impl blocks (&self, &mut self).
  • Define enums - simple and with data.
  • Use match to 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:

  1. Each value has exactly one owner.
  2. When the owner goes out of scope, the value is dropped (freed).
  3. 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:

let x = 5;
let y = x;
println!("{x}");      // 5 - OK, x is still valid
println!("{y}");      // 5

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:

  1. Return ownership. The function takes the value and gives it back:

    fn take_and_return(s: String) -> String {
        println!("{s}");
        s        // return ownership
    }
    
    Awkward. Almost nobody does this.

  2. 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:

  1. At any given time, you can have either:
  2. One mutable reference (&mut T), or
  3. Any number of immutable references (&T).
  4. References must always be valid (the value they point to must outlive them - no dangling pointers).
  5. (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 &str view of any String).
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:

  • &str when you just want to read text (most common).
  • &String - almost never. Use &str instead; String coerces to &str automatically.
  • String when you need to take ownership (rare). E.g., a function that stores the string somewhere.
  • &mut String when 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:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

'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.

  1. 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.

  1. 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.

  1. 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.

  1. Write a function fn add_exclamation(s: &mut String) that appends "!" to its argument. From main, 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.

Next: Collections →

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":

let nums = vec![1, 2, 3, 4, 5];

You'll use vec! constantly.

Iterating a Vec

let nums = vec![1, 2, 3];
for n in &nums {            // borrow each element
    println!("{n}");
}

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:

for (i, n) in nums.iter().enumerate() {
    println!("{i}: {n}");
}

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:

for (name, age) in &ages {
    println!("{name}: {age}");
}

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. Use std::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.:

let total: i32 = (1..=100).sum();    // 5050
let count = nums.iter().filter(|n| **n > 3).count();

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:

  1. Hardcode the sentence: "the quick brown fox jumps over the lazy dog the end".
  2. Split into words with .split_whitespace().
  3. Build a HashMap<&str, u32> of counts using the entry().or_insert() pattern.
  4. Print each word and its count.

Expected output (order varies):

the: 3
quick: 1
brown: 1
fox: 1
jumps: 1
over: 1
lazy: 1
dog: 1
end: 1

Stretch: sort the entries by count (descending) and print. Convert the HashMap to a Vec<(&str, u32)> and sort:

let mut entries: Vec<_> = counts.into_iter().collect();
entries.sort_by(|a, b| b.1.cmp(&a.1));

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 with for 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().

Next: Error handling →

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:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

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:

got 30
error: invalid digit found in string

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 the Ok value; panics on Err.
  • .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!:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("divide by zero");
    }
    a / b
}

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>:

  1. Trim the input. If it's empty, return Err("empty input".to_string()).
  2. Parse as u32. If parsing fails, return Err(format!("not a number: {s}")).
  3. If the number is 0, return Err("must be > 0".to_string()).
  4. Otherwise return Ok(n).

In main, loop over these inputs:

let inputs = ["42", "hello", "0", "", "100", "  7  "];

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.

Next: Traits and generics →

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:

fn announce(a: impl Animal) {
    println!("{}", a.speak());
}

Way 2 - generic parameter with trait bound:

fn announce<T: Animal>(a: T) {
    println!("{}", a.speak());
}

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:

fn announce(a: &impl Animal) {
    println!("{}", a.speak());
}

(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:

#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}

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." The dyn keyword 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:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

'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:

  1. Define a trait Shape with one required method: fn area(&self) -> f64.

  2. Define structs Circle { radius: f64 }, Square { side: f64 }, Rectangle { width: f64, height: f64 }. #[derive(Debug)] on each.

  3. Implement Shape for each.

  4. Write a function fn total_area(shapes: &[Box<dyn Shape>]) -> f64 that sums all areas.

  5. In main, create a heterogeneous Vec<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 Trait for 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.

Next: Tests →

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]:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[test]
fn test_add() {
    assert_eq!(add(2, 3), 5);
}

Run:

cargo test

You should see:

running 1 test
test test_add ... ok

test result: ok. 1 passed; 0 failed; 0 ignored

#[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:

assert_eq!(result, 5, "expected 5, got {}", result);

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:

#[test]
#[should_panic(expected = "divide by zero")]
fn divide_by_zero_panics() {
    divide(10, 0);
}

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:

my_project/
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    └── integration_test.rs

tests/integration_test.rs:

use my_project::add;

#[test]
fn addition_from_outside() {
    assert_eq!(add(2, 3), 5);
}

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:

  1. Make src/lib.rs (instead of src/main.rs, or in addition - Cargo supports both):

    pub fn word_count(s: &str) -> usize {
        s.split_whitespace().count()
    }
    
    pub fn is_palindrome(s: &str) -> bool {
        let cleaned: String = s.to_lowercase();
        cleaned == cleaned.chars().rev().collect::<String>()
    }
    

  2. Update Cargo.toml if needed - change the [package] to have a library, or leave the binary structure. (Cargo auto-detects lib.rs.)

  3. Add a #[cfg(test)] mod tests at the bottom of lib.rs with:

  4. Parameterized test for word_count (the manual-loop pattern): "" → 0, "hello" → 1, "hello world" → 2, " many spaces here " → 3.
  5. Parameterized test for is_palindrome: "" → true, "a" → true, "racecar" → true, "hello" → false, "Racecar" → true.

  6. Run cargo test.

  7. 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 tests modules.
  • Use assert!, assert_eq!, assert_ne!.
  • Use #[should_panic] for panic tests.
  • Write doc tests in /// comments.
  • Distinguish unit tests from integration tests.

Next: Crates and Cargo →

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 new creates: a folder with Cargo.toml and 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:

src/
├── main.rs
└── math.rs

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:

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

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 std::collections::*;

use is conventional for everything outside the current file. For your own modules:

mod math;
use math::add;

fn main() {
    println!("{}", add(2, 3));
}

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:

[dependencies]
serde = "1.0"
serde_json = "1.0"

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:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

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:

  1. Add serde (with features = ["derive"]) and serde_json:

    cargo add serde --features derive
    cargo add serde_json
    
    (Or edit Cargo.toml directly.)

  2. Define a struct Book with title: String and author: String and year: u32. Derive Serialize, Deserialize, Debug.

  3. Create a Book, serialize to JSON, print it.

  4. Parse the JSON back, print the result.

  5. Now do it for a Vec<Book> of 3 books.

  6. Stretch: add clap (cargo add clap --features derive). Make a small CLI that takes a --name flag and prints Hello, <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 pub to control visibility.
  • Import with use.
  • Add dependencies from crates.io via Cargo.toml or cargo 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:

  1. Read the README. What does this do? If unclear, the project is too unfinished.

  2. List the top-level files/directories:

  3. Cargo.toml - manifest.
  4. Cargo.lock - pinned versions (for binaries).
  5. src/ - source.
  6. src/lib.rs - library entry point.
  7. src/main.rs - binary entry point.
  8. src/bin/ - multiple binary entries.
  9. tests/ - integration tests.
  10. examples/ - runnable examples.
  11. benches/ - benchmarks (criterion).
  12. docs/ - long-form docs (some projects).
  13. .github/workflows/ - CI.

  14. Open Cargo.toml. Name? Version? Dependencies? Tells you the ecosystem.

  15. Find the entry point. For a library: src/lib.rs. Read it - often a list of pub mod foo; declarations and re-exports. That tells you the public API.

  16. 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-analyzer in 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.

  1. cargo doc --open (in a project using serde_json) - search for Value.
  2. Documentation says: "Represents any valid JSON value." Variants: Null, Bool(bool), Number(Number), String(String), Array(Vec<Value>), Object(Map<String, Value>).
  3. So Value is an enum (page 05 of this path!). Read a few of its methods: is_string(), as_str(), etc.
  4. 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.
  • async and await - 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 Trait in 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-rs or its successor ratatui-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.

Next: Picking a project →

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

  1. Small enough to comprehend. Under 10k LOC of Rust is ideal. Under 50k is doable.
  2. Active maintainers. PRs reviewed in days, not weeks.
  3. good first issue / help wanted labels.
  4. CONTRIBUTING.md exists and is readable.
  5. cargo test passes on fresh clone.
  6. 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

  1. Browse three Tier 1-2 projects. Run the 10-minute eval. Take notes.
  2. Pick the most responsive.
  3. Read its CONTRIBUTING.md.
  4. Clone:
    git clone https://github.com/<owner>/<repo>
    cd <repo>
    cargo test
    
    If tests don't pass on fresh clone, consider another.
  5. Browse good first issue tickets; 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 - usually MIT 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. rustup reads this and uses the right toolchain.
  • .gitignore - usually just /target and .idea/.
  • rustfmt.toml - formatter configuration.
  • clippy.toml - linter configuration.

.github/

  • workflows/*.yml - CI. Almost always runs cargo 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 with pub 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>.rs or src/<module>/mod.rs - modules.
  • src/bin/<name>.rs - extra binaries. Run with cargo 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 of lib.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, predates use. Recognize.
  • pub use module::Thing; in lib.rs - re-export. Makes crate::Thing work even though Thing is defined in a submodule.

A worked walkthrough: BurntSushi/ripgrep or serde-rs/serde

Pick whichever you cloned in page 12. Apply:

  1. README - what does it do? Examples?
  2. Cargo.toml - module structure ([lib]? [[bin]]?), dependencies.
  3. src/lib.rs (or main.rs) - pub mod declarations show the public API.
  4. tests/ - pick one; trace it.
  5. .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 require cargo fmt --check to 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:

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test

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.

  1. Clone locally.
  2. Walk the layout. Map files to categories above.
  3. Read CONTRIBUTING.md.
  4. Open one CI workflow YAML. Identify the commands.
  5. Run them locally:
    cargo fmt --check
    cargo clippy --all-targets
    cargo test
    
    Adjust to what CONTRIBUTING / CI specifies.
  6. Open the issue you tentatively picked in page 13. Identify the three files most likely involved (use grep and your guess).

You're ready to make a change.

What you might wonder

"What's rust-toolchain.toml?" Pins the Rust version. Looks like:

[toolchain]
channel = "1.80.0"
components = ["rustfmt", "clippy"]
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

  1. Fork on GitHub.
  2. Clone your fork.
  3. Add upstream as remote.
  4. Branch off main.
  5. Set up dev env, including formatters/linters.
  6. Change the code; add a test.
  7. Run formatter + clippy + tests locally.
  8. Push to your fork; open the PR.

Step 1: Fork

GitHub → Fork button (top right). Creates github.com/<you>/<project>.

Step 2: Clone

git clone https://github.com/<you>/<project>
cd <project>

Step 3: Add upstream

git remote add upstream https://github.com/<owner>/<project>
git fetch upstream
git remote -v

Later, to update from upstream:

git fetch upstream
git checkout main
git merge upstream/main
git push origin main

Step 4: Branch

git checkout -b fix/issue-123-clarify-error-message

Follow project's branch-name convention if any.

Step 5: Set up the dev environment

Read CONTRIBUTING.md. Almost always:

cargo test

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:

cargo fmt
cargo clippy --all-targets

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

git add <files>
git commit -m "fix: clarify error message in module.rs (#123)"

Commit message: - First line ≤50 chars, imperative. - Optional body. - Reference issue: (#123) auto-links.

If DCO required:

git commit -s -m "fix: ..."

Push:

git push origin fix/issue-123-clarify-error-message

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 #123 to 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:

  1. "LGTM, merging." Done.
  2. "Could you change these?" Most common. Inline comments. Address each - change code or reply with reason. Push more commits.
  3. "Thanks, but we don't want this." Rare for good first issue. Ask about related.
  4. 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

  1. Pick another issue in the same project. Familiarity compounds.
  2. After 3-5 PRs, become a regular. Watch issues; help others; review PRs.
  3. Branch out to Tier 2-3 projects.
  4. Build your own crate. Publish to crates.io.
  5. 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.