Toolmingo
Guides23 min read

Rust Tutorial for Beginners (2025): Learn Rust Step by Step

Complete Rust tutorial for beginners. Learn Rust syntax, ownership, structs, error handling, and build real projects. Free guide with code examples.

Rust is a systems programming language that runs blazingly fast, prevents segfaults, and guarantees memory safety — without a garbage collector. It has been voted the most loved programming language on Stack Overflow for nine consecutive years. It powers everything from WebAssembly to embedded firmware to operating system kernels. This tutorial takes you from zero to writing real Rust programs, step by step, with no prior Rust experience required.

What you'll learn

Topic What you'll be able to do
Setup Install Rust and write your first program
Syntax & variables Understand how Rust code is structured
Ownership & borrowing Understand Rust's core memory model
Types Work with integers, floats, strings, booleans
Control flow Use if/else, loop, while, and for
Structs & enums Model data with custom types
Collections Use Vec, HashMap, and HashSet
Error handling Use Result and Option idiomatically
Traits Write flexible, reusable abstractions
Closures & iterators Write functional-style data pipelines
Modules Organise code into modules and crates
Projects Build 3 real programs

Rust version used: Rust 1.80+ (stable)


Part 1 — Why Rust?

Rust was designed at Mozilla to solve systems programming problems: memory bugs, data races, and unpredictable performance. The compiler catches entire classes of bugs at compile time that other languages only find at runtime.

Rust use cases

Domain Examples
Systems programming Operating systems, device drivers
WebAssembly Blazingly fast browser code
CLI tools ripgrep, fd, bat, exa
Web backends Axum, Actix-web APIs
Embedded / IoT No-std firmware, microcontrollers
Blockchain Solana, Near Protocol
Game development Bevy engine
Networking Cloudflare services, Tokio async runtime

Rust vs C++ vs Go vs Python

Dimension Rust C++ Go Python
Speed Native (no GC) Native (no GC) Very fast (GC) Slow (interpreted)
Memory safety Guaranteed at compile time Manual (error-prone) GC handles it GC handles it
Concurrency Fearless (data race-free) Error-prone Goroutines GIL-limited
Learning curve Steep Very steep Gentle Very gentle
Ecosystem Growing fast Mature Growing Enormous
Best for Safety-critical systems Legacy systems Cloud services Data, scripting

Part 2 — Setup

Install Rust

The official way to install Rust is through rustup, the Rust toolchain manager.

# Windows (run in PowerShell)
winget install Rustlang.Rustup
# or download from https://rustup.rs and run the installer

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

After installation, open a new terminal and verify:

rustc --version   # rustc 1.80.0 (or newer)
cargo --version   # cargo 1.80.0 (or newer)

What gets installed

Tool Purpose
rustc The Rust compiler
cargo Build system and package manager
rustup Toolchain manager (update Rust)
rust-std Standard library

Recommended editor

VS Code with the rust-analyzer extension gives you the best experience: inline type hints, autocompletion, and real-time error checking.

Your first Rust program

cargo new hello-rust
cd hello-rust

This creates:

hello-rust/
├── Cargo.toml   # project manifest (like package.json)
└── src/
    └── main.rs  # entry point

Open src/main.rs:

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

Run it:

cargo run
# Hello, Rust!

println! is a macro (note the !). Macros are expanded at compile time and are more powerful than regular functions.


Part 3 — Variables and Types

Variables and mutability

In Rust, variables are immutable by default. This is intentional — it prevents accidental mutation.

fn main() {
    let x = 5;
    // x = 6;  // ERROR: cannot assign twice to immutable variable

    let mut y = 10;  // mut makes it mutable
    y = 20;
    println!("y = {y}");

    // Shadowing: redeclare with the same name
    let z = "hello";
    let z = z.len();  // z is now usize, not &str
    println!("z = {z}");

    // Constants: compile-time, always immutable, must have type
    const MAX_POINTS: u32 = 100_000;
    println!("max = {MAX_POINTS}");
}

Scalar types

