16. Build a CLI Tool
📋 Jump to Takeaways🎁 You've learned ownership, structs, error handling, iterators, and more. Now it's time to pull everything together into a real program. In this capstone lesson, you'll build a command-line word counter, a simplified version of the Unix wc tool, that reads a file and reports its lines, words, and characters.
The Goal
Your CLI tool will:
- Accept a filename as a command-line argument
- Read the file's contents into memory
- Count lines, words, and characters
- Print the results in a clean, formatted table
- Handle errors gracefully when things go wrong
This single program exercises ownership, structs, iterators, error handling with Result, and the standard library, tying together lessons 5 through 13.
Project Setup
Create a new binary project:
cargo new wordcount
cd wordcountYou'll write all your code in src/main.rs. No external dependencies needed, the standard library has everything.
Command-Line Arguments
Use std::env::args() to grab arguments passed to your program. The first argument is always the program's name, so the filename you care about is at index 1.
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: wordcount <filename>");
std::process::exit(1);
}
let filename = &args[1];
println!("File: {}", filename);
}
// Running: cargo run -- poem.txt
// Output: File: poem.txtenv::args() returns an iterator. You collect it into a Vec<String>, each argument is an owned String. If the user forgets to pass a filename, you print a usage message to stderr and exit with a non-zero code.
Reading the File
Use std::fs::read_to_string to load the entire file into a String. This function returns a Result<String, io::Error>, so you need to handle the possibility of failure.
use std::fs;
fn read_file(filename: &str) -> Result<String, std::io::Error> {
fs::read_to_string(filename)
}The returned String owns the file's contents. Ownership transfers to whoever calls this function, no copies, no dangling references. This is Rust's ownership system working exactly as designed.
A Struct for the Counts
A struct groups related data together. Create one to hold your three counts:
struct FileStats {
lines: usize,
words: usize,
chars: usize,
}This is cleaner than returning a tuple or passing three separate variables around. The struct gives each value a name, making your code self-documenting.
Counting with Iterators
Iterators are the idiomatic way to process text in Rust. The lines() method splits a string by newlines, and split_whitespace() splits by any whitespace.
fn count_stats(contents: &str) -> FileStats {
let lines = contents.lines().count();
let words = contents.split_whitespace().count();
let chars = contents.chars().count();
FileStats { lines, words, chars }
}Notice you pass contents as a &str, a borrowed reference. The function doesn't need to own the data; it just needs to read it. Each iterator method creates a lazy iterator that only does work when you call .count().
Formatting the Output
Print results in a readable format with aligned columns:
fn print_stats(filename: &str, stats: &FileStats) {
println!(" {:>8} lines", stats.lines);
println!(" {:>8} words", stats.words);
println!(" {:>8} chars", stats.chars);
println!(" {}", filename);
}
// Output for a sample file:
// 14 lines
// 92 words
// 531 chars
// poem.txtThe {:>8} format specifier right-aligns the number in an 8-character-wide field. You borrow stats with &FileStats because printing doesn't need ownership.
Error Handling with Result and ?
The ? operator propagates errors up the call stack. Wrap your main logic in a function that returns Result:
fn run() -> Result<(), std::io::Error> {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: wordcount <filename>");
std::process::exit(1);
}
let filename = &args[1];
let contents = fs::read_to_string(filename)?; // ? propagates the error
let stats = count_stats(&contents);
print_stats(filename, &stats);
Ok(())
}If read_to_string fails, file not found, permission denied, or any I/O error, the ? immediately returns the error from run(). No unwrap(), no panics. You handle it gracefully in main().
The Complete Program
Here's the complete, working program:
use std::env;
use std::fs;
use std::io;
use std::process;
struct FileStats {
lines: usize,
words: usize,
chars: usize,
}
fn count_stats(contents: &str) -> FileStats {
let lines = contents.lines().count();
let words = contents.split_whitespace().count();
let chars = contents.chars().count();
FileStats { lines, words, chars }
}
fn print_stats(filename: &str, stats: &FileStats) {
println!(" {:>8} lines", stats.lines);
println!(" {:>8} words", stats.words);
println!(" {:>8} chars", stats.chars);
println!(" {}", filename);
}
fn run() -> Result<(), io::Error> {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: wordcount <filename>");
process::exit(1);
}
let filename = &args[1];
let contents = fs::read_to_string(filename)?;
let stats = count_stats(&contents);
print_stats(filename, &stats);
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("Error: {}", e);
process::exit(1);
}
}Running the Tool
Create a test file called sample.txt:
Rust is a systems programming language.
It focuses on safety, speed, and concurrency.
You can build anything from CLI tools to web servers.Run your program:
cargo run -- sample.txt 3 lines
23 words
140 chars
sample.txtTry an error case:
cargo run -- nonexistent.txtError: No such file or directory (os error 2)The error message comes from the OS, wrapped in Rust's io::Error type. Your program exits cleanly with a helpful message, no panic, no stack trace.
How the Concepts Connect
Let's trace how this program uses what you've learned:
Ownership (Lesson 5): fs::read_to_string returns an owned String. That String lives in run() and you pass a borrowed &str to count_stats. No cloning needed.
Structs (Lesson 6): FileStats bundles three related values. You construct it in one function and pass a reference to another.
Error Handling (Lesson 7): run() returns Result<(), io::Error>. The ? operator makes error propagation a single character. main() matches on the result and prints a user-friendly message.
Iterators (Lesson 13): .lines(), .split_whitespace(), and .chars() are all lazy iterators. Calling .count() consumes each one. No manual loops, no index variables.
References (Lesson 5): count_stats borrows the string with &str. print_stats borrows both the filename and the stats. Ownership stays in run().
Extending the Tool
Once the basic version works, here are ideas to push further:
- Accept multiple filenames and print a total row
- Add a
-lflag to show only line counts - Use
BufReaderfor large files instead of loading everything into memory - Count bytes in addition to characters (they differ for UTF-8)
Each extension practices the same core concepts with slightly more complexity.
Key Takeaways
- Real programs combine many concepts. Ownership, structs, error handling, and iterators all appear naturally in practical code.
std::env::args()gives you CLI arguments as an iterator of ownedStringvalues.fs::read_to_stringloads a file and returnsResult<String, io::Error>, handle both cases.- The
?operator keeps error handling concise without sacrificing safety. - Iterators like
.lines()and.split_whitespace()let you process text without manual indexing. - Structs organize related data and make function signatures clear.
- Separating logic into a
run()function that returnsResultkeepsmain()clean and testable. - You built a complete, useful tool with zero external dependencies, just Rust's standard library.