M Logo
๐Ÿ“ Rust for Beginners ยท Part 22 of 22

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.

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.

bash
cargo new todo
cd todo

3. Task

First, a struct for one task: a title, and whether it's done.

src/main.rs:

rust
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.

bash
cargo run

7. Commands

Next, the commands. An enum fits perfectly: List, Add with a title, Done with a number, and Remove with a number.

rust
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:

text
$ 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.

bash
cargo run -- add "Buy milk"
cargo run -- list

12. Panic

And done five crashes, because there is no task five. Two things to fix: saving, and checking the number.

bash
cargo run -- done 5

13. 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.

rust
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):

text
$ 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 app

Before 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.

bash
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.

bash
cargo run -- done 1
cargo run -- remove 3

19. Errors

Wrong input gives a friendly error now, not a crash.

bash
cargo run -- done 9
cargo run -- done abc

20. Saved

And list shows our tasks are still there, because they're saved in todo dot t x t.

bash
cargo run -- list

Recap

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.

ยฉ 2026 All Rights Reserved By Mahmudul Alam