Type Description Example
i8, i16, i32, i64, i128 Signed integers let n: i32 = -42;
u8, u16, u32, u64, u128 Unsigned integers let n: u8 = 255;
isize, usize Pointer-sized integer let i: usize = 0;
f32, f64 Floating point let f: f64 = 3.14;
bool Boolean let b: bool = true;
char Unicode scalar value (4 bytes) let c: char = '😊';

Rust infers types in most cases. When it can't, you must annotate:

fn main() {
    let x = 42;           // inferred: i32
    let y = 3.14;         // inferred: f64
    let z: u8 = 200;      // explicit annotation

    // Type conversion is always explicit (no implicit casting)
    let a: i32 = 10;
    let b: f64 = a as f64;  // cast with `as`

    // Integer arithmetic
    println!("{}", 10 / 3);    // 3 (integer division)
    println!("{}", 10 % 3);    // 1 (remainder)
    println!("{}", 2_i32.pow(10));  // 1024

    // Underscores for readability
    let million = 1_000_000_u64;
    println!("{million}");
}

Strings

Rust has two main string types:

Type Description Stored on
&str String slice — immutable reference to string data Stack / static memory
String Owned, growable string Heap
fn main() {
    // &str — string literal (hardcoded in binary)
    let greeting: &str = "Hello";

    // String — owned, heap-allocated
    let mut name = String::from("Alice");
    name.push_str(", welcome!");
    name.push('!');

    // Format strings
    let full = format!("{greeting}, {name}");
    println!("{full}");

    // Useful String methods
    let s = String::from("  Hello, World!  ");
    println!("{}", s.trim());                   // "Hello, World!"
    println!("{}", s.to_lowercase());           // "  hello, world!  "
    println!("{}", s.contains("World"));        // true
    println!("{}", s.replace("World", "Rust")); // "  Hello, Rust!  "
    println!("{}", s.len());                    // 17 (bytes)

    // Split and collect
    let csv = "a,b,c,d";
    let parts: Vec<&str> = csv.split(',').collect();
    println!("{:?}", parts);  // ["a", "b", "c", "d"]
}

Part 4 — Ownership and Borrowing

Ownership is Rust's most distinctive feature. It's how Rust achieves memory safety without a garbage collector.

The three rules of ownership

  1. Each value in Rust has one owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value is dropped (freed).

Move semantics

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1 is MOVED into s2 — s1 is no longer valid

    // println!("{s1}");  // ERROR: value used after move

    // Clone makes a deep copy — both are valid
    let s3 = String::from("world");
    let s4 = s3.clone();
    println!("{s3} and {s4}");  // both valid

    // Copy types (integers, floats, bool, char) are copied, not moved
    let x = 5;
    let y = x;
    println!("{x} and {y}");  // both valid — i32 implements Copy
}

References and borrowing

Instead of moving ownership, you can borrow a reference:

fn main() {
    let s = String::from("hello");

    // Immutable reference: you can have many at once
    let r1 = &s;
    let r2 = &s;
    println!("{r1} and {r2}");  // both valid

    // Mutable reference: only ONE at a time
    let mut t = String::from("hello");
    let mr = &mut t;
    mr.push_str(", world");
    println!("{mr}");

    // You cannot mix mutable and immutable references in the same scope
    // let r3 = &t;          // ERROR if mr is still in use
}

// Function that borrows (doesn't take ownership)
fn calculate_length(s: &String) -> usize {
    s.len()
}  // s goes out of scope, but it's a reference — nothing is dropped

fn main_2() {
    let s = String::from("hello");
    let len = calculate_length(&s);  // borrow with &
    println!("{s} has {len} chars");  // s still valid!
}

The borrow checker rules summary

Rule What it prevents
One owner at a time Double-free bugs
Immutable refs OR one mutable ref Data races
References must not outlive the data Dangling pointers

Slices

A slice is a reference to a contiguous portion of a collection:

fn main() {
    let s = String::from("hello world");

    // String slice
    let hello = &s[0..5];   // or &s[..5]
    let world = &s[6..11];  // or &s[6..]
    println!("{hello} {world}");

    // Array slice
    let a = [1, 2, 3, 4, 5];
    let slice = &a[1..3];  // [2, 3]
    println!("{:?}", slice);
}

