DEV Community

Tobias weissmann
Tobias weissmann

Posted on

A Rust To-Do List CLI App (Part 1): Clap, TDD, and a HashMap That Behaves

I like small tools that disappear into your workflow.

Not “disappear” as in crash silently — disappear as in you stop noticing they exist because they just work. A tiny CLI you can run 30 times a day without thinking. Fast. Predictable. Boring in the best way.

So for my first “real” Rust project, I followed a classic learning move: rebuild something simple, but treat it like it’s going to ship.

This post is a guided walkthrough inspired by (and crediting) Claudio Restifo’s to-do CLI tutorial. I’m using his approach as a reference point while adding my own beginner-friendly notes and a more TDD-ish mindset. Any code snippets shown below are either directly from, or adapted from, that tutorial and the Rust Book examples — this is not presented as original code. (Mistakes in the explanations, if any, are mine.)

Production Grade Rust CLI: https://tobiweissmann.gumroad.com/l/vxlefy

What we’re building (and why it’s worth it)
A tiny CLI called todo that can:

Manage a list of entries: each item is either to-do or done
Provide commands to:
list
add "Buy milk"
delete "Buy milk" (we’ll wire this in later)
mark-done "Buy milk"
mark-todo "Buy milk" (later)
Persist data to a file (not in Part 1)
The interaction should feel like:

$ todo list

TO DO

  • Write post
  • Buy milk # DONE
  • Feed the cat $ todo add "Update CV" $ todo mark-done "Buy milk" $ todo list # TO DO
  • Write post
  • Update CV # DONE
  • Feed the cat
  • Buy milk This is intentionally “small.” The goal is not to build a universe. The goal is to learn Rust fundamentals the way you learn a language for real: by trying to build something end-to-end.

Step 0: Cargo gives you a project skeleton in 10 seconds
cargo new todo-cli
cd todo-cli
Cargo will create:

Cargo.toml (metadata + dependencies)
src/main.rs (entry point)
The default main.rs is the usual:

fn main() {
    println!("Hello, world!");
}
Enter fullscreen mode Exit fullscreen mode

Run it:

cargo run
Build it:

cargo build
Test it:

cargo test
Cargo isn’t just a build tool — it’s the workflow. If you learn nothing else early, learn how Cargo wants you to live.

Step 1: CLI parsing the “naive” way (and why we won’t stay there)
The classic Unix pattern is reading args directly:


let action = std::env::args().nth(1).expect("Please specify an action");
let item   = std::env::args().nth(2).expect("Please specify an item");
Enter fullscreen mode Exit fullscreen mode

This works, but it scales badly. You’ll quickly end up reinventing:

validation
help text
optional flags
subcommands
defaults
Rust will happily let you write this… and then politely let you regret it.

Step 2: Let Clap do the hard work
Clap is the standard go-to for Rust CLIs.

Install it:

cargo add clap --features derive
Minimal parsing with derive (adapted from common Clap patterns):

use clap::Parser;

#[derive(Parser)]
struct Cli {
    command: String,
    key: Option<String>,
}

fn main() {
    let args = Cli::parse();
    println!("Command: {:?}", args.command);
    println!("Key: {:?}", args.key);
}
Enter fullscreen mode Exit fullscreen mode

Why derive matters
Rust’s #[derive(...)] is everywhere because it compresses a lot of boilerplate into readable intent. You don’t need to master procedural macros on day one—just get comfortable using them.

Step 3: The data model — a deliberately simple TodoList
Claudio’s tutorial uses a HashMap, which is a clean beginner-friendly choice:

key = item text (String)
value = status (bool)
true → to-do
false → done
Yes, a bool is slightly “too clever” (an enum would be clearer). But as a learning tool, it’s perfect: you get storage + state with minimal ceremony.

