← thecodex.expert · The Codex Family of Knowledge
Tier 2 · Intermediate · Rust Project

CLI Task Manager

A sturdier command-line task manager with permanent, never-reused task IDs. Teaches manual argument handling, ID management, and file persistence in Rust.

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

1 The Problem

We want a real command-line tool: tasks add "Buy milk", tasks list, tasks done 2 — commands and arguments, just like git or npm. It teaches argparse, the proper way to build CLI tools that feel professional, not like a toy menu.

Where this shows up: every developer tool — git, docker, npm, pip. Building proper CLIs with subcommands and arguments is a core skill for automation, dev tools, and scripts others will use.

2 How to Think About It

The one design decision that matters here: tasks are identified by a permanent ID, not by their position in the list, so removing or reordering tasks never changes what a saved ID points to.

The plan — in plain English
1. Load tasks (each with an ID) from disk. → 2. Read the subcommand from std::env::args(): add, list, or done. → 3. Apply it, generating a fresh, never-reused ID for a new task. → 4. Save before exiting.

add

list

done

Command line input

argparse parses it

Which subcommand?

Add task

Show tasks

Mark task done

Save to file

3 The Build — explained part by part

Here is the complete manager. A real project would reach for the clap crate to parse arguments — unreachable here, since crates.io is not in this sandbox's network allowlist — so main matches on args.first() by hand, which is worth seeing once before automating it away.

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

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

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

/// Hand-rolled `id|done|text` line format — see to-do-list's write-up for why
/// this project does not reach for `serde_json`: crates.io is unavailable in
/// this build environment.
fn serialize(tasks: &[Task]) -> String {
    tasks
        .iter()
        .map(|t| format!("{}|{}|{}", t.id, 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 mut parts = line.splitn(3, '|');
            let id: u32 = parts.next()?.parse().ok()?;
            let done = parts.next()? == "1";
            let text = parts.next()?.to_string();
            Some(Task { id, done, text })
        })
        .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))
}

/// Task IDs count up forever and are never reused, even after a task is
/// removed — the same guarantee a real database's auto-increment primary key
/// gives you, so a script that saved an ID earlier never points at the wrong
/// task later.
fn next_id(tasks: &[Task]) -> u32 {
    tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1
}

fn add_task(tasks: &mut Vec<Task>, text: &str) -> u32 {
    let id = next_id(tasks);
    tasks.push(Task { id, done: false, text: text.to_string() });
    id
}

fn mark_done(tasks: &mut [Task], id: u32) -> bool {
    match tasks.iter_mut().find(|t| t.id == id) {
        Some(task) => {
            task.done = true;
            true
        }
        None => false,
    }
}

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

/// Everything before `main` is pure logic, testable with no CLI involved at
/// all. `main` itself is a thin translation from `std::env::args()` into
/// calls on that logic — the manual version of what a crate like `clap`
/// automates, worth seeing once before reaching for the crate.
fn print_usage() {
    eprintln!("Usage: cli_task_manager <add|list|done> [args]");
    eprintln!("  add <text...>   add a new task");
    eprintln!("  list            show all tasks");
    eprintln!("  done <id>       mark task <id> done");
}

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") => {
            if args.len() < 2 {
                print_usage();
                return;
            }
            let text = args[1..].join(" ");
            let id = add_task(&mut tasks, &text);
            println!("Added #{id}: {text}");
        }
        Some("list") => {
            println!("{}", format_list(&tasks));
        }
        Some("done") => {
            let id: Option<u32> = args.get(1).and_then(|s| s.parse().ok());
            match id {
                Some(id) if mark_done(&mut tasks, id) => println!("Marked #{id} done."),
                Some(id) => println!("No task #{id}."),
                None => print_usage(),
            }
        }
        _ => print_usage(),
    }

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

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

    #[test]
    fn ids_count_up_and_are_never_reused() {
        let mut tasks = Vec::new();
        let id1 = add_task(&mut tasks, "first");
        let id2 = add_task(&mut tasks, "second");
        assert_eq!(id1, 1);
        assert_eq!(id2, 2);
        tasks.retain(|t| t.id != id1); // simulate removing task 1
        let id3 = add_task(&mut tasks, "third");
        assert_eq!(id3, 3); // not reused as 1
    }

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

    #[test]
    fn round_trips_through_serialize_and_deserialize() {
        let tasks = vec![
            Task { id: 1, done: true, text: "Ship the release".into() },
            Task { id: 2, done: false, text: "Write the docs".into() },
        ];
        assert_eq!(deserialize(&serialize(&tasks)), tasks);
    }
}
⚠ 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
fn next_id(tasks: &[Task]) -> u32 — finds the highest existing ID and adds one, so IDs only ever go up, even after a task with a high ID is removed. This mirrors what a real database’s auto-increment primary key guarantees, and it is the reason this project is sturdier than the basic to-do-list, which used list position and would have silently renumbered every task after a removal.

