#18 Building a CLI tool
clap, args, subcommands
Parsing arguments
Rust provides std::env::args() to access command-line arguments. This works for simple tools, but quickly becomes tedious for complex CLIs:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
// args[0] is the program name
if args.len() < 2 {
println!("Usage: {} <name>", args[0]);
return;
}
let name = &args[1];
println!("Hello, {}!", name);
}For anything beyond trivial argument parsing, use the clap crate.
Using clap
clap is the most popular argument parsing library in Rust. Its derive macro lets you define your CLI interface as a struct:
[package]
name = "cli-tool"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }use clap::Parser;
/// A simple greeting program
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
/// Name of the person to greet
#[arg(short, long)]
name: String,
/// Number of times to greet
#[arg(short, long, default_value_t = 1)]
count: u8,
/// Use uppercase
#[arg(short, long)]
uppercase: bool,
}
fn main() {
let args = Args::parse();
for _ in 0..args.count {
let greeting = format!("Hello, {}!", args.name);
if args.uppercase {
println!("{}", greeting.to_uppercase());
} else {
println!("{}", greeting);
}
}
}With this definition, clap automatically generates --help, --version, error messages, and shell completions.
Subcommands
Real CLI tools often have subcommands (like git commit, cargo build). Define them as an enum:
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "cli-tool", version, about = "A simple CLI tool built with clap")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Greet a user
Greet {
/// Name to greet
#[arg(short, long)]
name: String,
},
/// Count lines in a file
Count {
/// Path to the file
path: String,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Greet { name } => {
println!("Hello, {}!", name);
}
Commands::Count { path } => {
match std::fs::read_to_string(&path) {
Ok(content) => {
let lines = content.lines().count();
println!("{}: {} lines", path, lines);
}
Err(e) => eprintln!("Error: {}", e),
}
}
}
}Reading from stdin
Many CLI tools read from standard input, allowing them to be used in pipelines:
use std::io::{self, BufRead};
fn main() {
let stdin = io::stdin();
let mut total_lines = 0;
let mut total_words = 0;
for line in stdin.lock().lines() {
let line = line.expect("Failed to read line");
total_lines += 1;
total_words += line.split_whitespace().count();
}
println!("Lines: {}", total_lines);
println!("Words: {}", total_words);
}You can also prompt for user input interactively:
use std::io::{self, Write};
fn prompt(message: &str) -> String {
print!("{}", message);
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
fn main() {
let name = prompt("Enter your name: ");
let age = prompt("Enter your age: ");
println!("Hello {}, you are {} years old!", name, age);
}Putting it together
Here is a complete CLI tool that combines subcommands, file I/O, and error handling:
use clap::{Parser, Subcommand};
use std::fs;
use std::io::{self, BufRead};
#[derive(Parser)]
#[command(name = "wordtool", version, about = "A word counting tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Count words in a file
Count {
/// File to process
path: String,
},
/// Search for a word in a file
Search {
/// Word to search for
word: String,
/// File to search in
path: String,
/// Case-insensitive search
#[arg(short, long)]
ignore_case: bool,
},
/// Read from stdin and count
Stdin,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Commands::Count { path } => {
let content = fs::read_to_string(&path)?;
let words: usize = content.split_whitespace().count();
let lines = content.lines().count();
println!("{}: {} words, {} lines", path, words, lines);
}
Commands::Search { word, path, ignore_case } => {
let content = fs::read_to_string(&path)?;
for (i, line) in content.lines().enumerate() {
let matches = if ignore_case {
line.to_lowercase().contains(&word.to_lowercase())
} else {
line.contains(&word)
};
if matches {
println!("{}:{}: {}", path, i + 1, line);
}
}
}
Commands::Stdin => {
let stdin = io::stdin();
let mut count = 0;
for line in stdin.lock().lines() {
count += line?.split_whitespace().count();
}
println!("Total words: {}", count);
}
}
Ok(())
}Summary
// Manual args
let args: Vec<String> = std::env::args().collect();
// clap derive
#[derive(Parser)]
struct Args {
#[arg(short, long)] // -n / --name
name: String,
#[arg(default_value_t = 1)] // default value
count: u8,
}
// Subcommands
#[derive(Subcommand)]
enum Commands {
Greet { name: String },
Count { path: String },
}
// Build release binary
cargo build --release
// Binary at: target/release/cli-toolYour turn
Try running the CLI tool with cargo run, cargo run -- --help, and build a release binary with cargo build --release: