Project: To-Do List CLI
In this project you’ll build a small to-do list that lives in your terminal. You type todo add Buy milk, and it remembers. Close the terminal, come back tomorrow, type todo list, and your tasks are still there.
It’s a great second project because it looks like a real tool. It uses structs, enums, vectors, command-line arguments, file I/O, and Result with ?. We’ll build each piece on its own, test it in the Playground, and then put it all together.
What you’ll build
Section titled “What you’ll build”Here’s what using the finished tool looks like:
$ cargo run -- add Buy milkAdded task 1.$ cargo run -- add Call grandmaAdded task 2.$ cargo run -- done 1Marked task 1 as done.$ cargo run -- list[x] 1: Buy milk[ ] 2: Call grandma$ cargo run -- remove 1Removed task 1: Buy milkEverything after -- is passed to your program, not to Cargo. The tasks are saved in a file called tasks.txt, one task per line:
2|0|Call grandmaEach line is id|done|text, where done is 1 for finished and 0 for not finished.
Start a new project with:
cargo new todocd todoStep 1: The Task struct
Section titled “Step 1: The Task struct”Every task has a number (so you can refer to it), a done flag, and some text. A struct is the natural fit. We also give it two methods: one to turn a task into a line of text for the file, and one to read a task back from a line.
#[derive(Debug)]struct Task { id: u32, done: bool, text: String,}
impl Task { // Turn a task into one line, like "1|0|Buy milk". fn to_line(&self) -> String { let done = if self.done { 1 } else { 0 }; format!("{}|{}|{}", self.id, done, self.text) }
// Read a task back from a line. Returns None if the line is broken. fn from_line(line: &str) -> Option<Task> { let mut parts = line.splitn(3, '|'); let id = parts.next()?.parse().ok()?; let done = parts.next()? == "1"; let text = parts.next()?.to_string(); Some(Task { id, done, text }) }}
fn main() { let task = Task { id: 1, done: false, text: String::from("Buy milk") }; let line = task.to_line(); println!("{line}");
println!("{:?}", Task::from_line("2|1|Call grandma")); println!("{:?}", Task::from_line("3|0|Read about A|B testing")); println!("{:?}", Task::from_line("this line is broken"));}Output:
1|0|Buy milkSome(Task { id: 2, done: true, text: "Call grandma" })Some(Task { id: 3, done: false, text: "Read about A|B testing" })NoneTwo details are worth slowing down for:
splitn(3, '|')splits into at most 3 pieces. The first two|characters separate the fields, and everything after that is the text, even if the text contains a|itself. With plainsplit('|'), the task “A|B testing” would lose half its text.from_linereturns anOption, and uses?on each step. If any piece is missing or the id isn’t a number, the whole function returnsNone. The.ok()turns theResultfromparse()into anOptionso?can work on it.
Step 2: The Command enum
Section titled “Step 2: The Command enum”The tool understands four commands. Each one is a variant of an enum, and some variants carry data: Add needs the text, Done and Remove need a task number.
#[derive(Debug)]enum Command { Add(String), List, Done(u32), Remove(u32),}
fn parse_id(arg: Option<&String>) -> Result<u32, String> { let arg = arg.ok_or(String::from("please give a task number"))?; arg.parse().map_err(|_| format!("'{arg}' is not a task number"))}
fn parse_command(args: &[String]) -> Result<Command, String> { match args.first().map(|s| s.as_str()) { None | Some("list") => Ok(Command::List), Some("add") => { let text = args[1..].join(" "); if text.is_empty() { Err(String::from("usage: todo add <task text>")) } else { Ok(Command::Add(text)) } } Some("done") => parse_id(args.get(1)).map(Command::Done), Some("remove") => parse_id(args.get(1)).map(Command::Remove), Some(other) => Err(format!("unknown command '{other}'")), }}
fn main() { let examples = [ vec!["add", "Buy", "milk"], vec!["list"], vec![], vec!["done", "2"], vec!["done", "two"], vec!["remove"], vec!["dance"], ];
for words in examples { let args: Vec<String> = words.iter().map(|w| w.to_string()).collect(); println!("{:?} -> {:?}", words, parse_command(&args)); }}Output:
["add", "Buy", "milk"] -> Ok(Add("Buy milk"))["list"] -> Ok(List)[] -> Ok(List)["done", "2"] -> Ok(Done(2))["done", "two"] -> Err("'two' is not a task number")["remove"] -> Err("please give a task number")["dance"] -> Err("unknown command 'dance'")In the real program, args comes from the command line. As you saw on the Command-Line Arguments page, the first item from std::env::args() is the program’s own name, so we skip it:
let args: Vec<String> = std::env::args().skip(1).collect();Why does add use args[1..].join(" ")? Your shell splits todo add Buy milk into separate words: add, Buy, milk. Joining everything after add puts the task text back together, so you don’t need quotes.
Notice that parse_command never crashes. Bad input becomes an Err with a helpful message. The caller decides what to do with it.
Step 3: Working with the list of tasks
Section titled “Step 3: Working with the list of tasks”All the tasks live in a Vec<Task>. Each command is a small function that changes the vector:
- Add: find the biggest id so far, add 1, and push a new task.
- Done: find the task with that id and set
done = true. - Remove: find the task’s position and remove it.
- List: print every task.
#[derive(Debug)]struct Task { id: u32, done: bool, text: String,}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 { let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1; tasks.push(Task { id, done: false, text }); id}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> { match tasks.iter_mut().find(|t| t.id == id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("there is no task {id}")), }}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> { match tasks.iter().position(|t| t.id == id) { Some(index) => Ok(tasks.remove(index)), None => Err(format!("there is no task {id}")), }}
fn print_tasks(tasks: &[Task]) { if tasks.is_empty() { println!("Nothing to do. Enjoy your day!"); return; } for task in tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.text); }}
fn main() { let mut tasks = Vec::new(); add_task(&mut tasks, String::from("Buy milk")); add_task(&mut tasks, String::from("Call grandma")); add_task(&mut tasks, String::from("Learn Rust"));
mark_done(&mut tasks, 3).unwrap(); let removed = remove_task(&mut tasks, 1).unwrap(); println!("Removed: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) { println!("Error: {message}"); }
print_tasks(&tasks);}Output:
Removed: Buy milkError: there is no task 99[ ] 2: Call grandma[x] 3: Learn RustSome choices here are on purpose:
- Ids come from the biggest existing id, not from
tasks.len() + 1. If you remove task 1 and then add a new one, using the length would give you a duplicate id. mark_donetakes&mut [Task](a slice) because it only changes items.add_taskandremove_tasktake&mut Vec<Task>because they change the length.- Errors are
Result<_, String>. AStringerror is the simplest thing that works. For bigger programs, see Custom Errors.
iter_mut().find(...) gives you a mutable reference to the matching task, which is why task.done = true changes the task inside the vector. See Iterators for more on find and position.
Step 4: Saving and loading the file
Section titled “Step 4: Saving and loading the file”Now the tasks need to survive after the program ends. We save them with std::fs::write and load them with std::fs::read_to_string. Both can fail, so both return Result, and the ? operator passes any error up to the caller:
use std::fs;use std::io;
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> { let mut text = String::new(); for task in tasks { text.push_str(&task.to_line()); text.push('\n'); } fs::write(path, text)?; Ok(())}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> { let text = match fs::read_to_string(path) { Ok(text) => text, // The first time you run the program, there's no file yet. // That's not an error: it just means "no tasks". Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(e), }; let tasks = text.lines().filter_map(Task::from_line).collect(); Ok(tasks)}The NotFound case matters more than it looks. Without it, the very first todo add would fail with “No such file or directory”, because you haven’t saved anything yet. Every other error (like “permission denied”) is still passed up.
filter_map(Task::from_line) reads each line and keeps only the ones that parse. Be aware that this quietly skips broken lines. That’s friendly if someone edits the file by hand and makes a typo, but it also means a mistake could go unnoticed. An extension idea below is to warn about them instead.
See File I/O for more on reading and writing files.
Step 5: The complete program
Section titled “Step 5: The complete program”Here’s everything together. Put it in src/main.rs and run it with cargo run -- add Buy milk, cargo run -- list, and so on.
The core logic is in run_command, which works on a Vec<Task> and doesn’t know anything about files. main just loads the file, calls run_command, and saves. Keeping those apart makes the logic easy to test, and it’s what lets us build a Playground version in the next step.
use std::env;use std::error::Error;use std::fs;use std::io;use std::process;
const FILE: &str = "tasks.txt";
#[derive(Debug)]struct Task { id: u32, done: bool, text: String,}
impl Task { fn to_line(&self) -> String { let done = if self.done { 1 } else { 0 }; format!("{}|{}|{}", self.id, done, self.text) }
fn from_line(line: &str) -> Option<Task> { let mut parts = line.splitn(3, '|'); let id = parts.next()?.parse().ok()?; let done = parts.next()? == "1"; let text = parts.next()?.to_string(); Some(Task { id, done, text }) }}
#[derive(Debug)]enum Command { Add(String), List, Done(u32), Remove(u32),}
fn parse_id(arg: Option<&String>) -> Result<u32, String> { let arg = arg.ok_or(String::from("please give a task number"))?; arg.parse().map_err(|_| format!("'{arg}' is not a task number"))}
fn parse_command(args: &[String]) -> Result<Command, String> { match args.first().map(|s| s.as_str()) { None | Some("list") => Ok(Command::List), Some("add") => { let text = args[1..].join(" "); if text.is_empty() { Err(String::from("usage: todo add <task text>")) } else { Ok(Command::Add(text)) } } Some("done") => parse_id(args.get(1)).map(Command::Done), Some("remove") => parse_id(args.get(1)).map(Command::Remove), Some(other) => Err(format!("unknown command '{other}'")), }}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 { let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1; tasks.push(Task { id, done: false, text }); id}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> { match tasks.iter_mut().find(|t| t.id == id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("there is no task {id}")), }}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> { match tasks.iter().position(|t| t.id == id) { Some(index) => Ok(tasks.remove(index)), None => Err(format!("there is no task {id}")), }}
fn print_tasks(tasks: &[Task]) { if tasks.is_empty() { println!("Nothing to do. Enjoy your day!"); return; } for task in tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.text); }}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> { match parse_command(args)? { Command::Add(text) => { let id = add_task(tasks, text); println!("Added task {id}."); } Command::List => print_tasks(tasks), Command::Done(id) => { mark_done(tasks, id)?; println!("Marked task {id} as done."); } Command::Remove(id) => { let task = remove_task(tasks, id)?; println!("Removed task {id}: {}", task.text); } } Ok(())}
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> { let mut text = String::new(); for task in tasks { text.push_str(&task.to_line()); text.push('\n'); } fs::write(path, text)?; Ok(())}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> { let text = match fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(e), }; let tasks = text.lines().filter_map(Task::from_line).collect(); Ok(tasks)}
fn run() -> Result<(), Box<dyn Error>> { let args: Vec<String> = env::args().skip(1).collect(); let mut tasks = load_tasks(FILE)?; run_command(&args, &mut tasks)?; save_tasks(FILE, &tasks)?; Ok(())}
fn main() { if let Err(e) = run() { eprintln!("Error: {e}"); process::exit(1); }}A note on run() -> Result<(), Box<dyn Error>>. Our functions return two different error types: io::Error from the file functions and String from the command functions. Box<dyn Error> is a box that can hold any error type, so ? works on both. main then prints the error with eprintln! (which writes to the error output) and exits with code 1, the usual signal that something went wrong.
Step 6: A version you can run in the Playground
Section titled “Step 6: A version you can run in the Playground”The Playground can’t pass command-line arguments, and we don’t want it to depend on files. So this version keeps the same run_command, but feeds it a scripted list of commands and prints what tasks.txt would contain at the end:
#[derive(Debug)]struct Task { id: u32, done: bool, text: String,}
impl Task { fn to_line(&self) -> String { let done = if self.done { 1 } else { 0 }; format!("{}|{}|{}", self.id, done, self.text) }}
#[derive(Debug)]enum Command { Add(String), List, Done(u32), Remove(u32),}
fn parse_id(arg: Option<&String>) -> Result<u32, String> { let arg = arg.ok_or(String::from("please give a task number"))?; arg.parse().map_err(|_| format!("'{arg}' is not a task number"))}
fn parse_command(args: &[String]) -> Result<Command, String> { match args.first().map(|s| s.as_str()) { None | Some("list") => Ok(Command::List), Some("add") => { let text = args[1..].join(" "); if text.is_empty() { Err(String::from("usage: todo add <task text>")) } else { Ok(Command::Add(text)) } } Some("done") => parse_id(args.get(1)).map(Command::Done), Some("remove") => parse_id(args.get(1)).map(Command::Remove), Some(other) => Err(format!("unknown command '{other}'")), }}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 { let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1; tasks.push(Task { id, done: false, text }); id}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> { match tasks.iter_mut().find(|t| t.id == id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("there is no task {id}")), }}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> { match tasks.iter().position(|t| t.id == id) { Some(index) => Ok(tasks.remove(index)), None => Err(format!("there is no task {id}")), }}
fn print_tasks(tasks: &[Task]) { if tasks.is_empty() { println!("Nothing to do. Enjoy your day!"); return; } for task in tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.text); }}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> { match parse_command(args)? { Command::Add(text) => { let id = add_task(tasks, text); println!("Added task {id}."); } Command::List => print_tasks(tasks), Command::Done(id) => { mark_done(tasks, id)?; println!("Marked task {id} as done."); } Command::Remove(id) => { let task = remove_task(tasks, id)?; println!("Removed task {id}: {}", task.text); } } Ok(())}
fn main() { // Each line is what you'd type after `todo`. let script = [ "list", "add Buy milk", "add Call grandma", "add Learn Rust", "done 3", "remove 1", "done 9", "jump", "list", ];
let mut tasks: Vec<Task> = Vec::new();
for line in script { println!("$ todo {line}"); let args: Vec<String> = line.split_whitespace().map(String::from).collect(); if let Err(message) = run_command(&args, &mut tasks) { println!("Error: {message}"); } }
println!(); println!("tasks.txt would contain:"); for task in &tasks { println!("{}", task.to_line()); }}Output:
$ todo listNothing to do. Enjoy your day!$ todo add Buy milkAdded task 1.$ todo add Call grandmaAdded task 2.$ todo add Learn RustAdded task 3.$ todo done 3Marked task 3 as done.$ todo remove 1Removed task 1: Buy milk$ todo done 9Error: there is no task 9$ todo jumpError: unknown command 'jump'$ todo list[ ] 2: Call grandma[x] 3: Learn Rust
tasks.txt would contain:2|0|Call grandma3|1|Learn RustChange the script and run it again. Try adding a task after removing one and check that the new id isn’t a duplicate.
Upgrade: save as JSON with serde
Section titled “Upgrade: save as JSON with serde”Our id|done|text format is easy to read, but you had to write to_line and from_line by hand. As your task grows (due dates, priorities, tags), that hand-written code grows too.
In real projects, people usually let the serde crates do this work. Add them with:
cargo add serde --features derivecargo add serde_jsonThen one derive line gives you saving and loading for free:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]struct Task { id: u32, done: bool, text: String,}
fn main() { let tasks = vec![ Task { id: 1, done: false, text: String::from("Buy milk") }, Task { id: 2, done: true, text: String::from("Call grandma") }, ];
let json = serde_json::to_string(&tasks).unwrap(); println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap(); println!("Loaded {} tasks back", loaded.len());}Output:
[{"id":1,"done":false,"text":"Buy milk"},{"id":2,"done":true,"text":"Call grandma"}]Loaded 2 tasks backYou’d write that JSON string to tasks.json with fs::write, exactly like before. The JSON with serde page covers this in detail.
Common mistakes
Section titled “Common mistakes”Using ? in a main that returns nothing. It’s tempting to write this:
use std::fs;
fn main() { let text = fs::read_to_string("tasks.txt")?; println!("{text}");}error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`) --> src/main.rs:4:47 |3 | fn main() { | --------- this function should return `Result` or `Option` to accept `?`4 | let text = fs::read_to_string("tasks.txt")?; | ^ cannot use the `?` operator in a function that returns `()`? means “if this is an error, return it from the current function”. A plain main returns (), so there’s nowhere to send the error. Either give main a return type like Result<(), Box<dyn Error>>, or do what our program does: put the work in a run() function that returns Result, and handle the error once in main.
Treating a missing file as a failure. If load_tasks just did fs::read_to_string(path)?, the program would fail on the very first run, before any file exists. Check for io::ErrorKind::NotFound and start with an empty list instead.
Using the list length as the next id. With tasks 1, 2, 3, removing task 1 leaves two tasks. len() + 1 would give 3 again, and now two tasks share an id. Use the biggest id plus one.
Ideas to extend it
Section titled “Ideas to extend it”- Undo “done”. Add an
undone <id>command that setsdoneback tofalse. - Edit a task.
edit 2 Call grandma tonightreplaces the text of task 2. - Clear finished tasks.
clearremoves every task that is done. Look atVec::retain. - Warn about broken lines. Instead of skipping bad lines silently, print a warning with the line number.
- A
helpcommand that prints all commands and how to use them. - Write tests.
parse_commandandrun_commanddon’t touch files, so they’re easy to test. - Use a real argument parser. The
clapcrate, mentioned on the Command-Line Arguments page, gives you--helpand subcommands for free.
- A
Taskstruct holds the data.to_lineandfrom_lineturn it into text and back. - A
Commandenum describes what the user asked for.parse_commandturns arguments into aCommand, or anErrwith a helpful message. - A
Vec<Task>holds the list. Small functions add, find, change, and remove tasks. fs::read_to_stringandfs::writereturnResult, and?passes errors up. A missing file on first run is handled as “no tasks”.- Keeping the logic (
run_command) separate from files and arguments makes it easy to test and to run in the Playground.
Quick check
Section titled “Quick check”Quick check
1. Why does from_line use splitn(3, '|') instead of split('|')?
2. What does load_tasks do when tasks.txt doesn't exist yet?
3. Why can't you use ? directly inside fn main() with no return type?
4. Which crate is the usual upgrade for saving structs as JSON?
Score: 0 / 4
Read more
Section titled “Read more”- An I/O Project: Building a Command Line Program — The Rust Book: a bigger guided CLI project, with good advice on splitting
maininto smaller, testable pieces. std::fs— std docs:read_to_string,write, and the other file functions behind the save and load code.std::env::args— std docs: how command-line arguments arrive, and why the first one is the program’s own name.str::splitn— std docs: splitting a line into at most N pieces. It is the trick that keeps a task text containing|in one piece.serde_json— docs.rs: the JSON crate from the upgrade section, withto_stringandfrom_strexamples. The serde guide explains the derive attributes.