tasks.iter_mut().find(|t| t.id == id) — looks a task up by its permanent ID rather than by index, so done 5 always means the same task no matter what else has changed.

match args.first().map(String::as_str) { Some("add") => ..., ... } — the manual argument dispatcher. args.first() gives an Option<&String>; .map(String::as_str) turns that into Option<&str> so it can be matched against string literals directly.
Common mistakes — and how to avoid them
✗ Using the task’s position in the Vec as its identity (like the basic to-do-list project does) — removing task #2 silently turns what was task #3 into the new #2, breaking any reference to it saved elsewhere.
✓ Give every task a permanent ID at creation time and look tasks up by that ID, never by position.
✗ Computing the next ID as tasks.len() + 1 — this reuses IDs the moment any task has ever been removed, since the count shrinks back down.
✓ Take the maximum existing ID and add one, as next_id does, so removed IDs are never recycled.

4 Test & Prove Each Part

We test the ID-management guarantee directly, since it is the whole point of this project over the basic to-do-list.

IDs count up and are never reused, even after a task is removed
Marking a task done finds it by ID, not by its position in the list
A list of tasks survives a round trip through serialize and deserialize
Rustsrc/main.rs (tests module)
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ids_count_up_and_are_never_reused() {
        let mut tasks = Vec::new();
        let id1 = add_task(&mut tasks, "first");
        let id2 = add_task(&mut tasks, "second");
        assert_eq!(id1, 1);
        assert_eq!(id2, 2);
        tasks.retain(|t| t.id != id1); // simulate removing task 1
        let id3 = add_task(&mut tasks, "third");
        assert_eq!(id3, 3); // not reused as 1
    }

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

    #[test]
    fn round_trips_through_serialize_and_deserialize() {
        let tasks = vec![
            Task { id: 1, done: true, text: "Ship the release".into() },
            Task { id: 2, done: false, text: "Write the docs".into() },
        ];
        assert_eq!(deserialize(&serialize(&tasks)), tasks);
    }
}

Run with cargo test. The ID-reuse test is the one that actually proves the design decision: it adds two tasks, removes the first, adds a third, and asserts the third gets ID 3 — not 1.

5 The Interface

INPUTINPUTcommand-line arguments
What it expects
add Ship the release
done 1
list
OUTPUTOUTPUTtask list / confirmation
What it returns
#1 [x] Ship the release
#2 [ ] Write the docs

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 Ship the release
Tasks persist in tasks.db in the current directory between runs.

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 Ship the release
Added #1: Ship the release
$ cargo run -- add Write the docs
Added #2: Write the docs
$ cargo run -- done 1
Marked #1 done.
$ cargo run -- list
#1 [x] Ship the release
#2 [ ] Write the docs
If it breaks — how to fix it
🚨 Usage: cli_task_manager <add|list|done> [args]
Printed whenever the first argument is missing or not recognized. Check for typos in the subcommand.
🚨 No task #7.
The ID does not exist — run list first to see the real IDs currently in use; they are not necessarily 1, 2, 3 if any tasks have been removed by hand-editing tasks.db.
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 a remove command. Delete a task by ID without renumbering the rest. (Teaches: Vec::retain.)
  2. Use the real clap crate. If you have network access, cargo add clap --features derive and replace the manual matching. (Teaches: declarative CLI parsing.)
  3. Add priorities. Sort list output by a priority field. (Teaches: Vec::sort_by_key on a derived field.)
What you learned
You learned why identity-by-position breaks under mutation, and how to give records a permanent, never-reused ID by hand — the same problem a database’s auto-increment column solves. You also saw what manual CLI argument dispatch looks like before reaching for a crate like clap. Related: Structs, Collections.