Building command-line tools in Rust combines performance with safety. The language’s strict compiler and rich ecosystem help create utilities that feel solid under heavy use. I’ve found these eight approaches particularly effective for making CLI applications that users trust.
Ergonomic Argument Parsing
Defining arguments as a struct feels natural. The clap
crate handles validation during compilation, catching mistakes before users run your tool. This snippet creates required input and optional output parameters:
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about)]
struct Options {
#[arg(short, long, help = "Input file path")]
input: PathBuf,
#[arg(short, long, default_value = "out.txt", help = "Output destination")]
output: PathBuf,
}
fn main() {
let opts = Options::parse();
println!("Converting {:?} to {:?}", opts.input, opts.output);
}
Missing required flags triggers auto-generated help messages. I always add help
text - it appears in documentation without extra effort.
Streaming File Processing
Handling large datasets requires memory efficiency. Buffered readers process data in chunks rather than loading entire files. This CSV processor demonstrates:
use std::{
fs::File,
io::{BufRead, BufReader}
};
fn sum_csv_column(path: &str, column: usize) -> Result<f64, std::io::Error> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut total = 0.0;
for line in reader.lines().filter_map(Result::ok) {
if let Some(value) = line.split(',').nth(column) {
total += value.parse::<f64>().unwrap_or(0.0);
}
}
Ok(total)
}
The buffered approach keeps memory usage stable even with 100GB files. I pair this with progress indicators for long operations.
User Feedback with Progress Bars
Visual feedback prevents users from killing processes prematurely. indicatif
provides configurable progress indicators:
use indicatif::{ProgressBar, ProgressStyle};
use std::thread;
use std::time::Duration;
fn download_files(urls: &[&str]) {
let bar = ProgressBar::new(urls.len() as u64);
bar.set_style(ProgressStyle::with_template(
"{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({eta})"
).unwrap());
for url in urls {
thread::sleep(Duration::from_millis(500)); // Simulate download
bar.inc(1);
bar.set_message(format!("Downloading {}", url));
}
bar.finish_with_message("Downloads complete");
}
Spinners, percentage counters, and ETA estimates transform user experience. I always test these under slow network conditions.
Colorized Terminal Output
Strategic color usage directs attention to critical information. The colored
crate works across platforms:
use colored::*;
use std::process::exit;
fn validate_config(config: &Config) {
if config.invalid_keys().is_empty() {
println!("{}", "✓ Configuration valid".bright_green());
} else {
eprintln!("{}", "⚠ Invalid keys found:".bright_yellow());
for key in config.invalid_keys() {
eprintln!(" - {}", key.red().bold());
}
exit(1);
}
}
I follow accessibility guidelines - never rely solely on color to convey meaning. Important errors always include text indicators.
Error Reporting with Context
Actionable errors reduce support requests. anyhow
and thiserror
create diagnostic chains:
use anyhow::{Context, Result};
use std::fs;
#[derive(thiserror::Error, Debug)]
enum ConfigError {
#[error("Invalid API key format")]
InvalidKey,
#[error("Missing required field: {0}")]
MissingField(String),
}
fn load_config() -> Result<Config> {
let data = fs::read_to_string("config.yaml")
.context("Configuration file not found")?;
let config: Config = serde_yaml::from_str(&data)
.context("Malformed YAML structure")?;
config.validate().map_err(|e| match e {
ValidationError::MissingField(f) => ConfigError::MissingField(f).into(),
ValidationError::InvalidKey => ConfigError::InvalidKey.into(),
})
}
Error messages should answer two questions: what happened and what can I do next?
Automated Output Formatting
Supporting multiple output formats increases tool flexibility. A dispatch-based approach works well:
use serde_json::json;
use std::fmt;
struct Report {
successes: usize,
failures: usize,
}
impl Report {
fn to_text(&self) -> String {
format!("Successes: {}\nFailures: {}", self.successes, self.failures)
}
fn to_json(&self) -> String {
json!({
"results": {
"successful": self.successes,
"failed": self.failures
}
}).to_string()
}
fn print(&self, format: OutputFormat) {
match format {
OutputFormat::Text => println!("{}", self.to_text()),
OutputFormat::Json => println!("{}", self.to_json()),
}
}
}
I implement Display
for text output and serde::Serialize
for JSON to keep concerns separated.
Subcommand Routing
Complex tools benefit from command hierarchies. Match statements dispatch cleanly:
use clap::{Parser, Subcommand};
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize new project
Init {
#[arg(short, long)]
name: String
},
/// Build project assets
Build {
#[arg(short, long)]
target: BuildTarget,
#[arg(short = 'r', long)]
release: bool
},
}
fn main() {
match Cli::parse().command {
Commands::Init { name } => create_project(&name),
Commands::Build { target, release } => compile(target, release),
}
}
Documentation comments become automatic help text. I keep subcommands in separate modules as projects grow.
Configuration Layering
Merge settings from multiple sources with clear precedence rules:
use config::{Config, File, Environment};
struct Settings {
timeout: u32,
retries: u8,
}
impl Settings {
fn new() -> Result<Self, config::ConfigError> {
let mut builder = Config::builder();
// Defaults
builder = builder.set_default("timeout", 30)?;
builder = builder.set_default("retries", 3)?;
// config.toml overrides
builder = builder.add_source(File::with_name("config").required(false));
// Environment variables (APP_TIMEOUT=10)
builder = builder.add_source(Environment::with_prefix("APP"));
let config = builder.build()?;
Ok(Settings {
timeout: config.get("timeout")?,
retries: config.get("retries")?,
})
}
}
I implement Default
for settings as safety nets. Environment variables work particularly well in containerized environments.
These patterns create tools that withstand real-world use. Rust’s type system catches entire classes of errors during development, while the crate ecosystem provides production-ready solutions. The result? Utilities that work reliably whether processing three records or three million.