Part 5 — Control Flow

if / else

fn main() {
    let n = 42;

    if n < 0 {
        println!("negative");
    } else if n == 0 {
        println!("zero");
    } else {
        println!("positive");
    }

    // if is an expression — it returns a value
    let description = if n % 2 == 0 { "even" } else { "odd" };
    println!("{n} is {description}");
}

Loops

fn main() {
    // loop: infinite, use `break` to exit (can return a value)
    let mut counter = 0;
    let result = loop {
        counter += 1;
        if counter == 10 {
            break counter * 2;  // loop returns 20
        }
    };
    println!("result = {result}");

    // while
    let mut n = 1;
    while n < 100 {
        n *= 2;
    }
    println!("n = {n}");

    // for — the most common loop in Rust
    for i in 0..5 {
        print!("{i} ");  // 0 1 2 3 4
    }
    println!();

    for i in (0..5).rev() {
        print!("{i} ");  // 4 3 2 1 0
    }
    println!();

    // Iterate over a collection
    let fruits = ["apple", "banana", "cherry"];
    for fruit in &fruits {
        println!("{fruit}");
    }

    // With index
    for (i, fruit) in fruits.iter().enumerate() {
        println!("{i}: {fruit}");
    }
}

Pattern matching with match

match in Rust is exhaustive — you must handle every case:

fn main() {
    let n = 7;

    match n {
        1 => println!("one"),
        2 | 3 => println!("two or three"),  // multiple patterns
        4..=6 => println!("four through six"),  // range
        x if x % 2 == 0 => println!("{x} is even"),  // guard
        _ => println!("something else"),  // wildcard (catch-all)
    }

    // match is an expression
    let grade = match n {
        90..=100 => "A",
        80..=89  => "B",
        70..=79  => "C",
        _        => "F",
    };
    println!("grade: {grade}");
}

Part 6 — Functions

// Basic function — return type after ->
fn add(a: i32, b: i32) -> i32 {
    a + b  // no semicolon = implicit return (expression)
}

// Multiple return values via tuple
fn min_max(nums: &[i32]) -> (i32, i32) {
    let min = *nums.iter().min().unwrap();
    let max = *nums.iter().max().unwrap();
    (min, max)
}

// Destructuring the tuple
fn main() {
    println!("{}", add(3, 4));  // 7

    let numbers = [3, 1, 4, 1, 5, 9, 2, 6];
    let (min, max) = min_max(&numbers);
    println!("min={min}, max={max}");
}

Part 7 — Structs

// Define a struct
#[derive(Debug)]  // enables {:?} formatting
struct Point {
    x: f64,
    y: f64,
}

// Methods go in an impl block
impl Point {
    // Associated function (like a static method) — no `self`
    fn new(x: f64, y: f64) -> Self {
        Self { x, y }  // shorthand when field names match variable names
    }

    // Method — takes self by reference
    fn distance_to_origin(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    // Mutable method
    fn translate(&mut self, dx: f64, dy: f64) {
        self.x += dx;
        self.y += dy;
    }
}

fn main() {
    let mut p = Point::new(3.0, 4.0);
    println!("{:?}", p);                    // Point { x: 3.0, y: 4.0 }
    println!("distance: {}", p.distance_to_origin());  // 5.0

    p.translate(1.0, 1.0);
    println!("{:?}", p);                    // Point { x: 4.0, y: 5.0 }
}

Tuple structs and unit structs

// Tuple struct: fields accessed by position
struct Meters(f64);
struct Kilograms(f64);

fn main() {
    let distance = Meters(1000.0);
    let weight = Kilograms(75.0);
    println!("{} meters", distance.0);
    // distance + weight would be a type error — they're different types!

    // Unit struct: no fields, used as markers
    struct AlwaysEqual;
    let _subject = AlwaysEqual;
}

Part 8 — Enums

Rust enums are much more powerful than enums in C or Java — each variant can hold data:

#[derive(Debug)]
enum Shape {
    Circle(f64),          // radius
    Rectangle(f64, f64),  // width, height
    Triangle { base: f64, height: f64 },  // named fields
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle(r) => std::f64::consts::PI * r * r,
            Shape::Rectangle(w, h) => w * h,
            Shape::Triangle { base, height } => 0.5 * base * height,
        }
    }
}

