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 wordcount

You'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.txt

env::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.txt

The {:>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.txt

Try an error case:

cargo run -- nonexistent.txt
Error: 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 -l flag to show only line counts
  • Use BufReader for 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 owned String values.
  • fs::read_to_string loads a file and returns Result<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 returns Result keeps main() clean and testable.
  • You built a complete, useful tool with zero external dependencies, just Rust's standard library.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy