← Back to all projects

LEARN RUST FROM FIRST PRINCIPLES

Learn Rust: From First Principles to Fearless Systems Programming

Goal: To deeply understand Rust by building projects that force you to confront its core principles: memory safety, fearless concurrency, and zero-cost abstractions. This is not just about learning syntax; it’s about internalizing the “why” behind the borrow checker and building a mental model for writing fast, safe, and modern systems software.


Why Learn Rust?

C and C++ built the world, but they did so on a foundation of “undefined behavior,” memory leaks, and data races. Rust offers a radical proposition: what if you could write code with the same low-level control as C++, but with compile-time guarantees that eliminate entire classes of the most common and dangerous bugs?

After completing these projects, you will not just “know” Rust. You will:

  • Think in Ownership and Borrows: Naturally structure your code to satisfy the borrow checker.
  • Write Fearless Concurrent Code: Build multi-threaded applications without fearing data races.
  • Leverage Zero-Cost Abstractions: Write high-level, expressive code that compiles down to hyper-efficient machine code.
  • Master the Type System: Use Option, Result, and enums to make impossible states impossible.
  • Integrate with the C World Safely: Build safe, idiomatic wrappers around existing C libraries.

Core Concept Analysis

Rust’s power comes from a few key concepts that work together. Understanding them is the key to mastering the language.

The Ownership & Borrowing Model

This is Rust’s most unique feature and the heart of its safety guarantees.

┌────────────────────────────────────────────────────────────┐
│                       C/C++ Approach                       │
│                                                            │
│   char* data = create_data();                              │
│   process_data(data);                                      │
│   // Who is responsible for freeing `data`?                │
│   // `process_data`? The original caller?                  │
│   // Did `process_data` keep a pointer to it?              │
│   // Leads to: double-free bugs, use-after-free bugs.      │
└────────────────────────────────────────────────────────────┘
                                │
                                ▼ Rust's Compiler (The Borrow Checker)
┌────────────────────────────────────────────────────────────┐
│                        Rust's Approach                     │
│                                                            │
│   let data = create_data();      // `data` is "owned" here. │
│   process_data(&data);           // "Lend" a reference.     │
│   // `data` is still owned here. The compiler *proves* that │
│   // `process_data` did not store the reference. When `data`│
│   // goes out of scope, it is automatically freed exactly  │
│   // once. No memory leaks. No use-after-free.              │
└────────────────────────────────────────────────────────────┘

Fearless Concurrency

Rust’s ownership model extends to threads, preventing data races at compile time.

┌────────────────────────────────────────────────────────────┐
│                       C/C++ Approach                       │
│                                                            │
│   int counter = 0;                                         │
│   // Thread 1: counter++;                                  │
│   // Thread 2: counter++;                                  │
│   // Oops, a data race! The final value could be 1 or 2.   │
│   // You must remember to use a mutex *every time*.        │
└────────────────────────────────────────────────────────────┘
                                │
                                ▼ Rust's Compiler
┌────────────────────────────────────────────────────────────┐
│                        Rust's Approach                     │
│                                                            │
│   let counter = Arc<Mutex<i32>>; // Wrap in thread-safe types
│   // Thread 1: let mut num = counter.lock().unwrap(); *num += 1;
│   // Thread 2: let mut num = counter.lock().unwrap(); *num += 1;
│   // The compiler will *not* let you access the data without │
│   // acquiring the lock first. Data races are impossible.  │
└────────────────────────────────────────────────────────────┘

Key Concepts Explained

1. Ownership, Borrowing, and Lifetimes

  • Ownership: Every value in Rust has a single “owner.” When the owner goes out of scope, the value is dropped (and its memory freed).
  • Borrowing: You can “lend” access to a value via references (&T for immutable, &mut T for mutable). The compiler enforces a critical rule: you can have either one mutable reference OR any number of immutable references, but not both.
  • Lifetimes: These are names for scopes that the compiler uses to ensure references never outlive the data they point to. Most of the time, the compiler infers them for you (lifetime elision).

2. The Type System: struct, enum, Option, Result

  • struct: A way to group related data, like in C.
  • enum: A type that can be one of several variants. Rust’s enums are “tagged unions,” meaning they can hold data.
  • Option<T>: An enum that encodes the possibility of a value being absent. It can be either Some(T) or None. This eliminates null pointer errors.
  • Result<T, E>: An enum for operations that can fail. It can be either Ok(T) (success with a value) or Err(E) (failure with an error). This forces you to handle errors explicitly.

