Your First CLI
What we are building
A tiny command-line program called wordc. You give it a file path; it tells you how many words are in that file. This is three hundred lines of Python in a week-two bootcamp. It is thirty lines of Rust, and the end result is a standalone native binary you can hand to a colleague who does not have Rust installed.
$ cargo run -- Cargo.toml
Cargo.toml has 12 words.
Make the project
cargo new wordc
cd wordc
Open src/main.rs and replace its contents with this:
use std::env;
use std::fs;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("usage: wordc <path>");
process::exit(1);
}
let path = &args[1];
let text = match fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!("could not read {path}: {e}");
process::exit(1);
}
};
let count = text.split_whitespace().count();
println!("{path} has {count} words.");
}
▶ Run this in the Rust Playground
Then run it against the project’s own manifest:
cargo run -- Cargo.toml
You should see Cargo.toml has 12 words. (the exact number will differ).
Let’s walk through the thirty lines
Every idea from Part 0, in one file
Line by line:
use std::env;pulls in theenvmodule so we can writeenv::args()instead ofstd::env::args().let args: Vec<String> = env::args().collect();collects the command-line arguments into a growable list.Vec<T>is Rust’sArray/list..collect()turns an iterator into a collection.if args.len() < 2 { ... }is a plain guard.process::exit(1)ends the program with a non-zero status, the Unix convention for “something went wrong”.let path = &args[1];borrows the path. We do not move it. We just look at it. (Chapter 5.)fs::read_to_string(path)returns aResult<String, io::Error>. We cannot touch the string until we have said what to do on error. Thematchdoes exactly that. (Chapter 6.)text.split_whitespace().count()is Rust’s iterator pattern in action.split_whitespacedoes not allocate a vector of words — it returns an iterator, whichcountwalks once. Zero allocations, one pass, fast.println!prints to stdout;eprintln!prints to stderr (where errors belong).
A smaller, nicer version with ?
Once we have a function that can return a Result, we can use ? to remove the match and let errors bubble up:
use std::env;
use std::fs;
use std::process;
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let path = args.get(1).ok_or("usage: wordc <path>")?;
let text = fs::read_to_string(path)?;
let count = text.split_whitespace().count();
println!("{path} has {count} words.");
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("{e}");
process::exit(1);
}
}
This is the shape most production Rust programs end up in: a run() that returns Result, and a main() that is just error formatting and exit-code plumbing.
Ship it
Make the release binary:
cargo build --release
./target/release/wordc Cargo.toml
That file — target/release/wordc — is a native, statically-compiled binary. You can scp it to another Linux machine without Rust installed and it will work. That is the end state of every Rust program.
You just wrote a real program, using every idea in Part 0: variables, bindings, borrowing, Option, Result, match. You compiled it to a native binary. If you stopped reading here, you could already use Rust at work for small tools.
Here is your CLI’s memory while it runs — everything in this picture is something you learned in the last six chapters.
Interactive simulation (requires JavaScript): the CLI collects its arguments into a heap-backed Vec, borrows the path argument, reads the file into an owned String via a Result and ?, and counts lines through a zero-copy iterator — every Part 0 concept in one running program.
wordc- Add a
--linesflag (checkargs.contains(&"--lines".to_string())) that prints the line count instead of the word count. - Support multiple file paths: print one line per file.
- Replace the argument parsing with the clap crate. Look at the
Cargo.tomlto see how dependencies are added.
You are done with the Part 0 core. Let’s talk about where to go next.