Build a Rust CLI To-Do App: structs, enums, match, Result and Files
November 10, 2026
Welcome to episode 22 of Rust for Beginners, a series for complete beginners (no programming experience needed). Welcome to the last episode of this beginner series. Today, we'll put everything together and build a real command-line to-do app. Every code block below is the exact code from the video, ready to copy.
- All episodes are in the Rust Tutorial for Beginners playlist.
- Versions used: Rust 1.99.1 on Ubuntu, VS Code with rust-analyzer.
1. Plan
We'll be able to add tasks, list them, mark them done, and remove them, and the tasks are saved to a file.
2. New project
Let's create a new project called todo, and open it in VS Code.
cargo new todo
cd todo3. Task
First, a struct for one task: a title, and whether it's done.
src/main.rs:
struct Task {
title: String,
done: bool,
}
fn print_tasks(tasks: &Vec<Task>) {
if tasks.is_empty() {
println!("Nothing to do!");
}
for (i, task) in tasks.iter().enumerate() {
let mark = if task.done { "x" } else { " " };
println!("{}. [{mark}] {}", i + 1, task.title);
}
}
fn main() {
let mut tasks = Vec::new();
tasks.push(Task { title: String::from("Learn Rust"), done: true });
tasks.push(Task { title: String::from("Build a to-do app"), done: false });
print_tasks(&tasks);
}Output: 1. [x] Learn Rust / 2. [ ] Build a to-do app
4. Vec
All tasks go in a vector.
5. Print
print tasks borrows the list. enumerate gives us each task's index, and we show an x for done tasks, using if as an expression. We add one, because people count from one.
6. Run A
Let's run it.
cargo run7. Commands
Next, the commands. An enum fits perfectly: List, Add with a title, Done with a number, and Remove with a number.
use std::env;
struct Task {
title: String,
done: bool,
}
enum Command {
List,
Add(String),
Done(usize),
Remove(usize),
}
fn parse_command(args: &[String]) -> Result<Command, String> {
let usage = String::from("Usage: list | add <title> | done <n> | remove <n>");
if args.is_empty() {
return Err(usage);
}
match args[0].as_str() {
"list" => Ok(Command::List),
"add" if args.len() == 2 => Ok(Command::Add(args[1].clone())),
"done" if args.len() == 2 => Ok(Command::Done(parse_number(&args[1])?)),
"remove" if args.len() == 2 => Ok(Command::Remove(parse_number(&args[1])?)),
_ => Err(usage),
}
}
fn parse_number(text: &str) -> Result<usize, String> {
text.parse().map_err(|_| format!("'{text}' is not a number"))
}
fn print_tasks(tasks: &Vec<Task>) {
if tasks.is_empty() {
println!("Nothing to do!");
}
for (i, task) in tasks.iter().enumerate() {
let mark = if task.done { "x" } else { " " };
println!("{}. [{mark}] {}", i + 1, task.title);
}
}
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let mut tasks = vec![Task { title: String::from("Learn Rust"), done: false }];
match parse_command(&args) {
Ok(Command::List) => {}
Ok(Command::Add(title)) => tasks.push(Task { title, done: false }),
Ok(Command::Done(n)) => tasks[n - 1].done = true,
Ok(Command::Remove(n)) => {
tasks.remove(n - 1);
}
Err(message) => {
println!("{message}");
return;
}
}
print_tasks(&tasks);
}Outputs:
$ cargo run -- add "Buy milk"
1. [ ] Learn Rust
2. [ ] Buy milk
$ cargo run -- list
1. [ ] Learn Rust
$ cargo run -- done 5
thread 'main' (5473) panicked at src/main.rs:50:38:
index out of bounds: the len is 1 but the index is 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace(Cargo's Compiling / Finished / Running lines also appear; the recording can use cargo run -q to hide them.)
8. Arguments
env args gives us the words typed after the program name. We skip the first one, which is the program itself, and collect the rest into a vector.
9. Parse
parse command turns those words into a Command, or an error message. We match on the first word. The if after a pattern is a guard: add needs exactly two words.
10. ?
parse number uses map err to turn a parse error into our own message, and the question mark passes it up.
11. Run B
To pass arguments through cargo, we write them after two dashes. Let's add a task. It works. But run list again, and it's gone: the tasks only live in memory.
cargo run -- add "Buy milk"
cargo run -- list12. Panic
And done five crashes, because there is no task five. Two things to fix: saving, and checking the number.
cargo run -- done 513. Save
Final stage. save tasks writes each task as one line, with an x or a space, a bar, and the title. fs write saves the text to todo dot t x t.
use std::env;
use std::fs;
const FILE: &str = "todo.txt";
struct Task {
title: String,
done: bool,
}
enum Command {
List,
Add(String),
Done(usize),
Remove(usize),
}
fn parse_command(args: &[String]) -> Result<Command, String> {
let usage = String::from("Usage: list | add <title> | done <n> | remove <n>");
if args.is_empty() {
return Err(usage);
}
match args[0].as_str() {
"list" => Ok(Command::List),
"add" if args.len() == 2 => Ok(Command::Add(args[1].clone())),
"done" if args.len() == 2 => Ok(Command::Done(parse_number(&args[1])?)),
"remove" if args.len() == 2 => Ok(Command::Remove(parse_number(&args[1])?)),
_ => Err(usage),
}
}
fn parse_number(text: &str) -> Result<usize, String> {
text.parse().map_err(|_| format!("'{text}' is not a number"))
}
fn check_index(n: usize, tasks: &Vec<Task>) -> Result<usize, String> {
if n == 0 || n > tasks.len() {
return Err(format!("There is no task {n}"));
}
Ok(n - 1)
}
fn load_tasks() -> Vec<Task> {
let text = fs::read_to_string(FILE).unwrap_or_default();
let mut tasks = Vec::new();
for line in text.lines() {
if let Some((mark, title)) = line.split_once('|') {
tasks.push(Task { title: title.to_string(), done: mark == "x" });
}
}
tasks
}
fn save_tasks(tasks: &Vec<Task>) -> Result<(), String> {
let text: String = tasks
.iter()
.map(|t| format!("{}|{}\n", if t.done { "x" } else { " " }, t.title))
.collect();
fs::write(FILE, text).map_err(|e| format!("Could not save: {e}"))
}
fn print_tasks(tasks: &Vec<Task>) {
if tasks.is_empty() {
println!("Nothing to do!");
}
for (i, task) in tasks.iter().enumerate() {
let mark = if task.done { "x" } else { " " };
println!("{}. [{mark}] {}", i + 1, task.title);
}
}
fn run(args: &[String]) -> Result<(), String> {
let mut tasks = load_tasks();
let command = parse_command(args)?;
match command {
Command::List => {}
Command::Add(title) => tasks.push(Task { title, done: false }),
Command::Done(n) => {
let i = check_index(n, &tasks)?;
tasks[i].done = true;
}
Command::Remove(n) => {
let i = check_index(n, &tasks)?;
tasks.remove(i);
}
}
save_tasks(&tasks)?;
print_tasks(&tasks);
Ok(())
}
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
if let Err(message) = run(&args) {
println!("Error: {message}");
}
}Demo session (verified in the VM, starting with no todo.txt):
$ cargo run -- add "Learn Rust"
1. [ ] Learn Rust
$ cargo run -- add "Build a to-do app"
1. [ ] Learn Rust
2. [ ] Build a to-do app
$ cargo run -- add "Buy milk"
1. [ ] Learn Rust
2. [ ] Build a to-do app
3. [ ] Buy milk
$ cargo run -- done 1
1. [x] Learn Rust
2. [ ] Build a to-do app
3. [ ] Buy milk
$ cargo run -- remove 3
1. [x] Learn Rust
2. [ ] Build a to-do app
$ cargo run -- done 9
Error: There is no task 9
$ cargo run -- done abc
Error: 'abc' is not a number
$ cargo run -- list
1. [x] Learn Rust
2. [ ] Build a to-do app
$ cat todo.txt
x|Learn Rust
|Build a to-do appBefore the take, delete any todo.txt left from a rehearsal (reset.sh).
14. Load
load tasks reads the file back. If there's no file yet, unwrap or default gives us empty text. split once splits each line at the bar.
15. Check
check index returns an error instead of crashing when the number is wrong.
16. run
And run ties it all together. It returns a Result, so every question mark sends errors to main, which prints them.
17. Demo
Let's try it. We add three tasks.
cargo run -- add "Learn Rust"
cargo run -- add "Build a to-do app"
cargo run -- add "Buy milk"18. Done/remove
Mark the first one done, and remove the third.
cargo run -- done 1
cargo run -- remove 319. Errors
Wrong input gives a friendly error now, not a crash.
cargo run -- done 9
cargo run -- done abc20. Saved
And list shows our tasks are still there, because they're saved in todo dot t x t.
cargo run -- listRecap
We used structs, enums, match, vectors, iterators, closures, Option, Result, the question mark, and reading and writing files. That's the whole beginner series in one small app.
21. Ideas
Try adding to it yourself: a command to edit a task, or tests for parse command.