fn main() {
    let shapes = vec![
        Shape::Circle(5.0),
        Shape::Rectangle(4.0, 6.0),
        Shape::Triangle { base: 3.0, height: 8.0 },
    ];

    for shape in &shapes {
        println!("{shape:?} => area = {:.2}", shape.area());
    }
}

Option — handling the absence of a value

Rust has no null. Instead, it uses Option<T>:

fn divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    // Pattern match on Option
    match divide(10.0, 2.0) {
        Some(result) => println!("result: {result}"),
        None => println!("cannot divide by zero"),
    }

    // if let — when you only care about one variant
    if let Some(result) = divide(10.0, 0.0) {
        println!("result: {result}");
    } else {
        println!("division failed");
    }

    // Useful Option methods
    let val: Option<i32> = Some(42);
    println!("{}", val.unwrap());           // 42 (panics if None!)
    println!("{}", val.unwrap_or(0));       // 42, or 0 if None
    println!("{}", val.unwrap_or_default());// 42, or i32::default() (0)
    println!("{}", val.is_some());          // true
    println!("{}", val.is_none());          // false

    // map transforms the inner value
    let doubled = val.map(|x| x * 2);
    println!("{:?}", doubled);  // Some(84)
}

Part 9 — Error Handling

Result — handling recoverable errors

use std::fs;
use std::num::ParseIntError;

// Functions that can fail return Result<T, E>
fn parse_number(s: &str) -> Result<i32, ParseIntError> {
    s.trim().parse::<i32>()  // parse returns Result
}

fn main() {
    // Pattern match on Result
    match parse_number("42") {
        Ok(n) => println!("parsed: {n}"),
        Err(e) => println!("error: {e}"),
    }

    // The ? operator — propagate errors up the call stack
    // (only works in functions that return Result or Option)
    fn read_username() -> Result<String, std::io::Error> {
        let content = fs::read_to_string("username.txt")?;  // ? = return Err if fail
        Ok(content.trim().to_string())
    }

    // unwrap_or, unwrap_or_else for defaults
    let n = parse_number("abc").unwrap_or(0);
    println!("n = {n}");

    let n2 = parse_number("abc").unwrap_or_else(|e| {
        eprintln!("warning: {e}");
        -1
    });
    println!("n2 = {n2}");
}

Custom error types

use std::fmt;

#[derive(Debug)]
enum AppError {
    IoError(std::io::Error),
    ParseError(std::num::ParseIntError),
    InvalidInput(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::IoError(e) => write!(f, "IO error: {e}"),
            AppError::ParseError(e) => write!(f, "parse error: {e}"),
            AppError::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
        }
    }
}

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self {
        AppError::IoError(e)
    }
}

fn process(s: &str) -> Result<i32, AppError> {
    if s.is_empty() {
        return Err(AppError::InvalidInput("empty string".to_string()));
    }
    let n = s.trim().parse::<i32>().map_err(AppError::ParseError)?;
    Ok(n * 2)
}

Part 10 — Collections

Vec — growable array

fn main() {
    // Create
    let mut v: Vec<i32> = Vec::new();
    let mut v2 = vec![1, 2, 3];  // vec! macro

    // Add / remove
    v2.push(4);
    v2.push(5);
    let last = v2.pop();  // Some(5)
    println!("{:?}, popped: {:?}", v2, last);

    // Access
    let third = &v2[2];           // panics if out of bounds
    let maybe = v2.get(10);       // returns Option<&i32>
    println!("third: {third}, maybe: {maybe:?}");

    // Iterate
    for item in &v2 {
        print!("{item} ");
    }
    println!();

    // Common operations
    v2.sort();
    v2.dedup();           // remove consecutive duplicates (sort first!)
    v2.retain(|&x| x > 1);  // keep only elements where closure returns true
    println!("{:?}", v2);

    // Useful methods
    println!("len: {}", v2.len());
    println!("contains 3: {}", v2.contains(&3));
    println!("sum: {}", v2.iter().sum::<i32>());
}

