Introduction
Building a CLI tool in Rust: what developers should know starts with understanding that Rust gives you speed, safety, and the ability to ship a single binary to any platform—no runtime dependencies, no installation headaches. We've spent years deploying Node.js tools that need npm, Python scripts that break across versions, and Go binaries that still feel heavyweight compared to what Rust delivers. When you need to build something fast, reliable, and truly portable, Rust's CLI ecosystem has matured enough that you can be productive on day one.
Over the course of a typical project, you'll handle argument parsing, error reporting, formatted output, and packaging. Each of these tasks has a clear best practice in the Rust community, and we'll walk through them with real code you can adapt immediately.
Argument parsing with clap and derive macros
Argument parsing is the foundation of any CLI. The clap crate has become the standard, and its derive macro API lets you define your arguments as a struct—no builder boilerplate, no manual validation logic.
Here's a real example: imagine you're building a log aggregation tool that reads from a local file or a remote endpoint, optionally filters by level, and outputs JSON or plain text.
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "logcat")]
#[command(about = "Fast log processor", long_about = None)]
struct Args {
/// Path to log file or remote endpoint
#[arg(value_name = "SOURCE")]
source: String,
/// Output format (json or text)
#[arg(short, long, default_value = "text")]
format: String,
/// Filter by log level (debug, info, warn, error)
#[arg(short, long)]
level: Option,
/// Enable verbose output
#[arg(short, long)]
verbose: bool,
/// Number of lines to tail (-1 for all)
#[arg(short, long, default_value = "100")]
lines: i32,
}
fn main() {
let args = Args::parse();
println!("Source: {}", args.source);
println!("Format: {}", args.format);
if let Some(level) = &args.level {
println!("Filter level: {}", level);
}
println!("Verbose: {}", args.verbose);
println!("Lines to tail: {}", args.lines);
}
What makes this powerful: clap automatically generates help text, validates argument types, handles short and long flags, and provides helpful error messages if the user passes invalid input. Run logcat --help and you get a formatted help screen with descriptions from your doc comments—no extra work needed.
For file paths, use PathBuf instead of String. Clap will parse the argument into a proper path and your code avoids string manipulation errors. If you need custom validation—say, the file must exist or be readable—implement clap::builder::TypedValueParser for a custom type and clap calls it during parsing.
Error handling with anyhow and context
A CLI tool lives or dies by its error messages. Users run it once, see a cryptic error, and move on. The anyhow crate gives you a straightforward way to attach context at each layer so the user sees exactly what went wrong and where.
Instead of naked Result<T, String> or generic Box<dyn Error>, use anyhow::Result<T> and the context() method to describe what the code was trying to do when it failed:
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
fn read_config(path: &Path) -> Result {
fs::read_to_string(path)
.context(format!(
"Failed to read config file at {}",
path.display()
))
}
fn parse_config(content: &str) -> Result {
serde_json::from_str(content)
.context("Config file is not valid JSON")
}
fn main() -> Result<()> {
let config_content = read_config(Path::new("config.json"))?;
let config = parse_config(&config_content)?;
println!("Config: {:?}", config);
Ok(())
}
When a file doesn't exist, the user sees "Failed to read config file at config.json: No such file or directory" instead of just "No such file or directory." When JSON parsing fails, they see "Config file is not valid JSON: expected `,` at line 5" with a line number. That context is the difference between a frustrating tool and one people actually use.
At the top of main(), convert your Result to a human-readable error message using a simple error handler:
fn main() {
if let Err(e) = run() {
eprintln!("Error: {:?}", e);
std::process::exit(1);
}
}
fn run() -> anyhow::Result<()> {
// your actual CLI code
Ok(())
}
The anyhow::Chain display format shows the full chain of context, making debugging straightforward.
Output: colors and progress feedback
Plain text output is fine for pipes and logs, but interactive use benefits from color and structured formatting. The colored crate makes colored output trivial, and indicatif provides progress bars and spinners when your tool processes large files or makes network calls.
Here's a realistic example: processing a batch of records and reporting progress:
use colored::Colorize;
use indicatif::ProgressBar;
use std::time::Duration;
fn process_records(records: Vec<&str>) -> anyhow::Result<()> {
let bar = ProgressBar::new(records.len() as u64);
bar.set_style(
indicatif::ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")?
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
);
bar.set_draw_delta(10);
for record in records {
// Simulate processing
std::thread::sleep(Duration::from_millis(50));
if record.contains("error") {
println!("{}", format!(" [SKIP] {}", record).red());
} else {
println!("{}", format!(" [OK] {}", record).green());
}
bar.inc(1);
}
bar.finish_with_message("Done!");
Ok(())
}
fn main() -> anyhow::Result<()> {
println!("{}", "Starting batch processor".bold());
process_records(vec!["record1", "record2 error", "record3"])?;
println!("{}", "All done!".green().bold());
Ok(())
}
For JSON output, use serde_json to serialize structured data—it's invaluable when your CLI output feeds into other tools or scripts. You can even add a --json flag to switch between human-readable and machine-parseable formats:
if args.json {
println!("{}", serde_json::to_string_pretty(&results)?);
} else {
for item in results {
println!("{}: {}", item.name.bold(), item.status);
}
}
Distribution: single binaries across platforms
The magic of Rust CLI tools is that you compile once per platform and ship a single binary with zero dependencies. This is radically different from Node.js, Python, or even Go in some scenarios.
Your build pipeline looks like this:
- Compile in release mode:
cargo build --release - The binary lands in
target/release/your-tool(or.exeon Windows) - Users download and run it—no npm install, no Python version conflicts, no runtime needed
For cross-platform builds, use cross to compile for Linux, macOS, and Windows from your development machine:
cargo install cross
# Build for Linux (from any OS)
cross build --release --target x86_64-unknown-linux-gnu
# Build for macOS
cross build --release --target x86_64-apple-darwin
# Build for Windows
cross build --release --target x86_64-pc-windows-gnu
Release binaries are typically 5–20 MB depending on features. You can strip debug symbols further if size matters:
strip target/release/your-tool
Consider distributing via GitHub Releases: tag your repo, attach the compiled binaries for each platform, and users grab the right binary for their OS. Tools like cargo-dist and release-plz automate this workflow. Many teams also publish to package managers—Homebrew on macOS, apt on Debian, chocolatey on Windows—but the single binary approach is often enough and simplest to maintain.
Common mistakes
- Panicking on user input: If a user passes invalid data, your tool should print a clear error and exit gracefully, not panic with a stack trace. Use
anyhow::Resultthroughout and handle errors at the top level. - Ignoring stdout vs. stderr: Use
println!for normal output andeprintln!for errors and diagnostics. This lets users pipe the tool's output without mixing errors into data streams. - Forgetting to set proper exit codes: Use
std::process::exit(1)on error andexit(0)on success. Scripts and CI systems depend on these codes to know whether your tool succeeded. - Hardcoding file paths: Always accept file paths as arguments or environment variables, never assume working directory. Use
std::env::varfor config paths if you need defaults. - Skipping help text: Doc comments on your
Args` fields become your help text. Invest in clear descriptions—they're part of your user experience.
Key takeaways
- Use
clap's derive macro to define CLI arguments as a struct—it handles parsing, validation, and help text automatically. - Wrap your logic in
anyhow::Resultand use.context()to attach human-readable messages at each layer so users know what went wrong. - Add color with
coloredand progress bars withindicatifto make interactive use pleasant; serialize structured output to JSON when appropriate. - Compile in release mode and distribute single binaries per platform—Rust's lack of runtime dependencies is one of its biggest advantages for CLI tools.
- Test your error paths: invalid arguments, missing files, network timeouts. A good CLI tells the user exactly how to fix the problem.






Comments
Loading comments…