3. Concurrency: Send, Sync, Arc, Mutex

  • Send: A marker trait indicating a type is safe to move to another thread.
  • Sync: A marker trait indicating a type is safe to be shared across multiple threads (&T is Send).
  • Arc<T>: “Atomically Reference-Counted” pointer. It’s how you share ownership of a value across multiple threads.
  • Mutex<T>: A smart pointer that provides mutually exclusive access to data. Crucially, the data can only be accessed after acquiring a lock.

4. Zero-Cost Abstractions

  • Iterators: Rust’s iterator trait allows for chainable, high-level data processing (.map(), .filter(), .fold()) that the compiler optimizes into machine code that is often just as fast as a manual C-style loop.
  • Async/Await: High-level syntax for writing asynchronous code that compiles down to an efficient state machine, without the overhead of a large runtime or “green threads” unless you want them.

Project List

These projects are designed to force you to grapple with Rust’s core strengths in a practical way.


Project 1: A Command-Line grep Clone (greprs)

  • File: LEARN_RUST_FROM_FIRST_PRINCIPLES.md
  • Main Programming Language: Rust
  • Alternative Programming Languages: C, Go
  • Coolness Level: Level 2: Practical but Forgettable
  • Business Potential: 1. The “Resume Gold”
  • Difficulty: Level 1: Beginner
  • Knowledge Area: CLI Tools / File I/O
  • Software or Tool: cargo
  • Main Book: “The Rust Programming Language” by Klabnik & Nichols

What you’ll build: A simple command-line tool that searches for a pattern in a file and prints the lines that contain it.

Why it teaches Rust: This is the perfect first project. It covers the basics: using cargo, parsing arguments, reading files, and handling potential errors. It immediately forces you to use Result and Option, introducing you to Rust’s robust error-handling philosophy.

Core challenges you’ll face:

  • Parsing command-line arguments → maps to using std::env::args and basic ownership
  • Reading a file line by line → maps to using std::fs and handling Result for I/O errors
  • Handling configuration (e.g., case-insensitivity) → maps to using structs for configuration
  • Writing clean, testable logic → maps to separating your main function from your library logic

Key Concepts:

  • Cargo and Crates: “The Rust Programming Language” Ch. 1 & 7
  • Structs and Enums: “The Rust Programming Language” Ch. 5
  • Error Handling with Result: “The Rust Programming Language” Ch. 9
  • Standard Library I/O: “The Rust Programming Language” Ch. 12

Difficulty: Beginner Time estimate: Weekend Prerequisites: None, this is a great place to start.

Real world outcome:

$ cat poem.txt
I'm nobody! Who are you?
Are you nobody, too?

$ cargo run -- nobody poem.txt
I'm nobody! Who are you?
Are you nobody, too?

Implementation Hints:

  1. Start with cargo new greprs. Look at the Cargo.toml and src/main.rs file cargo created.
  2. Your main function will be the entry point. Start by trying to read the command-line arguments. The std::env::args() function returns an iterator. How do you get the values you need from it? What happens if the user doesn’t provide enough arguments?
  3. Create a Config struct to hold the query and filename. Write a new function for it that returns a Result<Config, &'static str>. This is your first taste of idiomatic Rust error handling.
  4. In main, use a match expression or if let to handle the Result from Config::new.
  5. Create a run function that takes the Config. This function should also return a Result. Inside, use std::fs::read_to_string to read the file. This function also returns a Result—how do you handle it? Look up the ? operator.
  6. Iterate over the lines of the file content and check if each line contains your query.

Learning milestones:

  1. Your program compiles and runs → You understand the basic cargo workflow.
  2. You can parse arguments and read a file → You’ve handled basic String ownership and Result types.
  3. You have a separate Config struct and run function → You’re learning to write modular, testable Rust.
  4. The program correctly reports errors (e.g., file not found) → You’ve internalized the basics of Rust’s explicit error handling.

Project 2: A Linked List From Scratch

  • File: LEARN_RUST_FROM_FIRST_PRINCIPLES.md
  • Main Programming Language: Rust
  • Alternative Programming Languages: C, C++
  • Coolness Level: Level 4: Hardcore Tech Flex
  • Business Potential: 1. The “Resume Gold”
  • Difficulty: Level 3: Advanced
  • Knowledge Area: Data Structures / Memory Management
  • Software or Tool: cargo
  • Main Book: “Too Many Linked Lists” by Alexis Beingessner

What you’ll build: A functional singly linked list, with methods for push, pop, and iterating over the elements.

Why it teaches Rust: This is a rite of passage. In C, a linked list is trivial. In safe Rust, it’s a formidable challenge that pits you directly against the borrow checker. By building one, you will be forced to deeply understand ownership, Box<T> for heap allocation, and Option<T> for nullable pointers. It’s a trial by fire for Rust’s memory safety model.