HashMap

use std::collections::HashMap;

fn main() {
    let mut scores: HashMap<String, u32> = HashMap::new();

    // Insert
    scores.insert("Alice".to_string(), 95);
    scores.insert("Bob".to_string(), 87);
    scores.insert("Charlie".to_string(), 92);

    // Access — returns Option
    if let Some(score) = scores.get("Alice") {
        println!("Alice: {score}");
    }

    // Insert only if key doesn't exist
    scores.entry("Dave".to_string()).or_insert(75);

    // Update based on current value
    let count = scores.entry("Alice".to_string()).or_insert(0);
    *count += 5;

    // Iterate (order not guaranteed)
    for (name, score) in &scores {
        println!("{name}: {score}");
    }

    // Useful methods
    println!("has Bob: {}", scores.contains_key("Bob"));
    scores.remove("Bob");
    println!("len: {}", scores.len());
}

HashSet

use std::collections::HashSet;

fn main() {
    let mut a: HashSet<i32> = [1, 2, 3, 4, 5].iter().cloned().collect();
    let b: HashSet<i32> = [3, 4, 5, 6, 7].iter().cloned().collect();

    a.insert(6);
    a.remove(&1);

    println!("contains 2: {}", a.contains(&2));

    // Set operations
    let union: HashSet<_> = a.union(&b).collect();
    let intersection: HashSet<_> = a.intersection(&b).collect();
    let difference: HashSet<_> = a.difference(&b).collect();

    println!("union: {:?}", union);
    println!("intersection: {:?}", intersection);
    println!("difference (a-b): {:?}", difference);
}

Part 11 — Traits

Traits are Rust's way of defining shared behaviour — similar to interfaces in other languages:

// Define a trait
trait Greet {
    fn greeting(&self) -> String;

    // Default implementation
    fn greet(&self) {
        println!("{}", self.greeting());
    }
}

struct English;
struct Spanish;

impl Greet for English {
    fn greeting(&self) -> String {
        "Hello!".to_string()
    }
}

impl Greet for Spanish {
    fn greeting(&self) -> String {
        "¡Hola!".to_string()
    }
    // greet() uses default implementation
}

// Trait bounds — accept any type that implements Greet
fn print_greeting(g: &impl Greet) {
    g.greet();
}

// Generic version (equivalent)
fn print_greeting_generic<T: Greet>(g: &T) {
    g.greet();
}

fn main() {
    let en = English;
    let es = Spanish;

    print_greeting(&en);  // Hello!
    print_greeting(&es);  // ¡Hola!
}

Commonly used standard traits

Trait What it enables How to derive
Debug {:?} formatting #[derive(Debug)]
Display {} formatting Manual impl
Clone .clone() deep copy #[derive(Clone)]
Copy Implicit copy (like i32) #[derive(Copy, Clone)]
PartialEq, Eq == comparison #[derive(PartialEq, Eq)]
PartialOrd, Ord <, >, .sort() #[derive(PartialOrd, Ord)]
Hash Use in HashMap keys #[derive(Hash)]
Default .unwrap_or_default() #[derive(Default)]
Iterator for loops, .map(), etc. Manual impl

Part 12 — Closures and Iterators

Closures

Closures are anonymous functions that can capture variables from their environment:

fn main() {
    // Closure syntax
    let add = |a: i32, b: i32| a + b;
    println!("{}", add(3, 4));

    // Closures capture their environment
    let multiplier = 3;
    let triple = |x| x * multiplier;  // captures multiplier
    println!("{}", triple(5));  // 15

    // Move closure — takes ownership of captured variables
    let name = String::from("Rust");
    let greet = move || println!("Hello, {name}!");
    greet();
    // name is moved into greet — can't use it here
}

