Capstone: A CLI Tool
Reviewed & published by Brayan K
Rust is a systems programming language focused on speed, memory safety, and fearless concurrency — and a command-line tool is the perfect capstone, weaving together structs, enums, error handling, collections, and iterators into one real program.
Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll read command-line arguments, dispatch subcommands, model data with structs and a HashMap, and handle errors with Result — building a small task manager.
What You'll Learn in This Lesson
- Reading command-line args with env::args
- Dispatching subcommands with match
- Modeling data with structs and methods
- Storing state in a HashMap
- Handling errors with Result and ok_or
- Combining iterators, ownership, and traits
1️⃣ Reading Arguments and Dispatching Commands
A CLI starts by reading std::env::args() into a vector. Index 0 is the program name, so real arguments begin at index 1. You then match on the subcommand to decide what to do — exactly the enum-style dispatch you practiced earlier.
use std::env;
fn main() {
// env::args() yields the command-line arguments as an iterator of String.
// The FIRST argument (index 0) is the program's own name — skip it.
let mut args: Vec<String> = env::args().collect();
// A playground (and this page's checker) runs the program with no
// arguments, so stand in for "todo add Buy milk" when none were given.
// Delete these three lines once you are running it from a terminal.
if args.len() < 2 {
args = ["todo", "add", "Buy", "milk"].iter().map(|s| s.to_string()).collect();
}
// Run as: cargo run -- add Buy milk
// args would be: ["target/debug/todo", "add", "Buy", "milk"]
if args.len() < 2 {
println!("Usage: todo <command> [text...]");
return;
}
let command = &args[1]; // the subcommand, e.g. "add"
let rest = &args[2..]; // everything after it
match command.as_str() {
"add" => {
let text = rest.join(" ");
println!("Added task: {}", text);
}
"list" => println!("(your tasks would print here)"),
other => println!("Unknown command: {}", other),
}
}The output shown assumes you ran cargo run -- add Buy milk. The match on command.as_str() routes each subcommand to its handler — clean and extensible.
2️⃣ Modeling State and Handling Errors
Now the heart of the tool: a Task struct with a render method, a HashMap storing tasks by id, and a lookup function returning a Result so missing tasks are handled gracefully. This single program uses structs, methods, collections, Option/Result, and iterators together.
use std::collections::HashMap;
// A small task struct, with a method to render itself.
#[derive(Debug)]
struct Task {
id: u32,
title: String,
done: bool,
}
impl Task {
fn new(id: u32, title: &str) -> Task {
Task { id, title: title.to_string(), done: false }
}
fn render(&self) -> String {
let mark = if self.done { "[x]" } else { "[ ]" };
format!("{} {} {}", mark, self.id, self.title)
}
}
// A function that can fail returns Result, so callers can handle errors with ?.
fn find_task(tasks: &HashMap<u32, Task>, id: u32) -> Result<&Task, String> {
tasks.get(&id).ok_or(format!("no task with id {}", id))
}
fn main() {
// Build an in-memory store: id -> Task.
let mut tasks: HashMap<u32, Task> = HashMap::new();
tasks.insert(1, Task::new(1, "Learn ownership"));
tasks.insert(2, Task::new(2, "Write a CLI"));
// Mark task 1 complete (interior access via the map).
if let Some(t) = tasks.get_mut(&1) {
t.done = true;
}
// List tasks sorted by id (HashMap has no order, so we sort the keys).
let mut ids: Vec<&u32> = tasks.keys().collect();
ids.sort();
for id in ids {
println!("{}", tasks[id].render());
}
// Look one up, handling the error case.
match find_task(&tasks, 2) {
Ok(t) => println!("Found: {}", t.title),
Err(e) => println!("Error: {}", e),
}
match find_task(&tasks, 99) {
Ok(t) => println!("Found: {}", t.title),
Err(e) => println!("Error: {}", e),
}
}Notice ok_or turned the Option from .get into a Result with a helpful message — and because HashMap has no order, we sorted the ids before printing. Every concept from the course is pulling its weight here.
Your turn. Fill in the blanks marked ___, then run it.
// 🎯 YOUR TURN — finish the CLI helper, then run it.
use std::collections::HashMap;
fn count_done(tasks: &HashMap<u32, bool>) -> usize {
// Count how many tasks are done (value == true).
// Use an iterator: filter the values that are true, then count.
tasks
.values()
.filter(|&&done| done == ___) // 👉 the boolean for "done"
.___() // 👉 the consumer that counts
}
fn main() {
let mut tasks: HashMap<u32, bool> = HashMap::new();
tasks.insert(1, true);
tasks.insert(2, false);
tasks.insert(3, true);
println!("done: {}", count_done(&tasks)); // expected: done: 2
println!("total: {}", tasks.len()); // expected: total: 3
}
// ✅ Expected output:
// done: 2
// total: 3Mini-Challenge: Extend the Tool
Add a pending function that returns the unfinished tasks, then list them. This is the capstone of your capstone — make it your own. Run it with cargo run.
// 🎯 MINI-CHALLENGE: extend the task tool.
//
// Build on the tool you saw above. Add ONE function and use it:
//
// 1. Write fn pending(tasks: &HashMap<u32, Task>) -> Vec<&Task> that returns
// all tasks where done == false. (Use .values().filter(...).collect().)
// 2. In main, after marking some tasks done, call pending() and print each
// pending task's title with a loop.
// 3. Also print how many tasks are pending using the Vec's .len().
//
// (Reuse the Task struct from the worked example.)
//
// ✅ Example output (with task 1 done, tasks 2 and 3 pending):
// Pending: Write a CLI
// Pending: Ship it
// 2 task(s) left
fn main() {
// your code here
}Common Errors (and the fix)
- Arguments seem empty — When using Cargo, pass them after --: cargo run -- add task. Remember index 0 is the program name.
- "index out of bounds" reading args — Check args.len() before indexing, and print a usage message when too few are given.
- "cannot borrow `tasks` as mutable" — Use get_mut to modify a value in a HashMap, and make the map mut.
- Unordered output — A HashMap doesn't keep order; collect and sort the keys before printing.
Pro Tips
- 💡 Reach for clap: the clap crate gives you polished argument parsing, subcommands, and help text.
- 💡 Keep main thin: parse, dispatch, print — put real logic in functions that return Result so they're testable.
- 💡 Persist with serde: add serde to save and load your tasks to a file between runs.
📋 Quick Reference — CLI Tools
| Goal | Rust Syntax |
|---|---|
| Read args | env::args().collect() |
| Run with args | cargo run -- add task |
| Dispatch | match cmd.as_str() { } |
| Join args | rest.join(" ") |
| Option to Result | opt.ok_or(err) |
| Mutate map value | map.get_mut(&k) |
🎉 Course Complete!
- ✅ You read CLI arguments with env::args and dispatched with match
- ✅ You modeled state with structs, methods, and a HashMap
- ✅ You handled errors with Result and ok_or
- ✅ You combined ownership, iterators, and traits in one real program
- 🦀 Congratulations — you've completed the Rust Programming course! Keep building: a file-backed todo, a web server with axum, or your own CLI utility await.
Practice quiz
What does std::env::args() return?
- A Vec<i32>
- A single String
- An iterator over the command-line arguments as Strings
- A HashMap of flags
Answer: An iterator over the command-line arguments as Strings. env::args() yields the arguments as an iterator of String, commonly collected into a Vec.
Which argument is the program's own name?
- args[0]
- args[1]
- The last argument
- There is none
Answer: args[0]. By convention args[0] is the program name, so real arguments start at index 1.
How do you pass arguments to your program through Cargo?
- cargo run add Buy milk
- cargo args add Buy milk
- cargo run :: add Buy milk
- cargo run -- add Buy milk
Answer: cargo run -- add Buy milk. Everything after -- is forwarded to your program, e.g. cargo run -- add Buy milk.
What does ok_or do in tasks.get(&id).ok_or(format!(...))?
- Unwraps and panics
- Converts an Option into a Result with an error value
- Sorts the map
- Removes the key
Answer: Converts an Option into a Result with an error value. ok_or turns Some(v) into Ok(v) and None into Err(message), giving a Result.
What does count_done print? // tasks: {1: true, 2: false, 3: true} // fn count_done(t) { t.values().filter(|&&d| d == true).count() } println!("done: {}", count_done(&tasks));
- done: 2
- done: 1
- done: 3
- done: 0
Answer: done: 2. Two values are true (tasks 1 and 3), so the filtered count is 2.
What does find_task print for a missing id? match find_task(&tasks, 99) { Ok(t) => println!("Found: {}", t.title), Err(e) => println!("Error: {}", e), }
- Found: 99
- It panics
- Error: no task with id 99
- Error: None
Answer: Error: no task with id 99. Id 99 is absent, so ok_or yields Err with the formatted message printed by the Err arm.
Why must you sort a HashMap's keys before printing them in order?
- HashMap reverses keys
- HashMap stores entries in no guaranteed order
- Sorting is required to read values
- HashMap keys are always strings
Answer: HashMap stores entries in no guaranteed order. A HashMap has no defined iteration order, so you collect and sort the keys for ordered output.
How do you modify a value already stored in a HashMap?
- map.get(&k)
- map.peek(&k)
- map.borrow(&k)
- map.get_mut(&k)
Answer: map.get_mut(&k). get_mut returns a mutable reference to the value (and the map must be mut).
What does rest.join(" ") do where rest is &args[2..]?
- Sums the arguments
- Joins the remaining argument strings into one space-separated String
- Sorts them
- Counts them
Answer: Joins the remaining argument strings into one space-separated String. join concatenates the slice's strings with a space between each.
Which crate is commonly used for richer command-line argument parsing?
- serde
- tokio
- clap
- rand
Answer: clap. clap handles flags, subcommands, and help text beyond simple manual parsing.
Continue this course
- Previous: Checkpoint: Systems Rust
- Next: Async Rust with Tokio — async/await, futures, the Tokio runtime, spawning tasks, and channels
- Quick reference: Rust cheat sheet
Frequently asked questions
How do I read command-line arguments in a Rust program?
Use std::env::args(), which returns an iterator over the arguments as Strings. You typically collect them into a Vec<String>. Important: the first element (index 0) is the name of the program itself, so the real arguments start at index 1. When running through Cargo, you pass arguments after a double dash, like 'cargo run -- add Buy milk', so that Cargo knows the arguments are for your program rather than for Cargo. For anything beyond simple parsing, the popular clap crate handles flags, subcommands, and help text for you.
How does this capstone tie the whole course together?
A real CLI tool exercises nearly everything you have learned. You use structs and methods to model tasks, enums and match to dispatch commands, Option and Result with the ? operator to handle missing data and errors, collections like HashMap and Vec to store and process state, iterators and closures to filter and transform, and ownership and borrowing throughout to keep memory safe. Building a small but complete program is the best way to turn isolated concepts into working knowledge.
How do I organize a CLI project as it grows?
Start with everything in src/main.rs, then split logic into modules as it grows — for example a tasks module for the data model and a commands module for handling subcommands, each in its own file. Keep main.rs thin: parse arguments, dispatch to functions, and print results. Put the testable logic in functions that take inputs and return Results so you can write unit tests with #[test]. As needs grow, add crates from crates.io in Cargo.toml, such as clap for argument parsing or serde for saving and loading data.
Where should I go after finishing this Rust course?
Build projects. Good next steps include a file-based version of this todo tool that saves tasks to disk, a small HTTP server with a framework like axum, a command-line utility using clap, or a parser for a file format you care about. Read 'The Rust Programming Language' book and work through Rustlings exercises to reinforce the fundamentals. Explore crates.io to see how real libraries are built, and contribute to an open-source Rust project. The concepts in this course — ownership, traits, error handling, iterators, and concurrency — are exactly what you will use every day.