When I first started building command-line tools in Rust, I felt overwhelmed. The terminal is a powerful environment, but making an application that feels intuitive and robust involves many moving parts. How do you parse what the user types? How do you show a progress bar? How do you ask for a password without showing it on screen?
Over time, I discovered that Rust’s ecosystem has incredible libraries that turn these complex problems into simple tasks. These crates let you focus on what your tool does, not on the tedious details of how it interacts with the system. I want to share eight of them that have become essential in my own work.
Let’s start with how your tool understands what the user wants to do.
Getting user input right is the first step. A tool needs to understand commands, options, and flags. For this, I almost always reach for clap. It feels like the backbone of a good CLI application. You can define your interface in a way that’s clear and declarative, and clap handles the rest: parsing, validation, and even generating beautiful help text automatically.
Here’s a basic example. Imagine you’re building a simple file viewer. You might want a required input file and an optional flag for verbose output.
use clap::{Arg, Command};
fn main() {
let matches = Command::new("viewer")
.version("1.0")
.author("Me")
.about("Views files")
.arg(
Arg::new("file")
.help("The file to view")
.required(true)
.index(1), // This means it's a positional argument, not a flag
)
.arg(
Arg::new("verbose")
.short('v')
.long("verbose")
.help("Prints additional details")
.action(clap::ArgAction::SetTrue), // This flag doesn't take a value, it's just present or not
)
.get_matches();
let file_path = matches.get_one::<String>("file").unwrap();
let is_verbose = matches.get_flag("verbose");
println!("Viewing file: {}", file_path);
if is_verbose {
println!("Verbose mode is enabled.");
}
}
When you run viewer --help, clap will print a neatly formatted help screen based on this code. It makes your tool feel professional from day one. It also supports subcommands, which is perfect for tools like git that have git commit, git push, and so on.
Once your tool is doing some work, especially if it’s slow, you don’t want the user staring at a blank screen. They might think it’s frozen. This is where indicatif comes in. It adds life to your terminal with progress bars and spinners. It’s surprisingly satisfying to see a visual cue that things are moving along.
Let’s say your tool needs to process a list of items. A progress bar gives immediate feedback.
use indicatif::{ProgressBar, ProgressStyle};
use std::thread;
use std::time::Duration;
fn process_data(items: Vec<String>) {
println!("Starting to process {} items...", items.len());
// Create a progress bar with the total number of steps
let pb = ProgressBar::new(items.len() as u64);
// You can customize how it looks
pb.set_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}")
.unwrap()
.progress_chars("##-"),
);
for item in items {
// Simulate some time-consuming work on each item
thread::sleep(Duration::from_millis(50));
// Update the message on the bar
pb.set_message(format!("Processing '{}'", item));
// Move the bar forward by one step
pb.inc(1);
}
pb.finish_with_message("All items processed!");
}
The bar updates smoothly in place. You can also use spinners for indeterminate tasks, like waiting for a network request. It’s a small detail that makes a huge difference in user experience.
Now, let’s talk about making your output look good. Plain white text can be hard to read. You might want success messages in green, errors in red, or to highlight important information. But terminals work differently on Windows, macOS, and Linux. Writing code for each one is a nightmare.
crossterm solves this. It gives you a single, unified way to control the terminal, no matter where your code is running. Want to color some text? It’s straightforward.
use crossterm::{
execute,
style::{Color, Print, ResetColor, SetForegroundColor},
};
use std::io::{stdout, Write};
fn main() -> std::io::Result<()> {
let mut stdout = stdout();
// Print a green success message
execute!(
stdout,
SetForegroundColor(Color::Green),
Print("✔ Success!\n"),
ResetColor
)?;
// Print a red error message
execute!(
stdout,
SetForegroundColor(Color::Red),
Print("✘ Error: File not found.\n"),
ResetColor
)?;
// Print a yellow warning
execute!(
stdout,
SetForegroundColor(Color::Yellow),
Print("⚠ Warning: This action cannot be undone.\n"),
ResetColor
)?;
Ok(())
}
Beyond colors, crossterm lets you move the cursor, clear lines or the entire screen, and read keypresses in real time. This is how you build interactive terminal applications, like text editors or dashboard tools.
Often, users will give your tool a path. They might use shortcuts like ~ to mean their home directory, or reference environment variables like $HOME or %USERPROFILE%. Your tool needs to understand these shortcuts. Manually writing code to handle ~ on Unix and Windows is error-prone.
The shellexpand crate does this heavy lifting for you. It expands these shortcuts just like a shell would.
use shellexpand;
fn main() {
// Expand a home directory tilde
let home_path = "~/Documents/my_project";
let expanded_home = shellexpand::full(home_path).unwrap();
println!("The full path is: {}", expanded_home); // Prints: /home/username/Documents/my_project
// Expand an environment variable
let var_path = "$HOME/.config";
let expanded_var = shellexpand::full(var_path).unwrap();
println!("The config path is: {}", expanded_var);
// It even works with braces, which are common in shells
let braced_path = "${HOME}/logs";
let expanded_braced = shellexpand::full(braced_path).unwrap();
println!("The logs path is: {}", expanded_braced);
}
This makes your tool much more user-friendly. A user can type ~/file.txt and your tool will know exactly which file they mean, regardless of their username or operating system.
If you’re building a cleanup tool or a disk space analyzer, you need to know how big files and folders are. Walking through a directory tree and adding up file sizes sounds simple, but you have to think about symbolic links, permissions, and hidden files.
dirge is a library dedicated to this. It gives you a reliable way to calculate the size of a directory.
use dirge::size::{get_size, SizeOptions};
use std::path::Path;
fn analyze_storage(path: &str) {
let target_path = Path::new(path);
// Set options for the size calculation
let options = SizeOptions {
follow_links: false, // Do not follow symbolic links
require_access: true, // Respect file permissions
..SizeOptions::default()
};
match get_size(target_path, &options) {
Ok(size_in_bytes) => {
// Convert bytes to a human-readable format
let size_kb = size_in_bytes / 1024;
let size_mb = size_kb / 1024;
println!("Total size of '{}':", path);
println!(" Bytes: {}", size_in_bytes);
println!(" KB: {}", size_kb);
if size_mb > 0 {
println!(" MB: {}", size_mb);
}
}
Err(e) => eprintln!("Could not calculate size: {}", e),
}
}
It handles the recursion and edge cases, so you don’t have to. You just get a number you can trust.
Sometimes, you need to ask the user a question. A simple “yes or no,” a choice from a list, or a sensitive input like a password. Building these prompts from scratch involves careful handling of input and output.
dialoguer provides a delightful set of interactive prompts. It feels like a conversation with your tool.
use dialoguer::{Confirm, Input, Password, Select};
use std::io;
fn main() -> io::Result<()> {
// Ask a yes/no question
let should_run = Confirm::new()
.with_prompt("Do you want to run the database migration?")
.default(false) // Defaults to 'No'
.interact()?;
if !should_run {
println!("Migration cancelled.");
return Ok(());
}
// Ask for a simple text input
let username: String = Input::new()
.with_prompt("Please enter your database username")
.interact_text()?;
// Ask for a password (input is hidden)
let password = Password::new()
.with_prompt("Enter your database password")
.with_confirmation("Confirm password", "Passwords do not match")
.interact()?;
// Let the user choose from a list
let environments = &["Development", "Staging", "Production"];
let selection = Select::new()
.with_prompt("Select the target environment")
.items(&environments[..])
.default(0) // Highlights 'Development' first
.interact()?;
println!("\nSummary:");
println!(" Username: {}", username);
println!(" Password: [hidden]");
println!(" Environment: {}", environments[selection]);
println!(" Proceed with migration: {}", should_run);
Ok(())
}
The prompts handle validation, default values, and clear presentation. It turns a potentially clunky interaction into a smooth guided process.
Most real applications need configuration. Settings might come from a default value, a configuration file, environment variables, and finally, command-line arguments. Managing this hierarchy is a common source of bugs.
config-rs (often just called config) is a brilliant library for this. It lets you build your configuration from multiple layers, where later sources override earlier ones.
use config::{Config, File, Environment};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Settings {
host: String,
port: u16,
debug_mode: bool,
database_url: Option<String>, // This field might not be in all sources
}
fn load_settings() -> Result<Settings, config::ConfigError> {
let settings_builder = Config::builder()
// Start with default values (you could hardcode some here)
// .set_default("host", "localhost")? // Example of setting a default
// Layer 1: A config file (e.g., `config.yaml` or `config.toml`)
.add_source(File::with_name("config").required(false)) // It's okay if this file doesn't exist
// Layer 2: Environment variables with a prefix
// APP_DEBUG_MODE=true will set the `debug_mode` field
.add_source(Environment::with_prefix("APP").separator("_"))
// In a real app, you could add command-line arguments as a final layer here
;
let config = settings_builder.build()?;
// Deserialize the whole configuration into our Settings struct
config.try_deserialize()
}
fn main() {
match load_settings() {
Ok(settings) => {
println!("Configuration loaded:");
println!(" Host: {}", settings.host);
println!(" Port: {}", settings.port);
println!(" Debug Mode: {}", settings.debug_mode);
if let Some(db_url) = settings.database_url {
println!(" Database URL: {}", db_url);
}
}
Err(e) => eprintln!("Failed to load config: {}", e),
}
}
You could have a config.toml file with default settings for all developers. Then, in production, you override specific values using APP_HOST and APP_PORT environment variables. It keeps your configuration clean and flexible.
Finally, what if your tool needs to display documentation, a changelog, or formatted notes? Markdown is the standard for this kind of text. Rendering it as plain text in a terminal, with bold, italics, and code blocks, can be tricky.
comrak is a fast Markdown parser. You can use it to convert Markdown to HTML for a manual page, or you can use its output to format text for the terminal by handling the tags yourself.
use comrak::{markdown_to_html, Options};
// For a more terminal-focused approach, we might parse to an AST and format it ourselves.
// Here's a simple example using HTML as an intermediary for demonstration.
fn display_help() {
let markdown_help = r#"
# My Awesome Tool
Version 1.2.3
## Usage
`mytool [OPTIONS] <INPUT>`
### Options
* `-v, --verbose` - Enable **verbose** output.
* `-h, --help` - Print this help message.
* `--config <FILE>` - Use a custom config file.
### Example
```bash
mytool --verbose ~/data.txt
”#;
// Convert to HTML (you could write this to a file for a web-based help)
let html_output = markdown_to_html(markdown_help, &Options::default());
println!("HTML version (for a manual page):\n{}", html_output);
// For terminal display, you might write a simple function to convert
// common markdown to ANSI colors (e.g., **text** to bold green).
// `comrak` gives you a parsed syntax tree you can walk for this purpose.
}
// A very basic terminal-focused formatter (simplified)
fn markdown_to_terminal(md: &str) -> String {
// This is a naive example. In reality, you’d use comrak’s AST.
let mut result = md.to_string();
// Replace bold markers with ANSI codes (very simplistic)
result = result.replace("", “\x1b[1m”); // Start bold
result = result.replace("", “\x1b[0m”); // End bold (this is flawed but illustrative)
result = result.replace(“", "\x1b[36m"); // Start cyan for code result = result.replace("”, “\x1b[0m”); // End cyan
result
}
While `comrak` outputs HTML by default, its real power is providing a structured representation of the document. You can walk through this structure and generate perfectly formatted plain text with the right terminal colors and indentation for lists and code blocks.
Each of these libraries tackles a specific, common challenge. By combining them, you stop fighting the terminal and start building the unique logic of your application. You get argument parsing, user feedback, colorful output, path handling, file inspection, interactive prompts, config management, and documentation rendering.
The result is a tool that feels solid, responsive, and helpful. Rust gives you the performance and safety, and these libraries provide the polished experience that users appreciate. My own tools went from rough prototypes to professional utilities once I integrated these crates. They handle the complexity so you can focus on creating something useful.