Iterator adapters

Rust's iterator system is lazy and composable:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // map, filter, collect
    let even_squares: Vec<i32> = numbers.iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| x * x)
        .collect();
    println!("{:?}", even_squares);  // [4, 16, 36, 64, 100]

    // sum, product, count
    let sum: i32 = numbers.iter().sum();
    let product: i32 = (1..=5).product();
    let count = numbers.iter().filter(|&&x| x > 5).count();
    println!("sum={sum}, product={product}, count={count}");

    // any, all
    let has_even = numbers.iter().any(|&x| x % 2 == 0);
    let all_positive = numbers.iter().all(|&x| x > 0);
    println!("has_even={has_even}, all_positive={all_positive}");

    // find, position
    let first_even = numbers.iter().find(|&&x| x % 2 == 0);
    println!("first even: {first_even:?}");  // Some(2)

    // zip two iterators
    let letters = ['a', 'b', 'c'];
    let paired: Vec<_> = (1..=3).zip(letters.iter()).collect();
    println!("{:?}", paired);  // [(1, 'a'), (2, 'b'), (3, 'c')]

    // flat_map (map + flatten)
    let words = vec!["hello world", "foo bar"];
    let chars: Vec<&str> = words.iter()
        .flat_map(|s| s.split_whitespace())
        .collect();
    println!("{:?}", chars);  // ["hello", "world", "foo", "bar"]

    // fold (reduce)
    let factorial: u64 = (1..=10).fold(1, |acc, x| acc * x);
    println!("10! = {factorial}");
}

Part 13 — Modules and Crates

Organising code with modules

// src/main.rs
mod math {
    // Items are private by default — `pub` makes them public
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }

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

    pub mod advanced {
        pub fn power(base: i32, exp: u32) -> i32 {
            base.pow(exp)
        }
    }
}

use math::add;                  // bring into scope
use math::advanced::power;

fn main() {
    println!("{}", add(3, 4));         // 7
    println!("{}", math::subtract(10, 3));  // 7 (full path)
    println!("{}", power(2, 10));      // 1024
}

Using external crates

Add dependencies in Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.8"

Then run cargo build to download and compile.

// Using rand
use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let n: u32 = rng.gen_range(1..=100);
    println!("random: {n}");
}

Essential crates

Crate Purpose
serde + serde_json Serialisation / JSON
tokio Async runtime
reqwest HTTP client
axum Web framework
clap CLI argument parsing
rand Random number generation
chrono Date and time
log + env_logger Logging
anyhow Easy error handling
thiserror Custom error types

Part 14 — Three Projects

Project 1: CLI To-Do Manager

// src/main.rs
use std::env;

#[derive(Debug)]
struct Todo {
    id: usize,
    text: String,
    done: bool,
}

struct TodoList {
    items: Vec<Todo>,
    next_id: usize,
}

impl TodoList {
    fn new() -> Self {
        Self { items: Vec::new(), next_id: 1 }
    }

    fn add(&mut self, text: &str) {
        self.items.push(Todo {
            id: self.next_id,
            text: text.to_string(),
            done: false,
        });
        self.next_id += 1;
        println!("Added: {text}");
    }

    fn complete(&mut self, id: usize) {
        match self.items.iter_mut().find(|t| t.id == id) {
            Some(todo) => {
                todo.done = true;
                println!("Completed: {}", todo.text);
            }
            None => println!("No todo with id {id}"),
        }
    }

    fn list(&self) {
        if self.items.is_empty() {
            println!("No todos.");
            return;
        }
        for todo in &self.items {
            let status = if todo.done { "✓" } else { "○" };
            println!("[{status}] {} — {}", todo.id, todo.text);
        }
    }

    fn remove(&mut self, id: usize) {
        let before = self.items.len();
        self.items.retain(|t| t.id != id);
        if self.items.len() < before {
            println!("Removed todo {id}");
        } else {
            println!("No todo with id {id}");
        }
    }
}

