Expert · Real projects: CLI, web and WebAssembly · topic 43 of 50 · 35 min
Building a CLI tool
Explanation
Why Rust for CLI tools?
A CLI (command-line interface) tool is a program you run in a terminal, like git or cargo. Rust is excellent for them: the result is a single fast binary file that starts instantly, has no runtime (like Python or Java) to install, and works on Windows, macOS and Linux. Popular tools like ripgrep (a fast search tool) and many developer tools are written in Rust.
The project: an expense summariser
We will build expsum, a tool that reads a CSV file of expenses and prints the total per category. CSV (comma-separated values) is a simple text format for tables, the kind you can export from Excel or a bank app.
date,category,amount
2026-10-01,food,250
2026-10-01,travel,120
2026-10-02,food,180
2026-10-03,bills,1499expsum expenses.csv
expsum expenses.csv --category food
expsum --helpThe plan has three parts: read the arguments the user typed, do the real work (parse the CSV and add up amounts), and print results or a clear error. We keep the real work in a library function so it can be tested without a terminal, and keep main.rs as a thin layer that talks to the user.
Reading arguments with clap
std::env::args() gives the raw words typed after the program name, but parsing flags, help text and errors by hand is tedious. The clap crate does it from a struct: each field becomes an argument, and doc comments become help text.
cargo new expsum
cd expsum
cargo add clap --features deriveuse clap::Parser;
/// Summarise an expenses CSV file by category.
#[derive(Parser)]
#[command(version, about)]
struct Args {
/// Path to the CSV file
file: String,
/// Only show this category
#[arg(short, long)]
category: Option<String>,
}
fn main() {
let args = Args::parse(); // exits with a helpful message on bad input
println!("file: {}, category: {:?}", args.file, args.category);
}The logic, kept testable
// src/lib.rs
use std::collections::BTreeMap;
pub fn summarise(csv: &str) -> Result<BTreeMap<String, u64>, String> {
let mut totals = BTreeMap::new();
for (n, line) in csv.lines().enumerate().skip(1) { // skip the header
if line.trim().is_empty() {
continue;
}
let parts: Vec<&str> = line.split(',').collect();
if parts.len() != 3 {
return Err(format!("line {}: expected 3 columns", n + 1));
}
let amount: u64 = parts[2].trim().parse()
.map_err(|_| format!("line {}: bad amount '{}'", n + 1, parts[2]))?;
*totals.entry(parts[1].trim().to_string()).or_insert(0) += amount;
}
Ok(totals)
}// src/main.rs
use clap::Parser;
use std::process;
#[derive(Parser)]
#[command(version, about)]
struct Args {
file: String,
#[arg(short, long)]
category: Option<String>,
}
fn main() {
let args = Args::parse();
let text = std::fs::read_to_string(&args.file).unwrap_or_else(|e| {
eprintln!("expsum: cannot read {}: {e}", args.file);
process::exit(1);
});
match expsum::summarise(&text) {
Ok(totals) => {
for (cat, amt) in &totals {
if args.category.as_deref().map_or(true, |c| c == cat) {
println!("{cat:<10} Rs {amt}");
}
}
}
Err(e) => {
eprintln!("expsum: {e}");
process::exit(2);
}
}
}CLI good manners
- Exit code 0 means success; any other number means failure. Scripts and CI rely on this.
- Print results to stdout (println!) and errors to stderr (eprintln!).
- Give helpful errors with the file name and line number.
- Support --help and --version (clap does this for you).
- Install your own tool locally with cargo install --path . and run it from anywhere.
Common mistakes
- Putting all logic in main, so nothing can be tested. Keep main thin.
- Calling unwrap() on file reads: users see an ugly panic. Print a clear error and exit with a code.
- Reading a huge file fully into memory when you only need one line at a time. Use std::io::BufReader and lines() for big files.
- Splitting CSV by commas when fields can contain commas in quotes. For real CSV files, use the csv crate.
Need help?
Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.
Try:
Watch the lesson
Building a CLI tool · 1/6
Rust is great for command line tools: one fast binary, with nothing extra to install.
Prefer a video by a teacher? Find free videos on “rust cli tool clap derive tutorial” on YouTube ↗
In real life: A tool for an accounts team
An accountant at a small firm receives monthly bank exports as CSV. A junior developer builds a Rust CLI that totals expenses by category and flags any line it cannot read, with the exact line number. Because it is a single .exe, the accountant just double-clicks a script that runs it; there is no Python installation to maintain on the office computer.
$ expsum october.csv
bills Rs 1499
food Rs 430
travel Rs 120
$ expsum broken.csv
expsum: line 4: bad amount 'abc'Practice (0/3 done)
Why print error messages with eprintln! instead of println!?
In a clap struct, what makes an argument optional?
Build expsum yourself: clap arguments (file, --category), logic in lib.rs with unit tests (good file, bad column count, bad amount), clear errors with exit codes, and install it with cargo install --path . Then add a --top N flag that shows only the N biggest categories.
Quick quiz
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Serde and JSONReal projects: CLI, web and WebAssembly · same module
- Rust for the web with axumReal projects: CLI, web and WebAssembly · same module
- Rust and WebAssemblyReal projects: CLI, web and WebAssembly · same module
- Command-line tools with flag and osGo Programming (backend and cloud) · CLI tools and Go in cloud and DevOps
- The command line on all three cloudsCloud Computing (AWS, Azure, Google Cloud) · Google Cloud and comparing the clouds
- Installing .NET and using the dotnet CLIC# and .NET · Getting started with C# and .NET
Questions people ask about this
Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.
Which crate is the common choice for parsing command-line arguments?
Answer: clap
clap generates parsing and help from a struct.
What does an exit code of 0 mean?
Answer: Success
Non-zero codes signal errors to scripts.
Why keep main.rs thin?
Answer: Logic in lib.rs can be unit and integration tested
Testable logic lives in the library crate.
Which command installs your CLI from the current folder?
Answer: cargo install --path .
cargo install --path . builds in release mode and adds it to ~/.cargo/bin.
Where should a CLI take an API token from?Related
Answer: An environment variable or an interactive prompt
Flags can leak through shell history and process lists.
Which command checks your code for errors fastest, without producing a program?Related
Answer: cargo check
cargo check skips making the final program, so it is the fastest way to see errors.
You wrote src/tax.rs but get "unresolved module". What is missing?Related
Answer: mod tax; in main.rs or lib.rs
A file becomes a module only when declared with mod.
Which AWS CLI option filters the output on your machine using JMESPath?Related
Answer: --query
--query takes a JMESPath expression and filters the JSON before it is printed.
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.
Next steps
- 3 more topics in “Real projects: CLI, web and WebAssembly” before its exam: Serde and JSON, Rust for the web with axum, Rust and WebAssembly.
- Module 11 exam: Real projects · 13 questions · 25 min, without help. 4 topics of this module still to finish first.
- Then the next module, Rust careers and interviews. 50 topics left in the course before the final exam.