Rust in Practice #1 Project Design and clap: Structuring a Subcommand CLI
The nine-part basics course built the grammatical backbone, from ownership to lifetimes. This practice course spends eight parts building one real tool with that grammar, start to finish. From design and argument parsing through error design, file IO, iterator aggregation, parallelism, and testing, to release builds and distribution — the goal is not “code that works” but “a tool you can tell someone else to install.”
What we build: loglens, an access-log analyzer #
The project is a CLI tool that analyzes web server access logs. We’ll call it loglens.
loglens stats access.log # request counts, status code distribution
loglens top access.log --count 10 # top 10 most requested paths
loglens errors access.log # extract only 5xx linesLog analysis was chosen for a reason. The files can contain millions of lines, so the performance story of iterators and parallelism becomes the main plot rather than decoration; and the input is always messy (broken lines, unexpected formats), so error handling is the real thing rather than an exercise. CLI tools are Rust’s signature use case. As ripgrep and uv have shown, a single binary with no runtime to install and millisecond startup are strengths that shine brightest in a CLI.
Creating the project and adding clap #
cargo new loglens
cd loglens
cargo add clap --features deriveArgument parsing is possible with the standard library alone, but the de facto standard in practice is clap: auto-generated --help, type conversion, even typo suggestions — it returns the time you would spend on parsing to the tool’s real work. --features derive switches on the style where the CLI is defined by declaring structs.
Declaring the interface as types #
In clap’s derive style, the CLI interface is a type definition.
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about = "Access log analysis tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Summarize request counts and status code distribution
Stats { file: PathBuf },
/// Show the top N most requested paths
Top {
file: PathBuf,
#[arg(long, default_value_t = 10)]
count: usize,
},
/// Extract only server error (5xx) lines
Errors { file: PathBuf },
}Part 5 of the basics said an enum expresses “one of several shapes” — and a subcommand is exactly that structure. The user picks one of stats, top, or errors, and each choice carries different arguments. clap generates the entire parser and help text from this enum declaration. Note also that the /// doc comments become the --help descriptions verbatim.
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Stats { file } => {
println!("stats: {}", file.display());
todo!("implemented in part 3");
}
Commands::Top { file, count } => {
println!("top {count}: {}", file.display());
todo!("implemented in part 4");
}
Commands::Errors { file } => {
println!("errors: {}", file.display());
todo!("implemented in part 3");
}
}
}Cli::parse() is the whole of argument parsing. Invalid arguments are handled by clap itself, with an error message and exit code 2, and match — by the rule from basics Part 5 — forces handling of all three subcommands. When a subcommand is added later, this match will point out every unhandled spot as a compile error. todo!() is the macro that declares “types check out, implementation pending,” which matches this series’ approach exactly: erect the skeleton first, add the flesh later.
Running it #
$ cargo run -- --help
Access log analysis tool
Usage: loglens <COMMAND>
Commands:
stats Summarize request counts and status code distribution
top Show the top N most requested paths
errors Extract only server error (5xx) linesArguments after cargo run -- go to the program. We only wrote declarations, yet help, version display (--version), and argument validation all work. loglens top access.log --count 5 parses too, and count is already a usize. Not one line of string-to-number conversion was written.
The map of eight parts #
- #1 Design and clap ← this article
- #2 Error design — anyhow and thiserror, engineering how it dies
- #3 File IO and parsing — BufReader, one log line into a struct
- #4 Iterators in practice — aggregation, top-N, the release build difference
- #5 Parallelism with rayon — map-reduce without data races
- #6 Testing — from parser unit tests to CLI integration tests
- #7 Release optimization and cross-compilation
- #8 Distribution — crates.io and GitHub Releases automation
Summary #
- The practice project is an access-log analysis CLI. Huge inputs and messy inputs make performance and error handling the real curriculum.
- With clap derive, the CLI interface is a type declaration. Subcommands are an enum, and
matchblocks missing handlers at compile time. ///doc comments are the--helpcopy. One declaration buys parsing, validation, help, and version display.- The skeleton stands on
todo!(). A skeleton with correct types is a foundation the later parts can flesh out with confidence. - Next: error design. When the file is missing or the format is wrong, how this tool dies and what it says — organized with anyhow and thiserror.