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>, andremove <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.
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. Usetasks.get_mut(i)and handleNone:
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.
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 deriveandcargo 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
undocan restore the previous state. - Due dates. Add
cargo add chronoand show what's overdue in red.