Skip to content

Expert · Real projects: CLI, web and WebAssembly · topic 43 of 50 · 35 min

Building a CLI tool

Explanation

Command-line tools are one of Rust's strongest areas: one fast binary with no runtime to install. Read arguments with std::env::args or, better, the clap crate with #[derive(Parser)]. Keep main.rs thin and put the logic in lib.rs, return Result from functions, print errors to stderr with eprintln!, and exit with a non-zero code on failure.

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.

output
date,category,amount
2026-10-01,food,250
2026-10-01,travel,120
2026-10-02,food,180
2026-10-03,bills,1499
expenses.csv: the input file.
bash
expsum expenses.csv
expsum expenses.csv --category food
expsum --help
How a user will run the tool.

The 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.

bash
cargo new expsum
cd expsum
cargo add clap --features derive
clap 4 with the derive feature.
rust
use 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);
}
file is a required positional argument; --category (or -c) is optional because it is an Option.

The logic, kept testable

rust
// 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)
}
BTreeMap keeps categories sorted. Errors carry the line number, which users love.
rust
// 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);
        }
    }
}
eprintln! writes to stderr, so error messages do not mix with real output when the user redirects it to a file.

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

Single binary Fast start Cross-platform

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.

output
$ expsum october.csv
bills      Rs 1499
food       Rs 430
travel     Rs 120

$ expsum broken.csv
expsum: line 4: bad amount 'abc'
Clear output, and clear errors with line numbers.

Practice (0/3 done)

Task 1 · Question

Why print error messages with eprintln! instead of println!?

Task 2 · Question

In a clap struct, what makes an argument optional?

Task 3 · Practice

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

Question 1

1.Which crate is the common choice for parsing command-line arguments?

Question 2

2.What does an exit code of 0 mean?

Question 3

3.Why keep main.rs thin?

Question 4

4.Which command installs your CLI from the current folder?

Read more (free)

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.

From Command-line tools with flag and os

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.

From Your first program and cargo

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.

From Packages, crates and modules

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.

From The command line on all three clouds

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

  1. 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.
  2. Module 11 exam: Real projects · 13 questions · 25 min, without help. 4 topics of this module still to finish first.
  3. Then the next module, Rust careers and interviews. 50 topics left in the course before the final exam.

← Previous: Unsafe Rust: what it is and is not