Core challenges you’ll face:

  • Defining the Node struct → maps to using Box<T> to prevent infinite type recursion
  • Implementing push and pop → maps to transferring ownership of nodes
  • Handling the head pointer → maps to using Option<T> to represent a possibly empty list
  • Trying to implement an iterator → maps to fighting the borrow checker over mutable and immutable references

Key Concepts:

  • Ownership: “The Rust Programming Language” Ch. 4
  • Smart Pointers (Box): “The Rust Programming Language” Ch. 15
  • Option<T>: “The Rust Programming Language” Ch. 6
  • Recursive Data Structures: “Too Many Linked Lists” (This entire tutorial is dedicated to this problem).

Difficulty: Advanced Time estimate: 1-2 weeks Prerequisites: Project 1, a firm grasp of basic structs and enums.

Real world outcome: You will have a working (and tested!) linked list implementation.

// In your tests
let mut list = List::new();
list.push(1);
list.push(2);
list.push(3);

assert_eq!(list.pop(), Some(3));
assert_eq!(list.pop(), Some(2));

list.push(4);
assert_eq!(list.pop(), Some(4));
assert_eq!(list.pop(), Some(1));
assert_eq!(list.pop(), None);

Implementation Hints:

  1. How would you define a Node in C? It would be a struct containing data and a pointer *Node. Try that in Rust. Why does the compiler complain about a “recursive type with infinite size”? How does Box<T> solve this?
  2. Your List struct will just contain the head of the list. What should the type of head be? What if the list is empty? This is where Option is essential. The type might look something like Option<Box<Node<T>>>.
  3. For the push method: you’ll create a new Node. This new node needs to become the new head. What should its next pointer be? It should be the old head. This involves taking ownership of the old head. The Option::take method is your friend here.
  4. For the pop method: you need to remove the head and make the next node the new head. This also involves using take() to gain ownership of the head node, and then updating self.head with the popped node’s next field.

Learning milestones:

  1. You successfully define a recursive Node struct → You understand heap allocation with Box<T>.
  2. You can push and pop from the head of the list → You’ve mastered transferring ownership with Option::take.
  3. Your test suite passes without memory leaks → You’ve built a memory-safe data structure without a garbage collector.
  4. You understand why it was so hard → You’ve internalized the guarantees the borrow checker provides.

Project 3: A Multi-Threaded TCP Web Server

  • File: LEARN_RUST_FROM_FIRST_PRINCIPLES.md
  • Main Programming Language: Rust
  • Alternative Programming Languages: C (with pthreads), Go
  • Coolness Level: Level 4: Hardcore Tech Flex
  • Business Potential: 3. The “Service & Support” Model
  • Difficulty: Level 3: Advanced
  • Knowledge Area: Concurrency / Networking
  • Software or Tool: std::net, std::thread
  • Main Book: “The Rust Programming Language” Ch. 20

What you’ll build: A simple multi-threaded TCP server that listens for connections and serves a static HTML file. You will implement a thread pool to limit the number of concurrent connections.

Why it teaches Rust: This project is the crucible for “Fearless Concurrency.” You will directly confront the problem of sharing state (the thread pool’s job queue) between multiple threads. The compiler will act as your safety net, forcing you to use Arc and Mutex correctly and preventing all data races at compile time.

Core challenges you’ll face:

  • Listening for TCP connections → maps to using std::net::TcpListener
  • Spawning threads to handle connections → maps to using std::thread::spawn and closures with move
  • Building a thread pool → maps to sharing a queue of jobs between worker threads
  • Safely sharing the job queue → maps to the Arc<Mutex<T>> pattern for shared, mutable state

Key Concepts:

  • Concurrency vs. Parallelism: “The Rust Programming Language” Ch. 16
  • Threads: “The Rust Programming Language” Ch. 16
  • Shared-State Concurrency (Arc, Mutex): “The Rust Programming Language” Ch. 16
  • TCP Sockets: “The Linux Programming Interface” by Michael Kerrisk, Ch. 56

Difficulty: Advanced Time estimate: 1-2 weeks Prerequisites: Project 1, understanding of basic HTTP and TCP concepts.

Real world outcome: You’ll run your server, and be able to connect to it from a web browser.

