Building Your First CLI Application in Rust
I walk through std::env::args, clap's derive API, splitting logic across main.rs and lib.rs, reading files, and using the ? operator in main to build a working word-counter CLI.

You type args[0] expecting your first argument. What you get back is the path to the compiled binary.
Your first real argument is args[1]. That’s not a bug — it follows POSIX conventions, the same reason process.argv[0] in Node.js is node and process.argv[1] is your script path. But it catches almost everyone the first time. From there, the raw std::env::args vector works fine for two fixed positional arguments in a known order. The moment you want --flags, optional parameters, or a --help message the program generates itself, you’re either building a small parser from scratch or discovering that clap exists.
This post walks through both. First, std::env::args so you understand what’s underneath. Then clap’s derive API to build a real tool without the boilerplate. The project is a word counter — it reads a file, counts words by default, and switches to lines or characters when you pass the right flag.
Quick answer: Use
std::env::args().collect::<Vec<String>>()to read raw arguments. For anything beyond two positional args, addclapwith thederivefeature — annotate a struct with#[derive(Parser)]and the library generates the parser, help text, and version flag from your field types and doc comments. ReturnResult<(), Box<dyn std::error::Error>>frommainso?works at the top level.
Series: Part 11 of 12. Start with Part 1: Learning Rust as a TypeScript Developer · Previous: Error Handling in Rust with Result and Option
On this page
- Reading arguments with std::env::args
- When manual parsing runs out of road
- Building the CLI struct with clap
- Splitting main.rs and lib.rs
- Reading files and counting
- The ? operator in main
- What goes wrong
- What a single binary changes
Reading arguments with std::env::args
std::env::args returns an iterator over the command-line arguments. Calling .collect() on it gives you a Vec<String>.
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("{:?}", args);
}
If you run this with cargo run -- notes.txt, the output is:
["target/debug/wordcount", "notes.txt"]
Index 0 is always the binary path. Your first actual argument is at index 1. With two known positional arguments — say, a search term and a file path — you’d read &args[1] and &args[2]. That’s enough to write a focused, simple tool:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("usage: {} <file>", args[0]);
std::process::exit(1);
}
let file_path = &args[1];
println!("reading: {file_path}");
}
Notice eprintln! for the error message — that writes to stderr, so the user can redirect successful output to a file while still seeing the error on their terminal. The Rust Book’s chapter 12 covers this pattern when building minigrep, and it’s a good habit to keep.
When manual parsing runs out of road
The problem with args[1], args[2] indexing is that it only works when arguments arrive in a fixed, known order with no flags. Add one optional flag and the logic falls apart. Does --lines notes.txt mean args[1] is a flag? Or is args[1] always the file and flags always come after? You end up writing a loop that handles -- prefixes, detects whether a value is a flag or a positional argument, and tracks which flags have been seen — and you haven’t even added --help yet.
clap solves this. It’s the dominant argument parser in the Rust ecosystem, with a derive API that lets you declare your CLI’s shape as a struct and generates all the parsing, validation, and help text from it.
Add it to Cargo.toml:
[dependencies]
clap = { version = "4", features = ["derive"] }
The derive feature is what enables the #[derive(Parser)] macro. Without it you’d use the builder API — more explicit, more verbose.
Building the CLI struct with clap
The core idea is: your struct fields define what the CLI accepts. Positional arguments are plain fields. Flags are bool fields annotated with #[arg(short, long)]. Optional values are Option<T> fields. The types do the work.
use clap::Parser;
/// Count words, lines, or characters in a text file.
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// Path to the file to read.
file: String,
/// Count lines instead of words.
#[arg(short, long)]
lines: bool,
/// Count characters instead of words.
#[arg(short, long)]
chars: bool,
}
The doc comments become help text. #[command(version, about)] wires up --version and --help automatically. Run cargo run -- --help and you get:
Count words, lines, or characters in a text file.
Usage: wordcount [OPTIONS] <FILE>
Arguments:
<FILE> Path to the file to read.
Options:
-l, --lines Count lines instead of words.
-c, --chars Count characters instead of words.
-h, --help Print help
-V, --version Print version
You didn’t write a single line of that output. The field names become the flag names, bool becomes a toggle flag, and String becomes a required positional argument. If you pass an unrecognised flag, clap exits with a clear error message before your code even runs.
Check this before moving on
- You can run
cargo run -- --helpand see the generated help text - Passing no arguments exits with an error from clap, not a panic
-
--versionprints the version fromCargo.toml
Splitting main.rs and lib.rs
This is where the project structure matters. If all your logic lives in main, you can’t unit-test it — Rust doesn’t give you a clean way to call main() in tests. The standard pattern is to put the pure logic in src/lib.rs and keep src/main.rs as the glue: parse arguments, open files, call library functions, handle errors.
Create src/lib.rs:
/// Count the number of words in a string.
/// Splits on whitespace — spaces, tabs, and newlines all count as separators.
pub fn count_words(content: &str) -> usize {
content.split_whitespace().count()
}
/// Count the number of lines in a string.
pub fn count_lines(content: &str) -> usize {
content.lines().count()
}
/// Count the number of Unicode characters in a string.
pub fn count_chars(content: &str) -> usize {
content.chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn words_in_a_sentence() {
assert_eq!(count_words("three words here"), 3);
}
#[test]
fn handles_extra_whitespace() {
assert_eq!(count_words(" spaced out "), 2);
}
#[test]
fn empty_string_is_zero() {
assert_eq!(count_words(""), 0);
assert_eq!(count_lines(""), 0);
assert_eq!(count_chars(""), 0);
}
}
These functions take &str rather than String. That’s the ownership pattern from Rust’s ownership rules — borrowing a reference to the string’s contents rather than taking ownership of it. The functions don’t need to own the data, so they shouldn’t.
Reading files and counting
With the counting logic in lib.rs, main.rs handles the I/O:
use clap::Parser;
use std::fs;
use wordcount::{count_chars, count_lines, count_words};
/// Count words, lines, or characters in a text file.
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// Path to the file to read.
file: String,
/// Count lines instead of words.
#[arg(short, long)]
lines: bool,
/// Count characters instead of words.
#[arg(short, long)]
chars: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let content = fs::read_to_string(&cli.file)?;
let result = if cli.lines {
count_lines(&content)
} else if cli.chars {
count_chars(&content)
} else {
count_words(&content)
};
println!("{result}");
Ok(())
}
fs::read_to_string loads the entire file into a String. It returns a Result<String, io::Error> — the file might not exist, you might lack permission to read it, or the path might be invalid. The ? at the end handles all of that.
The use wordcount::... line works because the crate name is wordcount — set in Cargo.toml under [package] name. When you split into lib.rs, Rust automatically treats the library crate as a separate import target using that name.
The ? operator in main
The interesting line is the function signature: fn main() -> Result<(), Box<dyn std::error::Error>>.
Before Rust 1.26, main had to return (), which meant you couldn’t use ? at the top level. You had to catch errors with match or .unwrap(). Since then, main can return a Result, and when it does, ? works anywhere inside it.
If you’ve read the error handling post in this series, you know ? does two things: on an Ok value, it unwraps and continues; on an Err, it returns early with that error. In main, returning early with an Err prints the error message and exits with a non-zero status code. No std::process::exit(1), no manual error printing.
Box<dyn std::error::Error> is the catch-all error type. It accepts any error that implements the standard Error trait — io::Error from the file read, any clap error, anything you introduce later. For a production library crate you’d define a proper error enum (as covered in the error handling post, using thiserror). For a binary, this box is the practical choice.
Try running the tool on a file that doesn’t exist:
$ cargo run -- missing.txt
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
That error message came for free. The ? propagated the io::Error, main returned it, and Rust printed it.
What goes wrong
The args[0] trap. If you ever switch back to manual arg parsing, remember args[0] is the binary path. Treating it as a file path is the most common mistake and produces a confusing error when the binary happens to exist on the filesystem.
clap panics at startup for invalid struct definitions. If you annotate a field incorrectly — for example, marking a positional String as Option<String> when you actually want it required — clap will panic the first time the binary runs. The error appears at the top of cargo run output and points you to the struct field.
fs::read_to_string requires valid UTF-8. If the file contains arbitrary bytes — a compiled binary, an image — the function returns an error. For binary files, use fs::read instead, which gives you Vec<u8>, and convert to a string only if you know the encoding. This is the right constraint for a word counter, since counting words in binary data isn’t meaningful.
The crate name must match. If your Cargo.toml has name = "word-count" (with a hyphen), Rust converts it to word_count (with an underscore) for the use statement: use word_count::count_words. The file system name and the identifier name follow different rules.
What a single binary changes
Once you run cargo build --release, the target/release/wordcount binary has no runtime dependencies. No Rust installation required on the target machine, no node_modules, no virtual environment. You can copy it to a server, a container image, or a colleague’s laptop and it runs as-is.
That’s the practical payoff for the ownership model, the Result types, and the compile-time checks that felt frustrating earlier in this series. Every guarantee the compiler enforced during development means one fewer thing that can fail at runtime on a machine you don’t control.
The word counter is small, but the structure scales. A real CLI tool — a database migration runner, a file processor, a build helper — uses the same skeleton: clap struct for the interface, lib.rs for testable logic, main.rs for the I/O and error handling glue, and ? to keep the error paths readable without the noise.

A CLI tool is a loop: arguments in, file read, result out. The structure stays the same whether the tool counts words or runs database migrations.
Sources
- An I/O Project: Building a Command Line Program — The Rust Programming Language, Chapter 12
- Accepting Command Line Arguments — The Rust Book, ch12-01
- clap derive tutorial — docs.rs/clap
- std::env::args — Rust standard library
- std::fs::read_to_string — Rust standard library
- Error handling in main — Rust Reference