← thecodex.expert · The Codex Family of Knowledge
Tier 1 · Beginner · Rust Project

To-Do List (CLI)

A command-line to-do list that remembers your tasks between runs. Teaches structs, Vec, and persisting state to a file without a database.

🧠 Teaches how to think spoonfed, every age Last verified:

1 The Problem

We want a to-do list you can actually use: add tasks, see them numbered, remove the ones you finish, and — crucially — have them saved to a file so they survive after you close the program. It teaches lists, a menu loop, and saving data to disk.

Where this shows up: every app that stores your stuff — notes, reminders, shopping lists, saved games, settings. The pattern of “keep a list in memory, save it to a file, load it back next time” is the simplest form of a database.

2 How to Think About It

The hard part of any to-do list is not the list — it is remembering it after the program exits. Think in two halves: the in-memory list, and the file that survives between runs.

The plan — in plain English
1. Load tasks from the file at startup (empty list if none exists). → 2. Apply one command: add, done, remove, or list. → 3. Save the updated list back to the file before exiting.

Add

View

Remove

Quit

Load tasks from file

Show menu

Choice?

Add task

Show tasks

Remove task

Stop

Save to file

3 The Build — explained part by part

Here is the complete to-do list. Notice that load_tasks/save_tasks are the only two functions that touch the filesystem — everything else works on a plain Vec<Task> and is trivial to test.

Rustsrc/main.rs
use std::env;
use std::fs;

#[derive(Debug, Clone, PartialEq)]
struct Task {
    done: bool,
    text: String,
}

const FILE_PATH: &str = "tasks.txt";

/// Serializes tasks to a tiny hand-rolled line format: `1|text` for a done
/// task, `0|text` for a pending one. A real project would reach for the
/// `serde` and `serde_json` crates here, the way Go's version of this
/// project used the standard library's own `encoding/json`, but those are
/// external crates and this build environment cannot fetch crates.io, so
/// this is a plain, dependency-free stand-in that is still a real, working
/// file format.
fn serialize(tasks: &[Task]) -> String {
    tasks
        .iter()
        .map(|t| format!("{}|{}", if t.done { 1 } else { 0 }, t.text))
        .collect::<Vec<_>>()
        .join("\n")
}

fn deserialize(data: &str) -> Vec<Task> {
    data.lines()
        .filter(|l| !l.is_empty())
        .filter_map(|line| {
            let (flag, text) = line.split_once('|')?;
            Some(Task {
                done: flag == "1",
                text: text.to_string(),
            })
        })
        .collect()
}

fn load_tasks(path: &str) -> Vec<Task> {
    fs::read_to_string(path)
        .map(|data| deserialize(&data))
        .unwrap_or_default()
}

fn save_tasks(path: &str, tasks: &[Task]) -> std::io::Result<()> {
    fs::write(path, serialize(tasks))
}

fn add_task(tasks: &mut Vec<Task>, text: &str) {
    tasks.push(Task {
        done: false,
        text: text.to_string(),
    });
}

/// Marks the task at `index` (1-based, as shown to the user) done. Returns
/// `false` if the index is out of range, instead of panicking.
fn mark_done(tasks: &mut [Task], index: usize) -> bool {
    match index.checked_sub(1).and_then(|i| tasks.get_mut(i)) {
        Some(task) => {
            task.done = true;
            true
        }
        None => false,
    }
}

/// Removes the task at `index` (1-based). Returns `false` if out of range.
fn remove_task(tasks: &mut Vec<Task>, index: usize) -> bool {
    match index.checked_sub(1) {
        Some(i) if i < tasks.len() => {
            tasks.remove(i);
            true
        }
        _ => false,
    }
}

