Project · after Level 2

Todo List 📝

Your first program that remembers. Everything so far forgot everything the moment it exited; this one writes to a file and picks up where it left off. That single change is what separates a toy from an application.

📋 The spec

Build a command-line todo app:

$ cargo run -- add "learn Rust"
Added: learn Rust

$ cargo run -- add "feed Rusty"
Added: feed Rusty

$ cargo run -- list
  1. [ ] learn Rust
  2. [ ] feed Rusty

$ cargo run -- done 1
Nice work: learn Rust

$ cargo run -- list
  1. [x] learn Rust
  2. [ ] feed Rusty

Requirements:

  • Commands: add <text>, list, done <number>, and remove <number>.
  • Tasks survive between runs, saved in a plain text file.
  • A task is a struct with its text and whether it is done.
  • Unknown commands and bad task numbers produce helpful messages, never a crash.
  • A missing or empty save file is treated as an empty list, not an error.

The one new thing: files

Two functions cover everything you need, and both return Result because the disk can always say no:

use std::fs;

// write (creates or overwrites)
fs::write("todos.txt", "some text\n").expect("could not save");

// read
let contents = fs::read_to_string("todos.txt").expect("could not read");

Note that a missing file is an Err, not an empty string. Your first run has no file, so handle that case deliberately: unwrap_or_default() turns "no file" into "empty string", which is exactly the behavior the spec asks for.

💡 Design decision, yours to make You must invent a format for the file. That is a real engineering choice, and the hints suggest one, but any format you can write and read back is correct. Inventing formats is a normal part of the job.

Hints, in order of desperation

Hint 1: What shape should the data have?
#[derive(Debug)]
struct Task {
    text: String,
    done: bool,
}

The whole list is then a Vec<Task>. Everything the program does is one of: load the Vec from disk, change it, save it back. Holding that sentence in your head makes the rest mostly typing.

Hint 2: What file format should I use?

One task per line, with a marker and the text separated by a character that will not appear in normal text:

1|learn Rust
0|feed Rusty

Saving becomes: for each task, write 1 or 0, a pipe, then the text. Loading becomes: for each line, split_once('|') and rebuild the struct.

split_once returns an Option<(&str, &str)>, which is perfect: a malformed line simply yields None and can be skipped rather than crashing the program.

Hint 3: How do I load and save?
fn load() -> Vec<Task> {
    let contents = fs::read_to_string("todos.txt").unwrap_or_default();
    contents
        .lines()
        .filter_map(|line| {
            let (flag, text) = line.split_once('|')?;
            Some(Task {
                text: text.to_string(),
                done: flag == "1",
            })
        })
        .collect()
}

fn save(tasks: &[Task]) {
    let mut out = String::new();
    for task in tasks {
        let flag = if task.done { "1" } else { "0" };
        out.push_str(&format!("{flag}|{}\n", task.text));
    }
    fs::write("todos.txt", out).expect("could not save your todos");
}

filter_map is a lovely trick here: it runs a closure that returns Option and silently drops the Nones. Bad lines vanish, good lines become tasks, in one pass.

Hint 4: How do I dispatch the commands?

Match on the first argument as a string slice, and use as_str() so the patterns can be plain literals:

let args: Vec<String> = env::args().collect();
let command = args.get(1).map(|s| s.as_str()).unwrap_or("list");

match command {
    "add" => { /* ... */ }
    "list" => { /* ... */ }
    "done" => { /* ... */ }
    "remove" => { /* ... */ }
    other => println!("Unknown command '{other}'. Try: add, list, done, remove"),
}

Defaulting to list when no command is given is a small kindness that makes the tool feel finished.

Hint 5: Task numbers keep breaking

Two traps, both classic:

  • Humans count from 1, Rust counts from 0. Display index + 1, and subtract 1 from what the user types.
  • Never index straight into the Vec. tasks[99] panics. Use tasks.get_mut(i) and handle None:
match tasks.get_mut(number - 1) {
    Some(task) => {
        task.done = true;
        println!("Nice work: {}", task.text);
    }
    None => println!("There's no task {number}."),
}

Also guard against number == 0, since 0 - 1 on an unsigned integer panics with an overflow.

Hint 6: Show me the whole program
use std::env;
use std::fs;

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

const FILE: &str = "todos.txt";

fn load() -> Vec<Task> {
    fs::read_to_string(FILE)
        .unwrap_or_default()
        .lines()
        .filter_map(|line| {
            let (flag, text) = line.split_once('|')?;
            Some(Task { text: text.to_string(), done: flag == "1" })
        })
        .collect()
}

fn save(tasks: &[Task]) {
    let mut out = String::new();
    for task in tasks {
        let flag = if task.done { "1" } else { "0" };
        out.push_str(&format!("{flag}|{}\n", task.text));
    }
    fs::write(FILE, out).expect("could not save your todos");
}

fn list(tasks: &[Task]) {
    if tasks.is_empty() {
        println!("Nothing to do. Suspicious. 🦀");
        return;
    }
    for (i, task) in tasks.iter().enumerate() {
        let mark = if task.done { "x" } else { " " };
        println!("  {}. [{}] {}", i + 1, mark, task.text);
    }
}

fn parse_number(args: &[String]) -> Option<usize> {
    let raw: usize = args.get(2)?.parse().ok()?;
    if raw == 0 { None } else { Some(raw) }
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let command = args.get(1).map(|s| s.as_str()).unwrap_or("list");
    let mut tasks = load();

    match command {
        "add" => {
            let text = args[2..].join(" ");
            if text.trim().is_empty() {
                println!("What should I add? Try: add \"buy kelp\"");
            } else {
                println!("Added: {text}");
                tasks.push(Task { text, done: false });
                save(&tasks);
            }
        }
        "list" => list(&tasks),
        "done" => match parse_number(&args).and_then(|n| tasks.get_mut(n - 1).map(|t| (n, t))) {
            Some((_, task)) => {
                task.done = true;
                println!("Nice work: {}", task.text);
                save(&tasks);
            }
            None => println!("Which task? Try: done 1"),
        },
        "remove" => match parse_number(&args) {
            Some(n) if n <= tasks.len() => {
                let removed = tasks.remove(n - 1);
                println!("Removed: {}", removed.text);
                save(&tasks);
            }
            _ => println!("Which task? Try: remove 1"),
        },
        other => println!("Unknown command '{other}'. Try: add, list, done, remove"),
    }
}

Notice args[2..].join(" ") in the add branch: it lets add buy some kelp work without quotes, by gluing all the remaining arguments back together.

You built an application 🎉

Persistence is the line between a program and a tool. Yours now survives restarts, handles a file that might not exist, refuses to crash on bad input, and does something you might actually use. That is the same shape as software people pay for; the rest is scale and polish.

Stretch goals

Make it yours

  • Priorities. Add an enum Priority { Low, Normal, High } to the struct, show high-priority tasks with a 🔥, and sort the list.
  • Save somewhere sensible. Right now the file lands wherever you ran the command. Put it in the home directory instead so the tool works from anywhere.
  • Real JSON. Try cargo add serde --features derive and cargo add serde_json, then #[derive(Serialize, Deserialize)] on your struct. Your hand-rolled format disappears into two function calls. This is the crate that half the Rust ecosystem depends on.
  • Undo. Keep a backup copy before each save so undo can restore the previous state.
  • Due dates. Add cargo add chrono and show what's overdue in red.
🎓 What now? Three projects down. The honest next step is not another tutorial: pick something small that you want to exist and build it with no guide at all. That is the moment you stop being a student of Rust and start being a programmer who uses it. More workshop projects are coming, but do not wait for them.