Building CLI Apps in Rust: A Practical Guide
Rust is an excellent language for building command-line tools. It produces fast, statically-linked binaries, has no runtime overhead, and great libraries for argument parsing and output formatting. Tools like ripgrep, bat, fd, and delta are all written in Rust.
This guide walks through building a CLI application using the clap crate for argument parsing, anyhow for error handling, and indicatif for progress indicators.
Getting Started
Create a new project:
cargo new mycli
cd mycliAdd dependencies to Cargo.toml:
[package]
name = "mycli"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
indicatif = "0.17"
colored = "2"Argument Parsing with Clap
Clap 4 includes derive macros that define argument parsers declaratively:
use clap::Parser;
#[derive(Parser)]
#[command(name = "mycli")]
#[command(about = "A powerful CLI tool", long_about = None)]
struct Cli {
/// Input file path
#[arg(short, long, value_name = "FILE")]
input: String,
/// Output file path (optional)
#[arg(short, long, value_name = "FILE")]
output: Option<String>,
/// Verbose output
#[arg(short, long, default_value_t = false)]
verbose: bool,
/// Number of retries
#[arg(short, long, default_value_t = 3)]
retries: u32,
/// Config file path
#[arg(short = 'c', long, default_value = "config.toml")]
config: String,
---
fn main() {
let cli = Cli::parse();
if cli.verbose {
eprintln!("Processing file: {}", cli.input);
}
println!("Input: {}", cli.input);
if let Some(output) = cli.output {
println!("Output: {}", output);
}
---Run with cargo run -- --input data.txt --verbose.
Argument Types
Clap validates types at parse time:
#[derive(Parser)]
struct Opts {
/// Integer (validated by clap)
#[arg(short)]
count: u32,
/// Float
#[arg(short)]
ratio: f64,
/// Enum
#[arg(value_enum)]
mode: Mode,
/// List of values
#[arg(short, long, num_args = 1..)]
items: Vec<String>,
/// Key=value pairs
#[arg(short, long, value_parser = parse_key_val)]
define: Vec<(String, String)>,
---
#[derive(clap::ValueEnum, Clone)]
enum Mode {
Fast,
Accurate,
Balanced,
---
fn parse_key_val(s: &str) -> anyhow::Result<(String, String)> {
let mut parts = s.splitn(2, '=');
let key = parts.next().ok_or_else(|| anyhow::anyhow!("missing key"))?.to_string();
let val = parts.next().ok_or_else(|| anyhow::anyhow!("missing value"))?.to_string();
Ok((key, val))
---Subcommands
Subcommands create multi-tool interfaces like git commit, git push:
#[derive(Parser)]
#[command(name = "taskctl")]
enum Command {
/// Create a new task
Create(CreateArgs),
/// List all tasks
List(ListArgs),
/// Complete a task
Complete { id: u32 },
---
#[derive(clap::Args)]
struct CreateArgs {
/// Task title
title: String,
/// Task priority
#[arg(short, long, default_value_t = 5)]
priority: u32,
---
#[derive(clap::Args)]
struct ListArgs {
/// Filter by status
#[arg(short, long)]
status: Option<String>,
/// Show all tasks including completed
#[arg(short, long)]
all: bool,
---
fn main() -> anyhow::Result<()> {
let command = Command::parse();
match command {
Command::Create(args) => create_task(&args),
Command::List(args) => list_tasks(&args),
Command::Complete { id } => complete_task(id),
}
---Usage:
taskctl create "Write documentation" --priority 3
taskctl list --status pending
taskctl complete 42Error Handling with Anyhow
anyhow provides ergonomic error handling:
use anyhow::{Context, Result};
fn read_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config from {}", path))?;
let config: Config = serde_json::from_str(&content)
.with_context(|| "Failed to parse config JSON")?;
Ok(config)
---
fn process_file(input: &str, output: &str) -> Result<u64> {
let data = std::fs::read(input)
.with_context(|| format!("Cannot read input file: {}", input))?;
let processed: Vec<u8> = data.iter().map(|b| b ^ 0xFF).collect();
std::fs::write(output, &processed)
.with_context(|| format!("Failed to write output: {}", output))?;
Ok(processed.len() as u64)
---
fn main() -> Result<()> {
let cli = Cli::parse();
let config = read_config(&cli.config)?;
match process_file(&cli.input, &cli.output.unwrap_or_else(|| "output.bin".into())) {
Ok(bytes) => {
println!("Processed {} bytes", bytes);
Ok(())
}
Err(e) => {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
---The {:#} format with anyhow prints a chain of error contexts, making debugging easier for users.
Output Formatting
Colored Output
use colored::*;
fn print_status(level: &str, message: &str) {
match level {
"info" => println!("{} {}", "INFO".green(), message),
"warn" => println!("{} {}", "WARN".yellow(), message),
"error" => println!("{} {}", "ERROR".red().bold(), message),
_ => println!("{}", message),
}
---Tables
fn print_tasks(tasks: &[Task]) {
println!("{:<5} {:<30} {:<10} {:<10}", "ID", "Title", "Priority", "Status");
println!("{}", "-".repeat(60));
for task in tasks {
let status = if task.completed { "done".green() } else { "pending".yellow() };
println!("{:<5} {:<30} {:<10} {}", task.id, task.title, task.priority, status);
}
---JSON Output
Support --json flag for machine-readable output:
fn print_output(data: &impl serde::Serialize, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(data)?);
} else {
// human-readable output
print_human(data);
}
Ok(())
---Progress Indicators
Long-running operations benefit from progress bars:
use indicatif::{ProgressBar, ProgressStyle};
use std::thread;
use std::time::Duration;
fn process_files(files: &[String]) -> Result<()> {
let pb = ProgressBar::new(files.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")?
.progress_chars("#>-"),
);
for file in files {
pb.set_message(format!("Processing {}", file));
thread::sleep(Duration::from_millis(500)); // Simulate work
pb.inc(1);
}
pb.finish_with_message("Done!");
Ok(())
---Spinners
use indicatif::ProgressBar;
fn search_pattern(pattern: &str, path: &str) -> Result<Vec<String>> {
let spinner = ProgressBar::new_spinner();
spinner.set_message(format!("Searching for '{}' in {}", pattern, path));
spinner.enable_steady_tick(Duration::from_millis(100));
let results = perform_search(pattern, path)?;
spinner.finish_and_clear();
Ok(results)
---File System Operations
CLI tools often work with the filesystem:
use std::path::{Path, PathBuf};
fn find_config() -> Result<PathBuf> {
// XDG base directory specification
let xdg_config = std::env::var("XDG_CONFIG_HOME")
.map(|d| PathBuf::from(d).join("mycli/config.toml"))
.unwrap_or_else(|_| {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("mycli/config.toml")
});
if xdg_config.exists() {
return Ok(xdg_config);
}
let local = PathBuf::from("./mycli.toml");
if local.exists() {
return Ok(local);
}
anyhow::bail!("No config file found. Create one at {} or ./mycli.toml", xdg_config.display());
---Structured CLI Design
Exit Codes
Use exit codes to signal different failure modes:
fn main() {
let result = run();
match result {
Ok(exit_code) => std::process::exit(exit_code),
Err(e) => {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
---
fn run() -> Result<i32> {
// 0 = success, 1 = no results, 2 = partial failure
Ok(0)
---Stderr for Diagnostics, Stdout for Data
Follow the Unix philosophy: print data to stdout, diagnostics and errors to stderr:
// Good: normal output to stdout
println!("{}", result);
// Good: progress and errors to stderr
eprintln!("Processing file {} of {}", current, total);
eprintln!("Error: file not found: {}", path);This allows piping: mycli list --json | jq '.items' without progress messages interfering.
Testing CLI Applications
#[cfg(test)]
mod tests {
use super::*;
use assert_cmd::Command;
#[test]
fn test_help() {
let mut cmd = Command::cargo_bin("mycli").unwrap();
cmd.arg("--help")
.assert()
.success();
}
#[test]
fn test_version() {
let mut cmd = Command::cargo_bin("mycli").unwrap();
cmd.arg("--version")
.assert()
.success()
.stdout(predicates::str::contains("0.1.0"));
}
---Add assert_cmd and predicates to dev-dependencies for CLI integration testing.
Summary
Rust’s type system, performance, and cross-compilation make it ideal for CLI tools. Clap provides ergonomic argument parsing with derive macros. Anyhow simplifies error handling with context chains. Indicatif adds professional progress indicators. Colored output, JSON serialization, and testable command structures complete the picture. Start with a clear subcommand structure, handle errors gracefully, and format output for both humans and machines.
Argument Parsing with Clap
Clap is the standard argument parsing library for Rust CLIs. Derive mode uses #[derive(Parser)] on a struct to define arguments, options, and subcommands with minimal boilerplate. It generates help text, shell completions, and error messages automatically. Builder mode provides more flexibility for programmatic use cases.
Terminal Output and Formatting
Crates like colored and termion add color and styling to terminal output. indicatif provides progress bars and spinners for long-running operations. dialoguer enables interactive prompts with autocomplete, confirmation dialogs, and multi-select menus. These libraries transform simple CLIs into polished user experiences.
Rust in Production: Case Studies
Rust has been adopted in production by major technology companies for performance-critical and safety-critical systems. Mozilla uses Rust in the Firefox browser engine (Servo/Quantum) for improved memory safety and performance in CSS layout and rendering. Dropbox rewrote their sync engine core in Rust, achieving significant performance improvements and eliminating entire classes of bugs. Cloudflare uses Rust for edge computing infrastructure, including their Pingora HTTP proxy that handles 10% of the world’s internet traffic. Figma rewrote their multiplayer editing engine in Rust, achieving predictable performance and eliminating crashes. Discord used Rust for their read states service, handling millions of concurrent connections with minimal resources. These case studies demonstrate Rust’s value in systems where performance, reliability, and memory safety are critical. The common pattern: rewrite performance-critical components in Rust while maintaining the rest of the system in the original language, proving that incremental adoption works in practice.
Embedded Systems Programming
Rust is gaining traction in embedded systems for its memory safety guarantees without a garbage collector or runtime. The cortex-m-quickstart template provides a starting point for ARM Cortex-M microcontrollers. The RTIC framework provides real-time interrupt-driven concurrency. Embassy offers an async embedded runtime with USB, networking, and BLE support. Rust’s type system prevents common embedded errors like misconfiguring peripherals, incorrect register access, and data races between interrupt handlers and main code. The embedded ecosystem continues to mature with HAL implementations for major microcontroller families from STM, Nordic, and Espressif.
FAQ
What background knowledge is recommended? A basic understanding of programming fundamentals will help, but many concepts are explained from first principles.
How can I practice these skills? Start with small projects, contribute to open source, and build a portfolio. Consistent practice is more effective than occasional deep dives.
What tools do I need to get started? Most topics require only a text editor, the relevant runtime, and package manager. Specific tool recommendations are included throughout.
For a comprehensive overview, read our article on Getting Started With Rust.
For a comprehensive overview, read our article on Rust Async Programming.