Skip to content
rusthacks.dev
Rust

Ten Rust Project Ideas That Teach One Hard Idea Each

Ten small projects, each chosen to force one concept: ownership, traits, error handling, lifetimes, async, unsafe. Every entry names the idea, the scope and a stopping point.

9 min read
Summarize in AI
Flat illustration for the post “Ten Rust Project Ideas That Teach One Hard Idea Each”, composed around lifetime.

The usual advice for learning Rust is "build something", which is correct and unhelpful. The something matters. A project that exercises the wrong part of the language teaches you nothing you needed, and a project that is too large dies in week three with a half-working parser and a pile of borrow-checker errors. The projects that work are small, finish in a weekend, and force exactly one hard idea each. This post lists ten, names the idea each one forces, says what finished looks like, and includes working starting code for two of them with the output they print. It ran on Rust 1.99 in edition 2024.

The ordering follows how Rust's difficulty actually unfolds: first errors and ownership, then enums and pattern matching, then traits, then concurrency, then the awkward parts. Do them in order if you can, and skip any you already know.

How to pick, and how to keep it small

Pick the project for the concept it forces, not for how impressive it sounds. A grep clone sounds boring and teaches error handling, file I/O and process exit codes, which every real program needs. A "distributed database" sounds exciting and teaches you mostly that you are not ready for it. Set three limits before you start: a one-sentence description of done, a list of what you will not build, and a deadline of a weekend. Write the tests for parsers and data structures first; the compiler plus a test suite is the fastest feedback loop Rust offers. And finish. A completed grep with tests beats an abandoned database every time, because finishing is the skill you are practicing.

Project one: a grep clone

The idea it forces is error handling and I/O. The program takes a pattern and a file, prints the matching lines with numbers, and reports problems in a way scripts can use. Done means it matches grep on exit codes: 0 when something matched, 1 when nothing did, 2 on an error. Here is a first version that already does that:

src/main.rsrust
use std::fs;
use std::process::ExitCode;

/// Print every line of a file that contains the pattern, with its line number.
fn grep(pattern: &str, path: &str) -> Result<usize, String> {
    let text = fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
    let mut hits = 0;
    for (number, line) in text.lines().enumerate() {
        if line.contains(pattern) {
            println!("{}:{}", number + 1, line);
            hits += 1;
        }
    }
    Ok(hits)
}

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let [pattern, path] = args.as_slice() else {
        eprintln!("usage: mygrep <pattern> <file>");
        return ExitCode::from(2);
    };
    match grep(pattern, path) {
        Ok(0) => ExitCode::from(1), // like grep: 1 means no match
        Ok(_) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("mygrep: {e}");
            ExitCode::from(2)
        }
    }
}

Run against a four-line file with the pattern rust, then with a pattern that does not appear, a missing file, and no arguments, it printed:

Text
2:beta rust
4:rust again
exit 0
exit 1
mygrep: nope.txt: No such file or directory (os error 2)
exit 2
usage: mygrep <pattern> <file>
exit 2

Three Rust ideas are visible. Result<usize, String> carries either a hit count or an error message, and ? propagates the error. The let [pattern, path] = args.as_slice() else { ... } pattern checks the argument count and binds both at once. And ExitCode makes the process status explicit instead of calling std::process::exit from inside logic. Extensions that each add a concept: read standard input when no file is given, add -i for case-insensitive matching, take several files, and replace the string search with the regex crate.

Project two: a tokenizer and calculator

The idea is enums and pattern matching. You turn 2 * (3 + 4) - 5 / 2 into tokens, parse the tokens by recursive descent, and evaluate. Done means correct precedence, parentheses, unary minus, and an error message, not a panic, for bad input. The starting code defines a Token enum and a three-level parser, one function per precedence level:

src/main.rsrust
#[derive(Debug, PartialEq, Clone)]
enum Token {
    Num(f64),
    Plus,
    Minus,
    Star,
    Slash,
    LParen,
    RParen,
}

fn tokenize(input: &str) -> Result<Vec<Token>, String> {
    let mut tokens = Vec::new();
    let mut chars = input.chars().peekable();
    while let Some(&c) = chars.peek() {
        match c {
            ' ' => {
                chars.next();
            }
            '+' => { tokens.push(Token::Plus); chars.next(); }
            '-' => { tokens.push(Token::Minus); chars.next(); }
            '*' => { tokens.push(Token::Star); chars.next(); }
            '/' => { tokens.push(Token::Slash); chars.next(); }
            '(' => { tokens.push(Token::LParen); chars.next(); }
            ')' => { tokens.push(Token::RParen); chars.next(); }
            '0'..='9' | '.' => {
                let mut text = String::new();
                while let Some(&d) = chars.peek() {
                    if d.is_ascii_digit() || d == '.' {
                        text.push(d);
                        chars.next();
                    } else {
                        break;
                    }
                }
                tokens.push(Token::Num(text.parse().map_err(|_| format!("bad number {text:?}"))?));
            }
            other => return Err(format!("unexpected character {other:?}")),
        }
    }
    Ok(tokens)
}

// expr := term (('+' | '-') term)*   term := factor (('*' | '/') factor)*   factor := NUM | '(' expr ')' | '-' factor
struct Parser {
    tokens: Vec<Token>,
    pos: usize,
}

