When I build command line tools in Rust, I focus on combining performance with exceptional user experience. After years of developing CLI applications, I’ve learned that several key techniques make the difference between amateur projects and production-ready tools.
Rust’s ecosystem offers excellent libraries that help create polished command line interfaces. Let’s explore the most important techniques that will elevate your CLI applications.
Structured Argument Parsing
Every professional CLI tool needs a clean, intuitive interface. The clap crate is the standard for handling command line arguments in Rust, offering a declarative approach to defining your interface.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Optional name to operate on
#[arg(short, long)]
name: Option<String>,
/// Sets a custom config file
#[arg(short, long, value_name = "FILE")]
config: Option<std::path::PathBuf>,
/// Turn debugging information on
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Adds files to myapp
Add {
/// Adds files
#[arg(required = true)]
files: Vec<std::path::PathBuf>,
},
/// Remove files from myapp
Remove {
/// Files to remove
files: Vec<std::path::PathBuf>,
},
}
fn main() {
let cli = Cli::parse();
// You can handle arguments here
match &cli.command {
Some(Commands::Add { files }) => {
println!("Adding files: {:?}", files);
}
Some(Commands::Remove { files }) => {
println!("Removing files: {:?}", files);
}
None => {}
}
}
This approach creates a self-documenting CLI that handles parsing, validation, and generates help text automatically. The builder pattern makes it easy to create complex command hierarchies.
Progress Indicators
Users get frustrated when applications provide no feedback during long-running operations. The indicatif crate solves this with elegant progress bars and spinners.
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::{thread, time::Duration};
fn main() {
let m = MultiProgress::new();
let style = ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("#>-");
let pb1 = m.add(ProgressBar::new(100));
pb1.set_style(style.clone());
pb1.set_message("Processing files");
let pb2 = m.add(ProgressBar::new(50));
pb2.set_style(style);
pb2.set_message("Analyzing data");
thread::spawn(move || {
for i in 0..100 {
pb1.inc(1);
thread::sleep(Duration::from_millis(50));
if i == 50 {
pb1.set_message("File processing halfway done");
}
}
pb1.finish_with_message("Files processed");
});
for i in 0..50 {
pb2.inc(1);
thread::sleep(Duration::from_millis(100));
if i == 25 {
pb2.set_message("Analysis halfway done");
}
}
pb2.finish_with_message("Analysis complete");
m.join().unwrap();
}
This creates a professional look and gives users confidence that your application is working as expected.
Human-Friendly Output Formatting
CLI tools should present data in a readable format. The tabled crate makes it easy to display structured data as tables:
use tabled::{Table, Tabled};
use chrono::{DateTime, Utc};
#[derive(Tabled)]
struct FileInfo {
#[tabled(rename = "File Name")]
name: String,
#[tabled(rename = "Size (KB)")]
size: f64,
#[tabled(rename = "Modified")]
modified: String,
#[tabled(rename = "Permissions")]
permissions: String,
}
fn main() {
let files = vec![
FileInfo {
name: "document.pdf".to_string(),
size: 1256.4,
modified: "2023-04-12 14:32".to_string(),
permissions: "rw-r--r--".to_string(),
},
FileInfo {
name: "image.png".to_string(),
size: 485.2,
modified: "2023-04-10 09:15".to_string(),
permissions: "rw-r--r--".to_string(),
},
FileInfo {
name: "script.sh".to_string(),
size: 12.8,
modified: "2023-04-15 11:42".to_string(),
permissions: "rwxr-xr-x".to_string(),
},
];
let table = Table::new(files).to_string();
println!("{}", table);
}
For color and styling, consider combining it with the colored crate to highlight important information.
Graceful Error Handling
Error handling is critical for production applications. The thiserror and anyhow crates simplify creating and propagating errors:
use std::fs::File;
use std::io::Read;
use std::path::Path;
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("Processing failed for file {file}: {reason}")]
ProcessingError {
file: String,
reason: String,
}
}
fn read_config_file(path: &Path) -> Result<String, AppError> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
if contents.is_empty() {
return Err(AppError::ConfigError("Config file is empty".to_string()));
}
Ok(contents)
}
fn process_file(path: &Path) -> Result<(), AppError> {
if !path.exists() {
return Err(AppError::ProcessingError {
file: path.display().to_string(),
reason: "File does not exist".to_string(),
});
}
// Process file...
Ok(())
}
fn run() -> Result<(), AppError> {
let config = read_config_file(Path::new("config.toml"))?;
// Use config...
process_file(Path::new("data.csv"))?;
Ok(())
}
fn main() {
match run() {
Ok(_) => println!("Processing completed successfully"),
Err(e) => eprintln!("Error: {}", e),
}
}
This approach provides context-aware error messages that help users understand what went wrong.
Configuration File Management
Professional applications need to manage configuration from multiple sources. The config crate handles hierarchical configuration:
use config::{Config, ConfigError, File, Environment};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
struct AppConfig {
database: DatabaseConfig,
server: ServerConfig,
logging: LoggingConfig,
}
#[derive(Debug, Deserialize)]
struct DatabaseConfig {
url: String,
pool_size: u32,
}
#[derive(Debug, Deserialize)]
struct ServerConfig {
port: u16,
host: String,
timeout: u64,
}
#[derive(Debug, Deserialize)]
struct LoggingConfig {
level: String,
file: Option<PathBuf>,
}
fn load_config() -> Result<AppConfig, ConfigError> {
let config_dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("myapp");
let mut builder = Config::builder()
// Start with default values
.add_source(File::with_name("config/default.toml").required(false))
// Add system-wide config
.add_source(File::with_name("/etc/myapp/config.toml").required(false))
// Add user-specific config
.add_source(File::with_name(
config_dir.join("config.toml").to_str().unwrap()
).required(false))
// Add local config
.add_source(File::with_name("config.local.toml").required(false))
// Override with environment variables (APP_DATABASE_URL, etc)
.add_source(Environment::with_prefix("APP").separator("_"));
builder.build()?.try_deserialize()
}
fn main() {
match load_config() {
Ok(config) => {
println!("Configuration loaded: {:?}", config);
// Use config in application
},
Err(e) => eprintln!("Failed to load configuration: {}", e),
}
}
This pattern respects standard configuration file locations and allows overriding settings through environment variables.
Interactive User Input
CLI applications often need user input. The dialoguer crate provides interactive prompts:
use dialoguer::{Input, Password, Select, Confirm, MultiSelect, theme::ColorfulTheme};
fn interactive_setup() -> Result<(), std::io::Error> {
let theme = ColorfulTheme::default();
let name: String = Input::with_theme(&theme)
.with_prompt("What's your name?")
.default("User".into())
.interact_text()?;
let password = Password::with_theme(&theme)
.with_prompt("Choose a password")
.with_confirmation("Confirm password", "Passwords don't match")
.interact()?;
let level = Select::with_theme(&theme)
.with_prompt("Choose your experience level")
.default(0)
.items(&["Beginner", "Intermediate", "Advanced"])
.interact()?;
let features = MultiSelect::with_theme(&theme)
.with_prompt("Select desired features")
.items(&["Auto-save", "Cloud backup", "Dark mode", "Notifications"])
.interact()?;
let confirm = Confirm::with_theme(&theme)
.with_prompt("Do you want to proceed with these settings?")
.default(true)
.interact()?;
if confirm {
println!("Setup complete for {}!", name);
println!("Selected level: {}", ["Beginner", "Intermediate", "Advanced"][level]);
println!("Selected features:");
for &idx in &features {
println!("- {}", ["Auto-save", "Cloud backup", "Dark mode", "Notifications"][idx]);
}
} else {
println!("Setup cancelled");
}
Ok(())
}
fn main() {
if let Err(e) = interactive_setup() {
eprintln!("Setup failed: {}", e);
}
}
These components create an intuitive flow for configuration or data collection.
Signals and Shutdown Handling
Production applications must handle interrupts and terminate gracefully. Here’s how to implement this with tokio:
use tokio::signal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::time::{sleep, Duration};
async fn cleanup() {
println!("Cleaning up resources...");
// Close database connections, flush files, etc.
sleep(Duration::from_millis(500)).await;
println!("Cleanup complete");
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a flag to track shutdown state
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
// Spawn a task to handle Ctrl+C
tokio::spawn(async move {
if let Err(e) = signal::ctrl_c().await {
eprintln!("Failed to listen for ctrl+c: {}", e);
return;
}
println!("\nShutdown signal received");
r.store(false, Ordering::SeqCst);
});
println!("Application running (press Ctrl+C to stop)");
// Main processing loop
let mut counter = 0;
while running.load(Ordering::SeqCst) {
println!("Working... {}", counter);
counter += 1;
// Simulate work
sleep(Duration::from_secs(1)).await;
// Check shutdown flag periodically
if counter >= 10 || !running.load(Ordering::SeqCst) {
break;
}
}
// Handle cleanup
cleanup().await;
println!("Application terminated gracefully");
Ok(())
}
This pattern ensures your application can save state and free resources when interrupted.
Logging and Diagnostics
Comprehensive logging is essential for troubleshooting. The tracing crate provides structured logging:
use tracing::{info, warn, error, debug, Level};
use tracing_subscriber::{FmtSubscriber, EnvFilter};
use std::fs::File;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
fn setup_logging(verbose: bool) {
let filter_level = if verbose { Level::DEBUG } else { Level::INFO };
// Create a file appender that rolls daily
let file_appender = RollingFileAppender::new(
Rotation::DAILY,
"logs",
"application.log",
);
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
// Set up the subscriber
let subscriber = FmtSubscriber::builder()
.with_env_filter(EnvFilter::from_default_env().add_directive(filter_level.into()))
.with_writer(non_blocking)
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("Failed to set tracing subscriber");
info!("Logging initialized");
}
fn process_data(items: &[i32]) -> Result<(), String> {
info!("Processing {} items", items.len());
for (i, &item) in items.iter().enumerate() {
if item < 0 {
warn!(item = item, "Negative value encountered");
}
debug!(index = i, value = item, "Processing item");
// Simulate an error condition
if item == 0 {
error!(item = item, "Invalid zero value detected");
return Err("Zero values are not allowed".to_string());
}
}
info!("Processing completed successfully");
Ok(())
}
fn main() {
setup_logging(true);
info!("Application started");
let data = vec![5, 10, -3, 8, 0, 12];
match process_data(&data) {
Ok(_) => info!("Data processing successful"),
Err(e) => error!(error = %e, "Data processing failed"),
}
info!("Application shutting down");
}
The tracing ecosystem gives you structured logs that are invaluable for debugging production issues.
I’ve found these eight techniques to be fundamental when building production-ready CLI tools in Rust. They create applications that are not only functional but also offer a great user experience with informative output, clear error messages, and graceful behavior.
The combination of these patterns has helped me create tools that users actually enjoy using, rather than just tolerating. Each technique addresses a specific aspect of application quality, and together they create a comprehensive approach to CLI development in Rust.
By incorporating these practices into your development workflow, you’ll be able to create professional command line tools that stand out for their reliability and usability.