As a Rust developer, I’ve found that the right tools can dramatically simplify creating command-line applications. Over years of building CLI tools, I’ve identified several crates that consistently prove their worth. Here are the ten Rust crates I consider essential for crafting robust command-line tools.
Clap: Command Line Argument Parser
Clap stands as the gold standard for handling command-line arguments in Rust. Its declarative style makes even complex command structures intuitive.
use clap::{Arg, Command};
fn main() {
let matches = Command::new("archiver")
.version("1.0")
.author("Me <[email protected]>")
.about("Archives files with compression")
.arg(Arg::new("source")
.short('s')
.long("source")
.value_name("DIRECTORY")
.help("Source directory to compress")
.required(true))
.arg(Arg::new("output")
.short('o')
.long("output")
.value_name("FILE")
.help("Output file name"))
.arg(Arg::new("compression")
.short('c')
.long("compression")
.value_name("LEVEL")
.help("Compression level (1-9)")
.default_value("6"))
.get_matches();
let source = matches.get_one::<String>("source").unwrap();
let output = matches.get_one::<String>("output")
.map(|s| s.as_str())
.unwrap_or("output.tar.gz");
let compression = matches.get_one::<String>("compression")
.map(|s| s.parse::<u8>().unwrap_or(6))
.unwrap();
println!("Compressing {} to {} with level {}", source, output, compression);
}
I appreciate Clap’s validation capabilities, which prevent many runtime errors by catching issues at parse time. The automatic help generation saves hours of documentation work.
Indicatif: Progress Bars and Spinners
Nothing frustrates users more than a silent, seemingly frozen CLI. Indicatif solves this with elegant progress indicators.
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::thread;
use std::time::Duration;
fn main() {
let multi = MultiProgress::new();
let style = ProgressStyle::default_bar()
.template("{prefix:.bold.dim} {spinner} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
.unwrap()
.progress_chars("##-");
let pb1 = multi.add(ProgressBar::new(128));
pb1.set_style(style.clone());
pb1.set_prefix("Downloading:");
let pb2 = multi.add(ProgressBar::new(1024));
pb2.set_style(style);
pb2.set_prefix("Processing: ");
thread::spawn(move || {
for i in 0..128 {
pb1.inc(1);
thread::sleep(Duration::from_millis(15));
}
pb1.finish_with_message("Download complete");
});
for i in 0..1024 {
pb2.inc(1);
thread::sleep(Duration::from_millis(2));
}
pb2.finish_with_message("Processing complete");
multi.join().unwrap();
}
I’ve found that adding progress indicators boosts perceived performance and user satisfaction. The MultiProgress feature is particularly useful for showing parallel operations.
Crossterm: Terminal Control
Terminal manipulation is essential for interactive CLIs, and Crossterm provides this capability across platforms.
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{read, Event, KeyCode},
execute,
style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor},
terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
Result,
};
use std::io::{stdout, Write};
fn main() -> Result<()> {
let mut stdout = stdout();
execute!(
stdout,
EnterAlternateScreen,
Clear(ClearType::All),
Hide
)?;
// Draw a simple menu
execute!(
stdout,
MoveTo(5, 3),
SetForegroundColor(Color::White),
SetBackgroundColor(Color::Blue),
Print(" Simple Menu "),
ResetColor
)?;
execute!(
stdout,
MoveTo(5, 5),
Print("1. Option One"),
MoveTo(5, 6),
Print("2. Option Two"),
MoveTo(5, 7),
Print("3. Exit"),
MoveTo(5, 9),
Print("Press a number key to select: ")
)?;
// Wait for user input
let result = loop {
match read()? {
Event::Key(event) => {
match event.code {
KeyCode::Char('1') => break "Option One selected",
KeyCode::Char('2') => break "Option Two selected",
KeyCode::Char('3') | KeyCode::Char('q') | KeyCode::Esc => break "Exiting",
_ => {}
}
}
_ => {}
}
};
// Clean up and show result
execute!(
stdout,
Clear(ClearType::All),
MoveTo(5, 3),
Print(result),
MoveTo(5, 5),
Print("Press any key to exit..."),
Show
)?;
read()?; // Wait for key press
execute!(stdout, LeaveAlternateScreen)?;
Ok(())
}
Before discovering Crossterm, I struggled with platform-specific terminal code. Now I can create consistent interactive interfaces across Windows, macOS, and Linux.
Log and Env_logger: Structured Logging
Proper logging is crucial for debugging and monitoring CLI tools in production.
use log::{debug, error, info, warn, LevelFilter};
use env_logger::Builder;
use std::io::Write;
fn main() {
// Initialize with custom format
Builder::new()
.format(|buf, record| {
writeln!(
buf,
"{} [{}] - {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
record.level(),
record.args()
)
})
.filter(None, LevelFilter::Info)
.parse_env("MY_APP_LOG") // Allow override with env var
.init();
info!("Application starting");
debug!("Configuration loaded from default path");
let connection_result = connect_to_service();
if connection_result.is_err() {
error!("Failed to connect: {}", connection_result.unwrap_err());
}
warn!("Using deprecated API call");
info!("Operation completed");
}
fn connect_to_service() -> Result<(), String> {
// Simulated connection logic
Err("Connection timeout".to_string())
}
I’ve saved countless debugging hours by implementing proper logging early in my projects. The ability to control verbosity through environment variables is particularly useful.
Dialoguer: Interactive User Input
When simple command-line arguments aren’t enough, Dialoguer provides rich interactive prompts.
use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Password, Select};
fn main() {
let theme = ColorfulTheme::default();
// Simple text input
let name: String = Input::with_theme(&theme)
.with_prompt("What's your name?")
.with_initial_text("User")
.validate_with(|input: &String| -> Result<(), &str> {
if input.trim().is_empty() {
Err("Name cannot be empty")
} else {
Ok(())
}
})
.interact_text()
.unwrap();
// Password input (masked)
let password = Password::with_theme(&theme)
.with_prompt("Enter your password")
.with_confirmation("Confirm password", "Passwords don't match")
.interact()
.unwrap();
// Selection from list
let items = vec!["Small", "Medium", "Large", "Extra Large"];
let selection = Select::with_theme(&theme)
.with_prompt("Select size")
.default(0)
.items(&items)
.interact()
.unwrap();
// Multiple selection
let features = vec!["Git integration", "Syntax highlighting", "Auto-complete", "Themes", "Extensions"];
let selections = MultiSelect::with_theme(&theme)
.with_prompt("Select features")
.items(&features)
.defaults(&[true, true, false, false, false])
.interact()
.unwrap();
// Confirmation
let confirmed = Confirm::with_theme(&theme)
.with_prompt("Do you want to proceed?")
.default(true)
.interact()
.unwrap();
println!("\nSummary:");
println!("Name: {}", name);
println!("Password set: {}", if !password.is_empty() { "Yes" } else { "No" });
println!("Selected size: {}", items[selection]);
println!("Selected features:");
for selection in selections {
println!(" - {}", features[selection]);
}
println!("Proceeding: {}", confirmed);
}
My users appreciate the guided experience Dialoguer provides, especially for complex configuration tasks. The validation capabilities prevent many common input errors.
Colored: Text Styling
Visual distinction through color improves readability and user experience.
use colored::*;
use std::collections::HashMap;
fn main() {
// Show application header
println!("{}", "FILE ANALYZER 1.0".bright_blue().bold());
println!("{}", "=================".bright_blue());
// Display file information
let files = analyze_files();
if files.is_empty() {
println!("{}", "No files found".red());
return;
}
println!("\n{} files found\n", files.len().to_string().green());
for (name, info) in files {
println!("{}: {}", "File".blue(), name);
println!(" {}: {}", "Size".yellow(), format_size(info.size));
println!(" {}: {}", "Type".yellow(), info.file_type);
if info.is_executable {
println!(" {}", "EXECUTABLE".green());
}
if info.is_hidden {
println!(" {}", "HIDDEN".red());
}
println!();
}
}
struct FileInfo {
size: u64,
file_type: String,
is_executable: bool,
is_hidden: bool,
}
fn analyze_files() -> HashMap<String, FileInfo> {
// Simplified example
let mut files = HashMap::new();
files.insert("document.pdf".to_string(), FileInfo {
size: 1_540_982,
file_type: "PDF Document".to_string(),
is_executable: false,
is_hidden: false,
});
files.insert("server.sh".to_string(), FileInfo {
size: 4_829,
file_type: "Shell Script".to_string(),
is_executable: true,
is_hidden: false,
});
files.insert(".config".to_string(), FileInfo {
size: 892,
file_type: "Configuration".to_string(),
is_executable: false,
is_hidden: true,
});
files
}
fn format_size(size: u64) -> String {
if size < 1024 {
format!("{} B", size)
} else if size < 1024 * 1024 {
format!("{:.1} KB", size as f64 / 1024.0)
} else if size < 1024 * 1024 * 1024 {
format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", size as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
I’ve found that strategic use of color makes command output much easier to scan. The colored crate’s simple API makes implementation painless.
Serde with Serde_json: Serialization
Configuration management is central to most CLI tools, and Serde makes it straightforward.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize, Deserialize)]
struct ServerConfig {
host: String,
port: u16,
workers: u8,
features: Features,
endpoints: Vec<Endpoint>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Features {
logging: bool,
metrics: bool,
tls: Option<TlsConfig>,
}
#[derive(Debug, Serialize, Deserialize)]
struct TlsConfig {
cert_file: String,
key_file: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct Endpoint {
path: String,
method: String,
rate_limit: Option<u32>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load configuration
let config_path = "server_config.json";
let config = if Path::new(config_path).exists() {
let config_text = fs::read_to_string(config_path)?;
serde_json::from_str(&config_text)?
} else {
// Create default configuration
let default_config = ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,
workers: 4,
features: Features {
logging: true,
metrics: false,
tls: None,
},
endpoints: vec![
Endpoint {
path: "/api/v1/users".to_string(),
method: "GET".to_string(),
rate_limit: Some(100),
},
Endpoint {
path: "/api/v1/status".to_string(),
method: "GET".to_string(),
rate_limit: None,
},
],
};
// Save default configuration
let config_json = serde_json::to_string_pretty(&default_config)?;
fs::write(config_path, config_json)?;
default_config
};
println!("Server configuration:");
println!("Host: {}", config.host);
println!("Port: {}", config.port);
println!("Workers: {}", config.workers);
println!("Logging enabled: {}", config.features.logging);
println!("Metrics enabled: {}", config.features.metrics);
if let Some(tls) = config.features.tls {
println!("TLS enabled:");
println!(" Cert file: {}", tls.cert_file);
println!(" Key file: {}", tls.key_file);
} else {
println!("TLS disabled");
}
println!("\nConfigured endpoints:");
for endpoint in config.endpoints {
println!(" {} {}", endpoint.method, endpoint.path);
if let Some(limit) = endpoint.rate_limit {
println!(" Rate limit: {} requests/minute", limit);
}
}
Ok(())
}
The ability to transparently serialize and deserialize complex data structures has simplified many of my projects. Combined with JSON, YAML, or TOML parsers, Serde makes configuration management trivial.
Rustyline: Command History and Editing
Interactive shells benefit greatly from command history and editing capabilities.
use rustyline::error::ReadlineError;
use rustyline::{Editor, Config, CompletionType};
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::hint::{Hinter, HistoryHinter};
use std::borrow::Cow::{self, Borrowed, Owned};
use std::collections::HashMap;
struct MyCompleter {
commands: Vec<String>,
file_completer: FilenameCompleter,
}
impl Completer for MyCompleter {
type Candidate = Pair;
fn complete(&self, line: &str, pos: usize) -> Result<(usize, Vec<Pair>), ReadlineError> {
if line.starts_with("open ") || line.starts_with("save ") {
// Complete filenames for "open" and "save" commands
let (pos, mut filenames) = self.file_completer.complete(line, pos)?;
return Ok((pos, filenames));
}
// Complete command names
let mut completions = Vec::new();
for command in &self.commands {
if command.starts_with(line) {
completions.push(Pair {
display: command.clone(),
replacement: command.clone(),
});
}
}
Ok((0, completions))
}
}
struct MyHinter {
history_hinter: HistoryHinter,
}
impl Hinter for MyHinter {
type Hint = String;
fn hint(&self, line: &str, pos: usize) -> Option<String> {
self.history_hinter.hint(line, pos)
}
}
fn main() -> rustyline::Result<()> {
// Set up editor with custom config
let config = Config::builder()
.history_ignore_space(true)
.completion_type(CompletionType::List)
.build();
let commands = vec![
"help".to_string(),
"open".to_string(),
"save".to_string(),
"quit".to_string(),
"clear".to_string(),
"status".to_string(),
];
let my_completer = MyCompleter {
commands: commands.clone(),
file_completer: FilenameCompleter::new(),
};
let my_hinter = MyHinter {
history_hinter: HistoryHinter {},
};
let mut rl = Editor::with_config(config)?;
rl.set_completer(Some(my_completer));
rl.set_hinter(Some(my_hinter));
// Load history if available
if rl.load_history("history.txt").is_err() {
println!("No previous history");
}
let mut command_handlers: HashMap<String, Box<dyn Fn(&str)>> = HashMap::new();
command_handlers.insert("help".to_string(), Box::new(|_| {
println!("Available commands:");
println!(" help - Show this help");
println!(" open <file> - Open a file");
println!(" save <file> - Save to a file");
println!(" status - Show current status");
println!(" clear - Clear the screen");
println!(" quit - Exit the program");
}));
command_handlers.insert("open".to_string(), Box::new(|args| {
println!("Opening file: {}", args.trim());
}));
command_handlers.insert("save".to_string(), Box::new(|args| {
println!("Saving to file: {}", args.trim());
}));
command_handlers.insert("status".to_string(), Box::new(|_| {
println!("Current status: Running");
}));
command_handlers.insert("clear".to_string(), Box::new(|_| {
print!("\x1B[2J\x1B[1;1H");
}));
println!("Interactive Shell (type 'help' for commands, 'quit' to exit)");
let mut running = true;
while running {
match rl.readline(">> ") {
Ok(line) => {
let line = line.trim();
if !line.is_empty() {
rl.add_history_entry(line)?;
if line == "quit" {
println!("Goodbye!");
running = false;
continue;
}
let parts: Vec<&str> = line.splitn(2, ' ').collect();
let command = parts[0];
let args = parts.get(1).unwrap_or(&"");
if let Some(handler) = command_handlers.get(command) {
handler(args);
} else {
println!("Unknown command: {}", command);
println!("Type 'help' for available commands");
}
}
},
Err(ReadlineError::Interrupted) => {
println!("CTRL-C pressed, type 'quit' to exit");
},
Err(ReadlineError::Eof) => {
println!("CTRL-D pressed, exiting");
break;
},
Err(err) => {
println!("Error: {:?}", err);
break;
}
}
}
rl.save_history("history.txt")?;
Ok(())
}
For interactive CLIs, Rustyline has been a game-changer in my projects. Users appreciate the familiar readline behavior, and I appreciate not having to implement this complex functionality from scratch.
Ctrlc: Signal Handling
Proper signal handling ensures graceful shutdowns and prevents data corruption.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use std::fs::File;
use std::io::Write;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Shared state to track if application should continue running
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
// Set up Ctrl+C handler
ctrlc::set_handler(move || {
println!("\nReceived termination signal, shutting down gracefully...");
r.store(false, Ordering::SeqCst);
})?;
// Resource that needs to be cleaned up properly
let mut temp_file = File::create("temp_data.txt")?;
println!("Long-running process started. Press Ctrl+C to exit.");
println!("Writing data to temporary file...");
// Simulate a long-running process with periodic writes
let mut counter = 0;
while running.load(Ordering::SeqCst) {
counter += 1;
// Write some data periodically
if counter % 10 == 0 {
writeln!(temp_file, "Data line {}", counter / 10)?;
temp_file.flush()?;
println!("Wrote data chunk {}", counter / 10);
}
// Simulate work
thread::sleep(Duration::from_millis(100));
}
// Clean up resources
println!("Cleaning up resources...");
drop(temp_file);
// In a real application, you might want to:
// 1. Flush and close files
// 2. Complete in-flight network operations
// 3. Persist state
// 4. Release locks
println!("Shutdown complete. Goodbye!");
Ok(())
}
Before implementing proper signal handling, I had issues with corrupted data and incomplete operations when users terminated my tools. The ctrlc crate solved these problems elegantly.
Dirs and Fs_extra: File System Management
Managing configuration files and application data requires proper directory handling.
use std::fs;
use std::path::{Path, PathBuf};
use fs_extra::dir::{copy, CopyOptions};
use fs_extra::file::read_to_string;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Find appropriate directories for this application
let app_name = "my_awesome_cli";
// Get standard directories
let config_dir = get_config_dir(app_name)?;
let data_dir = get_data_dir(app_name)?;
let cache_dir = get_cache_dir(app_name)?;
println!("Application directories:");
println!("Config: {}", config_dir.display());
println!("Data: {}", data_dir.display());
println!("Cache: {}", cache_dir.display());
// Create directories if they don't exist
for dir in &[&config_dir, &data_dir, &cache_dir] {
if !dir.exists() {
println!("Creating directory: {}", dir.display());
fs::create_dir_all(dir)?;
}
}
// Create a default config file if it doesn't exist
let config_file = config_dir.join("settings.conf");
if !config_file.exists() {
println!("Creating default configuration file");
fs::write(&config_file, "# Default Configuration\nlog_level = info\nmax_items = 100\n")?;
}
// Read and display the configuration
let config_content = fs::read_to_string(&config_file)?;
println!("\nCurrent configuration:");
println!("{}", config_content);
// Create some example data
let example_data_file = data_dir.join("user_data.txt");
fs::write(&example_data_file, "This is some user data that should persist between runs")?;
// Demonstrate copying a directory with progress
let temp_dir = cache_dir.join("temp");
if !temp_dir.exists() {
fs::create_dir_all(&temp_dir)?;
fs::write(temp_dir.join("cache_file.tmp"), "Temporary data")?;
}
let backup_dir = data_dir.join("backup");
println!("\nCreating backup from {} to {}", temp_dir.display(), backup_dir.display());
let mut copy_options = CopyOptions::new();
copy_options.overwrite = true;
let result = copy(&temp_dir, &data_dir, ©_options)?;
println!("Copied {} bytes", result);
// Clean up cache on exit (not config or data)
println!("\nCleaning cache directory");
fs::remove_dir_all(&cache_dir)?;
println!("\nOperation complete");
Ok(())
}
fn get_config_dir(app_name: &str) -> Result<PathBuf, &'static str> {
dirs::config_dir()
.map(|d| d.join(app_name))
.ok_or("Could not determine config directory")
}
fn get_data_dir(app_name: &str) -> Result<PathBuf, &'static str> {
dirs::data_dir()
.map(|d| d.join(app_name))
.ok_or("Could not determine data directory")
}
fn get_cache_dir(app_name: &str) -> Result<PathBuf, &'static str> {
dirs::cache_dir()
.map(|d| d.join(app_name))
.ok_or("Could not determine cache directory")
}
The dirs crate saves me from platform-specific directory structure concerns, while fs_extra provides operations missing from the standard library, like recursive copies with progress reporting.
These ten crates form the foundation of my Rust CLI toolkit. By combining them, I can quickly build powerful command-line applications that provide excellent user experiences. The productivity benefits are substantial, allowing me to focus on the core functionality rather than implementing basic infrastructure. If you’re building command-line tools in Rust, I highly recommend starting with these crates as your foundation.