impl Parser {
    fn peek(&self) -> Option<&Token> {
        self.tokens.get(self.pos)
    }
    fn next(&mut self) -> Option<Token> {
        let t = self.tokens.get(self.pos).cloned();
        self.pos += 1;
        t
    }
    fn expr(&mut self) -> Result<f64, String> {
        let mut value = self.term()?;
        while let Some(op) = self.peek().cloned() {
            match op {
                Token::Plus => { self.next(); value += self.term()?; }
                Token::Minus => { self.next(); value -= self.term()?; }
                _ => break,
            }
        }
        Ok(value)
    }
    fn term(&mut self) -> Result<f64, String> {
        let mut value = self.factor()?;
        while let Some(op) = self.peek().cloned() {
            match op {
                Token::Star => { self.next(); value *= self.factor()?; }
                Token::Slash => {
                    self.next();
                    let divisor = self.factor()?;
                    if divisor == 0.0 {
                        return Err("division by zero".into());
                    }
                    value /= divisor;
                }
                _ => break,
            }
        }
        Ok(value)
    }
    fn factor(&mut self) -> Result<f64, String> {
        match self.next() {
            Some(Token::Num(n)) => Ok(n),
            Some(Token::Minus) => Ok(-self.factor()?),
            Some(Token::LParen) => {
                let v = self.expr()?;
                match self.next() {
                    Some(Token::RParen) => Ok(v),
                    _ => Err("expected )".into()),
                }
            }
            other => Err(format!("unexpected {other:?}")),
        }
    }
}

fn calculate(input: &str) -> Result<f64, String> {
    let mut p = Parser { tokens: tokenize(input)?, pos: 0 };
    let v = p.expr()?;
    if p.pos < p.tokens.len() {
        return Err(format!("unexpected {:?} after the expression", p.tokens[p.pos]));
    }
    Ok(v)
}

fn main() {
    for input in ["1 + 2 * 3", "(1 + 2) * 3", "-4 / (2 - 2)", "2 * (3 + 4) - 5 / 2", "2 $ 3", "(1 + 2", "3 4"] {
        match calculate(input) {
            Ok(v) => println!("{input:<22} = {v}"),
            Err(e) => println!("{input:<22} ! {e}"),
        }
    }
}
Text
1 + 2 * 3              = 7
(1 + 2) * 3            = 9
-4 / (2 - 2)           ! division by zero
2 * (3 + 4) - 5 / 2    = 11.5
2 $ 3                  ! unexpected character '$'
(1 + 2                 ! expected )
3 4                    ! unexpected Num(4.0) after the expression

The seven inputs show the happy path, precedence, a division by zero, a bad character, a missing parenthesis and trailing garbage, each handled with a message. The program leans on match over an enum the compiler checks for completeness, Peekable<Chars> for one-character lookahead, and Result<f64, String> threaded through recursion. Extensions: add variables and assignment, then functions, which turns the evaluator into a small interpreter and teaches HashMap environments and the borrow-checker questions that come with them.

Project three: a key-value store

The idea is ownership across a persistent boundary. Build a store with set, get and delete commands in memory first, using a HashMap<String, String>. Then persist it to an append-only log file and replay the log on startup. The questions it forces are who owns the keys, when to clone, and how to keep a file and an in-memory structure consistent. Done means the data survives a restart and a corrupted last line does not crash the loader. Extension: compact the log when it grows.

The idea is async and tokio. Read a Markdown file, extract URLs, and check them concurrently with a cap on how many are in flight. It teaches async fn, .await, spawning tasks, timeouts, and the difference between concurrency and parallelism. Done means a slow link does not stall the others and the exit code reflects the result. There is a worked version in building a Markdown link checker.

Project five: a worker pool

The idea is threads and channels. Spawn N threads that pull jobs from a shared queue, process them, and send results back. You will meet Arc, Mutex, mpsc, Send and Sync, and your first "cannot be sent between threads safely" error, which is a useful one to get early. Done means all jobs complete, workers shut down cleanly when the queue closes, and a panicking job does not kill the pool. Extension: compare it with rayon on the same workload.

Project six: a text diff tool

The idea is algorithms on slices, with lifetimes. Implement the longest common subsequence of two files' lines and print a unified-style diff. It forces you to work with &[T], indices and 2D tables, and to return results that borrow from the inputs, which is the first honest lifetime problem most people hit. Done means it agrees with diff on small inputs. Test it with property tests: applying the diff to the first file must produce the second.

Project seven: an LRU cache

The idea is data structures that fight the borrow checker. A least-recently-used cache needs a map for lookup and a list for order, and linking them is hard in safe Rust. The honest approaches are HashMap plus VecDeque with index fixups, a slab of nodes addressed by index, or Rc<RefCell<...>>. Trying two of them teaches you why Rust data structure code looks the way it does. Done means constant-time get and put and a test for eviction order.

Project eight: an HTTP server over raw TCP

The idea is parsing a protocol and defining traits. Using only std::net::TcpListener, read a request line and headers, route by path, and write a response. Then extract a Handler trait so routes are registered as values. It teaches BufReader, byte-level parsing, and trait objects for the handler table. Done means curl works against it and a malformed request gets a 400, not a panic. Do not add TLS.

Project nine: a bytecode virtual machine

The idea is state machines and performance. Define a handful of instructions, push, add, jump and call, run them in a loop with a match, and then measure. It is the smallest project that makes you think about enum layout and dispatch cost, and the one where a release build matters most, as Rust is not always faster shows. Done means a loop of ten million iterations runs in well under a second in release mode.

Project ten: a command line todo app with a database

The idea is working with real dependencies. Use clap for arguments, serde for configuration, and rusqlite or sqlx for storage, with anyhow for errors. It teaches reading crate documentation, feature flags, and structuring a program as a library plus a thin binary, the layout covered in a command line tool from clap to a shipped release. Done means todo add, todo list and todo done 3 work and the data is in one file.

After you finish one

Read the standard library or a popular crate's solution to the same problem. Your grep next to ripgrep's core, your pool next to rayon's, your cache next to the lru crate. The difference between the two is the lesson, and it is usually about API design and error handling, not cleverness. Then rewrite your weakest module with what you saw. That second pass is where most of the learning happens.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.