Start with a test (tiny TDD)
Rust lets you put unit tests in the same file:

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

    #[test]
    fn init_todo() {
        let _todo = TodoList::new();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now your compiler forces you to create the type.

A minimal implementation (structure and approach are aligned with the referenced tutorial, but condensed):

use std::collections::HashMap;

struct TodoList {
    items: HashMap<String, bool>,
}
impl TodoList {
    fn new() -> TodoList {
        TodoList { items: HashMap::new() }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the first “Rust truth” you’ll internalize:

Rust rewards you for building tiny, correct pieces that compile.

Step 4: Add items (and learn what &mut self really means)
Test first:

#[test]
fn add_item() {
    let mut todo = TodoList::new();
    todo.add("Something to do".to_string());
    assert_eq!(todo.items.get("Something to do"), Some(&true));
}
Implementation:

impl TodoList {
    fn add(&mut self, key: String) {
        self.items.insert(key, true);
    }
}
Enter fullscreen mode Exit fullscreen mode

The important bit: &mut self
Read it like this (it helps):

&mut self = “borrow the struct mutably so I can change its internals”
The reference isn’t “mutating.” The data behind it is.

This is where Rust starts training your brain to be explicit about changes — and once you get used to it, it’s incredibly calming.

Step 5: Don’t overwrite existing items (entry API = superpower)
You may want: “adding the same item twice shouldn’t reset its status.”

Test (conceptually):

add item
manually mark it done
add again
status should remain done
To achieve this, HashMap::entry is your friend. It lets you express the idea:

“Only insert if the key doesn’t exist.”

In Rust, that often looks like:

check if entry is vacant
insert only then
This pattern matters beyond CLIs — this is the same mental model you’ll use in caches, deduping, idempotency, and more.

Step 6: Mark items as done / to-do (Option → Result, the Rust way)
We want:

mark(key, value) updates the item if it exists
if it doesn’t exist, return an error
Test idea:

#[test]
fn mark_item_does_not_exist() {
    let mut todo = TodoList::new();
assert!(todo.mark("Missing".to_string(), false).is_err());
}
Enter fullscreen mode Exit fullscreen mode

Implementation idea:

use get_mut → returns Option<&mut bool>
convert Option → Result
use ? to propagate errors cleanly
This is the part where Rust starts feeling professional.

Why ? feels like cheating (in a good way)
Instead of writing nested match blocks forever, you let errors bubble up naturally—but still explicitly. It’s one of the most productivity-friendly language features Rust has.

Step 7: List items without mixing logic and printing
A surprisingly “real world” rule:

If you mix business logic and printing, your CLI becomes hard to test and hard to evolve.

So we want a list() method that returns two groups:

items where status is true (to-do)
items where status is false (done)
A clean approach is to return iterators filtered by status. (This is the kind of thing Rust does extremely well: lazily, without allocating extra memory unless you ask for it.)

Then the CLI layer decides how to print them.

Step 8: Wire methods into CLI commands (with match)
We parse the command and run the correct operation:

add needs a key
mark-done needs a key
list doesn’t
In Rust, match is the natural control flow tool here.

A key nuance: each branch should resolve to a uniform result type (often Result<(), String>), so the program can print a success/error outcome consistently.

This is where you’ll naturally practice:

Option handling (Some/None)
converting Option to Result
formatting error messages
keeping side effects (printing) near the edge
What you’ve quietly learned in Part 1
Even without persistence, this “tiny” app already touched the core Rust muscles:

Cargo as your workflow
Clap for real CLI parsing
struct + impl as your “object model”
HashMap + entry API for safe updates
Ownership and &mut self without tears
Option, Result, and ? as your error language
Iterators as a performance-friendly default
match as a clean command router
This is why small projects matter: they force you to connect the dots.

What’s next (Part 2)
Right now the CLI “forgets” everything when it exits.

In Part 2, we’ll add persistence the right way:

default storage file (and a --file option)
load on startup, save on updates
pick a format (JSON/TOML) and keep it stable
tighten error handling so failures are actionable
(optional) add integration tests for the CLI surface
Credits (important)
This walkthrough is inspired by and based on Claudio Restifo’s Rust to-do list CLI tutorial. The structure and several implementation ideas (CLI flow + HashMap-based state management) follow that reference, and the Rust concepts are cross-checked against the Rust Book. This post is intended as an educational “learning notes” version, not as an originality claim.

Production Grade Rust CLI: https://tobiweissmann.gumroad.com/l/vxlefy

Top comments (0)