fn format_list(tasks: &[Task]) -> String {
    tasks
        .iter()
        .enumerate()
        .map(|(i, t)| {
            format!(
                "{}. [{}] {}",
                i + 1,
                if t.done { "x" } else { " " },
                t.text
            )
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn main() {
    let mut tasks = load_tasks(FILE_PATH);
    let args: Vec<String> = env::args().skip(1).collect();

    match args.first().map(String::as_str) {
        Some("add") => {
            let text = args[1..].join(" ");
            add_task(&mut tasks, &text);
            println!("Added: {text}");
        }
        Some("done") => {
            let index: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
            if mark_done(&mut tasks, index) {
                println!("Marked task {index} done.");
            } else {
                println!("No task #{index}.");
            }
        }
        Some("remove") => {
            let index: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
            if remove_task(&mut tasks, index) {
                println!("Removed task {index}.");
            } else {
                println!("No task #{index}.");
            }
        }
        Some("list") | None => {
            println!("{}", format_list(&tasks));
        }
        Some(other) => {
            println!("Unknown command '{other}'. Use add, done, remove, or list.");
        }
    }

    if let Err(e) = save_tasks(FILE_PATH, &tasks) {
        eprintln!("Could not save tasks: {e}");
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trips_through_serialize_and_deserialize() {
        let tasks = vec![
            Task { done: false, text: "Buy milk".into() },
            Task { done: true, text: "Call mom".into() },
        ];
        let data = serialize(&tasks);
        assert_eq!(deserialize(&data), tasks);
    }

    #[test]
    fn mark_done_updates_the_right_task_and_rejects_bad_index() {
        let mut tasks = vec![
            Task { done: false, text: "A".into() },
            Task { done: false, text: "B".into() },
        ];
        assert!(mark_done(&mut tasks, 2));
        assert!(tasks[1].done);
        assert!(!tasks[0].done);
        assert!(!mark_done(&mut tasks, 99));
    }

    #[test]
    fn remove_task_shrinks_the_list() {
        let mut tasks = vec![
            Task { done: false, text: "A".into() },
            Task { done: false, text: "B".into() },
            Task { done: false, text: "C".into() },
        ];
        assert!(remove_task(&mut tasks, 2));
        assert_eq!(tasks.len(), 2);
        assert_eq!(tasks[1].text, "C");
        assert!(!remove_task(&mut tasks, 0));
    }
}
⚠ No in-browser playground here
Rust compiles to a real binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once Rust (via rustup) is installed.
What each part does — in plain words
#[derive(Debug, Clone, PartialEq)] struct Task — derive generates boilerplate implementations automatically: Debug for printing, Clone for copying, and PartialEq for the == comparisons the tests below use — all without writing a line of that code by hand.

serialize / deserialize — a deliberately simple, hand-rolled file format (1|Buy milk) rather than real JSON. A production project would reach for the serde and serde_json crates here, exactly the way Go’s version of this project used its standard library’s own encoding/json. Those are external crates, unavailable in this build environment, which is a genuine and useful difference to notice: Go ships JSON support in its standard library, Rust deliberately keeps it out and expects you to add a crate.

index.checked_sub(1).and_then(|i| tasks.get_mut(i)) — the task numbers shown to the user start at 1, but Rust Vec indices start at 0. checked_sub(1) converts safely — on task “0” it would underflow a usize, so checked_sub returns None instead of panicking, and get_mut then returns None again if the index is simply too large.

tasks.remove(i) — removes the element at index i and shifts every later element down by one, an O(n) operation worth knowing about if the list ever gets large.
Common mistakes — and how to avoid them
✗ Indexing directly with tasks[index - 1] using the user’s 1-based number — if index is 0, index - 1 underflows a usize and panics instead of failing gracefully.
✓ Use checked_sub(1) and .get_mut()/.get(), which return Option instead of panicking on a bad index.
✗ Forgetting to save after every mutating command — the change looks like it worked in that run, but disappears the next time the program starts.
✓ Call save_tasks once, unconditionally, right before main returns, as the code above does.

4 Test & Prove Each Part

We test the persistence format and the mutation logic entirely in memory, with no real file on disk.

A list of tasks survives a round trip through serialize and deserialize unchanged
Marking a task done updates the right one and leaves the rest untouched
Marking an out-of-range task done is rejected instead of panicking
Removing a task shrinks the list and keeps the remaining order
Rustsrc/main.rs (tests module)
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trips_through_serialize_and_deserialize() {
        let tasks = vec![
            Task { done: false, text: "Buy milk".into() },
            Task { done: true, text: "Call mom".into() },
        ];
        let data = serialize(&tasks);
        assert_eq!(deserialize(&data), tasks);
    }

    #[test]
    fn mark_done_updates_the_right_task_and_rejects_bad_index() {
        let mut tasks = vec![
            Task { done: false, text: "A".into() },
            Task { done: false, text: "B".into() },
        ];
        assert!(mark_done(&mut tasks, 2));
        assert!(tasks[1].done);
        assert!(!tasks[0].done);
        assert!(!mark_done(&mut tasks, 99));
    }

    #[test]
    fn remove_task_shrinks_the_list() {
        let mut tasks = vec![
            Task { done: false, text: "A".into() },
            Task { done: false, text: "B".into() },
            Task { done: false, text: "C".into() },
        ];
        assert!(remove_task(&mut tasks, 2));
        assert_eq!(tasks.len(), 2);
        assert_eq!(tasks[1].text, "C");
        assert!(!remove_task(&mut tasks, 0));
    }
}

Run with cargo test. The round-trip test is the most important one: it builds a Vec<Task>, serializes it, deserializes the result, and asserts the two lists are == — which only compiles because Task derives PartialEq.

5 The Interface

INPUTINPUTcommand-line arguments
What it expects
add Buy milk
done 1
remove 2
list
OUTPUTOUTPUTtask list / confirmation
What it returns
1. [x] Buy milk
2. [ ] Call mom

6 Run It & Automate It

Save the code as src/main.rs inside a Cargo project's src/ folder and run it with cargo run — Cargo compiles and executes in one step while you are experimenting, then cargo build --release gives you an optimized binary once you are done.

Run it locally
cargo run -- add Buy milk
Tasks are saved to tasks.txt in the current directory and reloaded automatically next run.

A CI tool like Jenkins runs cargo test automatically whenever the code changes — every line below has a plain explanation.

What you should see when it works
Terminala real run
$ cargo run -- add Buy milk
Added: Buy milk
$ cargo run -- add Call mom
Added: Call mom
$ cargo run -- done 1
Marked task 1 done.
$ cargo run -- list
1. [x] Buy milk
2. [ ] Call mom
If it breaks — how to fix it
🚨 The list is always empty, even after adding tasks.
Check that cargo run is being run from the same directory each time — tasks.txt is created relative to the current working directory.
🚨 Unknown command 'add Buy milk'. Use add, done, remove, or list.
Rust receives each shell word as a separate argument. Make sure you are not quoting the whole command as one string.
GroovyJenkinsfile
// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
    agent any                                  // run on any available machine

    stages {
        stage('Get the code') {
            steps { checkout scm }             // download the latest code
        }
        stage('Set up Rust') {
            steps {
                sh 'rustc --version'                // confirm Rust is installed
                sh 'cargo build'                     // compile, downloading any crates
            }
        }
        stage('Run the tests') {
            steps {
                sh 'cargo clippy -- -D warnings'     // catch obvious mistakes before running
                sh 'cargo test'                       // run every test, show each result
            }
        }
    }

    post {
        success { echo 'All tests passed.' }
        failure { echo 'A test failed — look above.' }
    }
}
🎯 Try this next — make it yours
  1. Add due dates. Extend Task with an Option<String> field. (Teaches: optional struct fields.)
  2. Switch to real JSON. If you have network access, cargo add serde serde_json and derive Serialize/Deserialize on Task. (Teaches: derive macros from an external crate.)
  3. Sort by status. Show incomplete tasks before done ones. (Teaches: Vec::sort_by_key.)
What you learned
You learned to model data with a #[derive]d struct, separate pure logic from file I/O so it stays trivially testable, and handle a user-facing 1-based index safely with checked_sub instead of risking a panic. You also saw a real, honest example of Rust’s thin-standard-library philosophy compared to Go’s batteries-included one. Related: Structs, Collections.