Skip to content

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.

Here’s what using the finished tool looks like:

Terminal window
$ cargo run -- add Buy milk
Added task 1.
$ cargo run -- add Call grandma
Added task 2.
$ cargo run -- done 1
Marked task 1 as done.
$ cargo run -- list
[x] 1: Buy milk
[ ] 2: Call grandma
$ cargo run -- remove 1
Removed task 1: Buy milk

Everything 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 grandma

Each line is id|done|text, where done is 1 for finished and 0 for not finished.

Start a new project with:

Terminal window
cargo new todo
cd todo

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 milk
Some(Task { id: 2, done: true, text: "Call grandma" })
Some(Task { id: 3, done: false, text: "Read about A|B testing" })
None
▶ Try it Yourself

Two 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 plain split('|'), the task “A|B testing” would lose half its text.
  • from_line returns an Option, and uses ? on each step. If any piece is missing or the id isn’t a number, the whole function returns None. The .ok() turns the Result from parse() into an Option so ? can work on it.

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'")
▶ Try it Yourself

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.

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 milk
Error: there is no task 99
[ ] 2: Call grandma
[x] 3: Learn Rust
▶ Try it Yourself

Some 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_done takes &mut [Task] (a slice) because it only changes items. add_task and remove_task take &mut Vec<Task> because they change the length.
  • Errors are Result<_, String>. A String error 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.

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.

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 list
Nothing to do. Enjoy your day!
$ todo add Buy milk
Added task 1.
$ todo add Call grandma
Added task 2.
$ todo add Learn Rust
Added task 3.
$ todo done 3
Marked task 3 as done.
$ todo remove 1
Removed task 1: Buy milk
$ todo done 9
Error: there is no task 9
$ todo jump
Error: unknown command 'jump'
$ todo list
[ ] 2: Call grandma
[x] 3: Learn Rust
tasks.txt would contain:
2|0|Call grandma
3|1|Learn Rust
▶ Try it Yourself

Change the script and run it again. Try adding a task after removing one and check that the new id isn’t a duplicate.

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:

Terminal window
cargo add serde --features derive
cargo add serde_json

Then 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 back
▶ Try it Yourself

You’d write that JSON string to tasks.json with fs::write, exactly like before. The JSON with serde page covers this in detail.

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.

  • Undo “done”. Add an undone <id> command that sets done back to false.
  • Edit a task. edit 2 Call grandma tonight replaces the text of task 2.
  • Clear finished tasks. clear removes every task that is done. Look at Vec::retain.
  • Warn about broken lines. Instead of skipping bad lines silently, print a warning with the line number.
  • A help command that prints all commands and how to use them.
  • Write tests. parse_command and run_command don’t touch files, so they’re easy to test.
  • Use a real argument parser. The clap crate, mentioned on the Command-Line Arguments page, gives you --help and subcommands for free.
  • A Task struct holds the data. to_line and from_line turn it into text and back.
  • A Command enum describes what the user asked for. parse_command turns arguments into a Command, or an Err with a helpful message.
  • A Vec<Task> holds the list. Small functions add, find, change, and remove tasks.
  • fs::read_to_string and fs::write return Result, 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

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