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.
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.
std::env::args(): add, list, or done. → 3. Apply it, generating a fresh, never-reused ID for a new task. → 4. Save before exiting.
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.
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);
}
}
rustup) is installed.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.
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.tasks.len() + 1 — this reuses IDs the moment any task has ever been removed, since the count shrinks back down.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.
#[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
What it expects
add Ship the release
done 1
listWhat it returns
#1 [x] Ship the release
#2 [ ] Write the docs6 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.
cargo run -- add Ship the releaseTasks 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.
$ 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 docslist 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.// 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.' }
}
}
- Add a remove command. Delete a task by ID without renumbering the rest. (Teaches:
Vec::retain.) - Use the real
clapcrate. If you have network access,cargo add clap --features deriveand replace the manual matching. (Teaches: declarative CLI parsing.) - Add priorities. Sort
listoutput by a priority field. (Teaches:Vec::sort_by_keyon a derived field.)
clap. Related: Structs, Collections.