$ cargo run
   Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/web-server`
Server listening on 127.0.0.1:7878

# Open http://127.0.0.1:7878 in your browser and see your HTML page.
# Open multiple tabs to see the multi-threading in action.

Implementation Hints:

  1. Start with a single-threaded version. Use TcpListener::bind and loop over listener.incoming() to accept connections. For each connection stream, read the HTTP request and write back a hardcoded HTTP response.
  2. Now, try to spawn a new thread for each connection using thread::spawn. Why does the compiler complain about the lifetime of the stream? You’ll need to use a move closure.
  3. Spawning infinite threads is bad. Let’s build a ThreadPool. It will need a new function and an execute method. The ThreadPool will create a fixed number of Worker threads.
  4. How do the main thread and Worker threads communicate? You need a channel or a shared queue. A Mutex<mpsc::Receiver<Job>> is a great choice.
  5. But how do you share the Mutex across multiple worker threads? A single Mutex has a single owner. You need multiple owners. This is the exact problem that Arc (Atomically Reference Counted) solves. The final type will be Arc<Mutex<...>>.
  6. The compiler will guide you. If you try to access the shared receiver without locking the mutex, it will fail to compile. If you try to share the mutex incorrectly, it will fail to compile. Listen to the error messages!

Learning milestones:

  1. Your server handles one request at a time → You understand basic TCP sockets in Rust.
  2. Your server spawns a new thread for each request → You understand basic thread spawning.
  3. You implement a thread pool that compiles → You’ve conquered the Arc<Mutex<T>> pattern.
  4. Your server gracefully shuts down → You understand how to manage the lifecycle of concurrent resources. You have achieved fearless concurrency.

Project 4: Build a redis-cli Clone

  • File: LEARN_RUST_FROM_FIRST_PRINCIPLES.md
  • Main Programming Language: Rust
  • Alternative Programming Languages: Go, Python
  • Coolness Level: Level 3: Genuinely Clever
  • Business Potential: 2. The “Micro-SaaS / Pro Tool”
  • Difficulty: Level 2: Intermediate
  • Knowledge Area: Async I/O / Network Protocols
  • Software or Tool: tokio crate, Redis
  • Main Book: “Rust in Action” by Tim McNamara

What you’ll build: An asynchronous command-line client for a Redis server. Your tool will connect to Redis, send commands like PING, SET, and GET, and parse the RESP (REdis Serialization Protocol) responses.

Why it teaches Rust: This project is the perfect introduction to async/await, Rust’s modern approach to asynchronous programming. You’ll learn how to handle I/O without blocking threads, a critical skill for building high-performance network services. It also highlights Rust’s strength in parsing binary protocols safely.

Core challenges you’ll face:

  • Setting up an async runtime → maps to understanding tokio and the #[tokio::main] macro
  • Making an async TCP connection → maps to using tokio::net::TcpStream
  • Sending and receiving data asynchronously → maps to using .await on I/O operations
  • Parsing a streaming protocol (RESP) → maps to managing a read buffer and parsing framed messages safely

Key Concepts:

  • Async/Await in Rust: “The Rust Programming Language” Ch. 16 (briefly), but the Tokio tutorial is better.
  • Futures: The core concept behind async. A Future is a value that will be computed later.
  • Tokio Runtime: The engine that polls your Futures and drives them to completion.
  • Protocol Parsing: Writing a state machine to parse incoming byte streams.

Difficulty: Intermediate Time estimate: 1-2 weeks Prerequisites: Project 1, basic understanding of what async is for.

Real world outcome: Your CLI will be able to talk to a real Redis server.

$ cargo run -- PING
"PONG"

$ cargo run -- SET foo "hello world"
"OK"

$ cargo run -- GET foo
"hello world"

Implementation Hints:

  1. You’ll need tokio as a dependency. Add tokio = { version = "1", features = ["full"] } to your Cargo.toml.
  2. Your main function needs to be marked with #[tokio::main]. This sets up the async runtime.
  3. Use TcpStream::connect to connect to your Redis server (e.g., “127.0.0.1:6379”). Notice it returns a Future—you must .await it.
  4. A TcpStream can be split into a reader and a writer. You’ll write your command to the writer half.
  5. Reading the response is the tricky part. Redis uses RESP, which is a text-based protocol with prefixes like + for simple strings, $ for bulk strings, and * for arrays. You’ll need to read from the socket into a buffer and parse the response frame by frame.
  6. The Mini-Redis tutorial by the Tokio team is an excellent, step-by-step guide for exactly this project. Following it is highly recommended.

Learning milestones:

  1. You can connect to Redis and send a PING → You understand the basics of tokio and async networking.
  2. You can parse simple string and error responses → You’ve started to build a protocol parser.
  3. You can handle bulk strings and arrays → Your parser is now robust.
  4. Your CLI works just like the real redis-cli for basic commands → You have a practical understanding of building async clients in Rust.

Project 5: A Safe Wrapper around a C Library

  • File: LEARN_RUST_FROM_FIRST_PRINCIPLES.md
  • Main Programming Language: Rust
  • Alternative Programming Languages: C, Python (with ctypes)
  • Coolness Level: Level 4: Hardcore Tech Flex
  • Business Potential: 3. The “Service & Support” Model
  • Difficulty: Level 4: Expert
  • Knowledge Area: Foreign Function Interface (FFI) / API Design
  • Software or Tool: bindgen, libclang
  • Main Book: “The Rustonomicon” Ch. 6 (FFI)

What you’ll build: A safe, idiomatic Rust wrapper around a C library like libz (for compression) or sqlite3. Your Rust library will expose a clean API that uses Result for errors and handles memory management automatically, hiding the unsafe C-level details.

Why it teaches Rust: This project demonstrates Rust’s power as a modern replacement for C++. A huge amount of the world runs on C libraries. This project teaches you how to bridge the gap, bringing Rust’s safety to existing C codebases. It forces you to think about API boundaries and what “safety” really means.

Core challenges you’ll face:

  • Linking to a C library → maps to using a build script (build.rs)
  • Generating Rust bindings for C functions → maps to using the bindgen tool
  • Calling unsafe C functions → maps to working with raw pointers and unsafe blocks
  • Creating safe abstractions → maps to wrapping raw pointers in structs that implement Drop for automatic cleanup, and converting C integer error codes into Rust Result types

Key Concepts:

  • unsafe keyword: “The Rust Programming Language” Ch. 19
  • Foreign Function Interface (FFI): “The Rustonomicon” Ch. 6
  • The Drop Trait: “The Rust Programming Language” Ch. 15 (for custom cleanup logic)
  • Build Scripts (build.rs): The Cargo Book

Difficulty: Expert Time estimate: 2-3 weeks Prerequisites: Project 1, basic C knowledge.

Real world outcome: Your Rust code will feel safe and high-level, even though it’s calling C under the hood.

// The API you will build
use my_zlib_wrapper::compress;

fn main() -> Result<(), ZlibError> {
    let data = b"hello world";
    let compressed_data = compress(data, 5)?; // 5 is compression level

    // compressed_data is a Vec<u8>, memory is managed automatically.
    // The C-level z_stream, mallocs, and frees are all hidden.

    println!("Compressed: {:?}", compressed_data);
    Ok(())
}

// Contrast with the C API:
// You'd have to manually initialize a z_stream struct, allocate buffers,
// call deflate, check integer return codes, and then call deflateEnd.

Implementation Hints:

  1. Choose a simple C library. libz is a great choice.
  2. Create a new library crate: cargo new my_zlib_wrapper --lib.
  3. You’ll need a -sys crate (e.g., my_zlib-sys). This crate’s only job is to compile and link the C library and generate the raw, unsafe bindings.
  4. In the my_zlib-sys crate, use a build.rs file to find the C library on the system or compile it from source. Use bindgen to automatically generate bindings.rs from the C header file (zlib.h).
  5. Your main my_zlib_wrapper crate will depend on my_zlib-sys.
  6. Inside my_zlib_wrapper, you will call the unsafe functions from the generated bindings.
  7. Create a safe Rust function, e.g., compress. Inside, you’ll work with the C API’s structs and raw pointers, but the function signature will take safe Rust types (&[u8]) and return a Result<Vec<u8>, MyError>.
  8. If the C API requires you to init and destroy a struct, create a Rust struct that holds the raw pointer, and implement the Drop trait for it to automatically call the C destroy function. This is the RAII (Resource Acquisition Is Initialization) pattern, and it’s key to safety.

Learning milestones:

  1. You can call a C function from Rust → You understand the basics of FFI.
  2. Your project compiles and links the C library automatically → You’ve mastered build.rs.
  3. You create a safe wrapper function → You can convert C’s error codes and manual memory management into Rust’s Result and Drop.
  4. Your final API is completely safe and idiomatic → You understand how to build bridges between the unsafe world and the safe world, which is Rust’s ultimate superpower.

Summary

Project Main Language Difficulty
Project 1: A Command-Line grep Clone (greprs) Rust Beginner
Project 2: A Linked List From Scratch Rust Advanced
Project 3: A Multi-Threaded TCP Web Server Rust Advanced
Project 4: Build a redis-cli Clone Rust Intermediate
Project 5: A Safe Wrapper around a C Library Rust Expert

I recommend starting with Project 1 (greprs). It is the ideal entry point to the Rust ecosystem and its core philosophies without being overwhelming. After that, tackling the Linked List (Project 2) is a crucial step to truly test your understanding of ownership. Good luck on your journey to mastering Rust!