fn main() {
    let mut list = TodoList::new();
    let args: Vec<String> = env::args().collect();

    match args.get(1).map(String::as_str) {
        Some("add") => {
            if let Some(text) = args.get(2) {
                list.add(text);
            } else {
                eprintln!("Usage: todo add <text>");
            }
        }
        Some("done") => {
            if let Some(id) = args.get(2).and_then(|s| s.parse().ok()) {
                list.complete(id);
            }
        }
        Some("rm") => {
            if let Some(id) = args.get(2).and_then(|s| s.parse().ok()) {
                list.remove(id);
            }
        }
        Some("ls") | None => list.list(),
        Some(cmd) => eprintln!("Unknown command: {cmd}"),
    }
}

Run it:

cargo run -- add "Learn Rust"
cargo run -- add "Build a project"
cargo run -- ls
cargo run -- done 1
cargo run -- rm 2

Project 2: Word Frequency Counter

// Cargo.toml: no extra dependencies
use std::collections::HashMap;
use std::env;
use std::fs;

fn count_words(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        // Normalise: lowercase, strip punctuation
        let clean: String = word
            .chars()
            .filter(|c| c.is_alphabetic())
            .collect::<String>()
            .to_lowercase();
        if !clean.is_empty() {
            *counts.entry(clean).or_insert(0) += 1;
        }
    }
    counts
}

fn top_n(counts: &HashMap<String, usize>, n: usize) -> Vec<(&String, &usize)> {
    let mut sorted: Vec<_> = counts.iter().collect();
    sorted.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
    sorted.into_iter().take(n).collect()
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let path = args.get(1).map(String::as_str).unwrap_or("input.txt");
    let n: usize = args.get(2)
        .and_then(|s| s.parse().ok())
        .unwrap_or(10);

    let text = match fs::read_to_string(path) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("Error reading {path}: {e}");
            std::process::exit(1);
        }
    };

    let counts = count_words(&text);
    let total_words: usize = counts.values().sum();
    let unique_words = counts.len();

    println!("Total words: {total_words}");
    println!("Unique words: {unique_words}");
    println!("\nTop {n} words:");
    println!("{:<20} {:<10} {}", "Word", "Count", "Frequency");
    println!("{}", "-".repeat(42));

    for (word, count) in top_n(&counts, n) {
        let freq = (*count as f64 / total_words as f64) * 100.0;
        println!("{:<20} {:<10} {:.2}%", word, count, freq);
    }
}

Run it:

echo "the quick brown fox jumps over the lazy dog the fox" > input.txt
cargo run -- input.txt 5

Project 3: Simple HTTP API (with Axum)

# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
// src/main.rs
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::Json,
    routing::{delete, get, post},
    Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Note {
    id: u64,
    title: String,
    body: String,
}

#[derive(Deserialize)]
struct CreateNote {
    title: String,
    body: String,
}

type Db = Arc<Mutex<Vec<Note>>>;

async fn list_notes(State(db): State<Db>) -> Json<Vec<Note>> {
    let notes = db.lock().unwrap();
    Json(notes.clone())
}

async fn create_note(
    State(db): State<Db>,
    Json(payload): Json<CreateNote>,
) -> (StatusCode, Json<Note>) {
    let mut notes = db.lock().unwrap();
    let id = notes.len() as u64 + 1;
    let note = Note {
        id,
        title: payload.title,
        body: payload.body,
    };
    notes.push(note.clone());
    (StatusCode::CREATED, Json(note))
}

async fn get_note(
    State(db): State<Db>,
    Path(id): Path<u64>,
) -> Result<Json<Note>, StatusCode> {
    let notes = db.lock().unwrap();
    notes.iter()
        .find(|n| n.id == id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

async fn delete_note(
    State(db): State<Db>,
    Path(id): Path<u64>,
) -> StatusCode {
    let mut notes = db.lock().unwrap();
    let before = notes.len();
    notes.retain(|n| n.id != id);
    if notes.len() < before {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

#[tokio::main]
async fn main() {
    let db: Db = Arc::new(Mutex::new(Vec::new()));

    let app = Router::new()
        .route("/notes", get(list_notes).post(create_note))
        .route("/notes/:id", get(get_note).delete(delete_note))
        .with_state(db);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Test it:

cargo run &

curl -X POST http://localhost:3000/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"First note","body":"Hello Rust!"}'

curl http://localhost:3000/notes
curl http://localhost:3000/notes/1
curl -X DELETE http://localhost:3000/notes/1

Learning path

Stage Duration What to learn
Beginner Weeks 1–4 Ownership, basic types, enums, pattern matching
Getting comfortable Weeks 5–8 Traits, generics, error handling, collections
Intermediate Weeks 9–16 Lifetimes, closures, iterators, modules, Cargo
Async Weeks 17–20 Tokio, async/await, futures
Systems / advanced Weeks 21–28 Unsafe Rust, macros, FFI, no-std
Specialise Ongoing Pick a domain (web/systems/embedded/WebAssembly)

Best resource: The Rust Book — free, official, excellent. Also: Rustlings for exercises and Rust by Example.


Common mistakes

Mistake Problem Fix
Fighting the borrow checker Trying to write non-Rust patterns in Rust Learn ownership; restructure code rather than force-fitting
Using unwrap() everywhere Panics at runtime in production Use ? operator, unwrap_or, or proper error handling
clone()ing to avoid borrow errors Performance cost, may indicate design issue Use references, restructure, or clone only where necessary
Using String when &str suffices Unnecessary heap allocation Prefer &str for function parameters that just read
Not using iterators Verbose, often less efficient Replace for loops with .iter().map().filter().collect()
Ignoring compiler warnings Warnings often indicate real bugs Run cargo clippy and address all warnings
Skipping lifetimes until forced Confusing when you first encounter them Read the lifetime chapter in the Rust Book early
Not reading error messages fully Rust error messages are excellent — they tell you what to do Read the full error including the "help:" suggestions

Rust vs related languages and tools

Term Relationship to Rust
Cargo Rust's official build system and package manager
crates.io The official Rust package registry (like npm/PyPI)
Tokio The most popular async runtime for Rust
rustup Manages Rust compiler versions and toolchains
clippy Rust's official linter (cargo clippy)
rustfmt Rust's official formatter (cargo fmt)
unsafe Rust Opt-in subset that allows raw pointers and unsafe operations
WebAssembly (WASM) Rust compiles to WASM — run Rust in the browser
no_std A Rust subset for embedded systems with no OS
C FFI Rust can call C code and be called from C

FAQ

Do I need to know C or C++ to learn Rust? No. While Rust is often compared to C++, you don't need to know either. Having any programming experience (Python, JavaScript, Go, Java) is enough. C/C++ experience can help you understand why ownership matters, but it's not required.

How long does it take to become productive in Rust? Expect 2–4 weeks before the borrow checker stops fighting you, and 2–3 months before you feel fluent. Rust has a steeper learning curve than most languages, but the skills you learn transfer to a deep understanding of memory and concurrency that benefits your work in all languages.

What's the deal with lifetimes? Lifetimes are annotations that tell the compiler how long references are valid. Most of the time, the compiler infers them automatically (lifetime elision). You only need to write them explicitly in function signatures and structs that hold references — which is less common than beginners expect.

Should I use async Rust right away? No. Learn sync Rust first. Async Rust (Tokio, async/await) adds significant complexity. Build a few sync programs, get comfortable with ownership and traits, then tackle async.

Is Rust worth learning for web development? Yes, but it's not the easiest path. Axum and Actix-web are excellent frameworks, and Rust excels at high-performance APIs. However, Node.js, Go, or Python/Django will get you to a working web app faster. Rust is worth it if you need maximum performance or are building infrastructure.

Where is Rust used in production? Mozilla (the Servo browser engine), Cloudflare (networking services), Discord (voice/video infrastructure), Amazon (AWS Firecracker hypervisor, Bottlerocket OS), Microsoft (Windows kernel components), Linux kernel (Rust for Linux), Google (Android OS components), Meta (Folly library, Diem blockchain).

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools