Rust CLI tools compile to a single static binary with no runtime dependencies: the fastest path from prototype to production-ready tool used by thousands of developers.
Rust has become the language of choice for modern CLI tools: ripgrep, fd, bat, exa, and Zed are all Rust. This guide covers clap for argument parsing, indicatif for progress bars, colored output, and shipping a polished CLI in 2026.
By Rustify Team: Updated March 2026
TL;DR: Rust CLI tools start up in <5ms, produce a single static binary with no runtime dependencies, and run on Linux/macOS/Windows without modification. The Rust CLI ecosystem has matured significantly:
clapfor arguments,indicatiffor progress,colored/owo-colorsfor terminal colors, andserde+toml/jsonfor config.
- clap 4: derive-based argument parsing: attributes on a struct generate the full CLI
- Single binary:
cargo build --release --target x86_64-unknown-linux-musl: drop anywhere- Modern CLI UX: colored output, progress bars, spinner, interactive prompts with
dialoguer- Config files:
confyor manualserde+toml: XDG-compliant config directories- Cross-platform: Rust CLI tools work identically on macOS, Linux, and Windows
Who Should Read This?
This article is for Rust developers who want to build a polished, distributable CLI tool: something that could replace a shell script, a Python utility, or an existing Unix tool. If you have been writing Rust web services and want to explore a different domain, CLI tools are the fastest path to a shippable artifact. The requirements are lower: no database, no auth, no deployment infrastructure. You build it, strip the binary, and ship it. Career-wise, CLI tool development is particularly valued in DevOps and platform engineering roles, where Rust engineers with tooling experience earn $145Kâ$185K at mid-level. Senior engineers who have shipped widely-used CLI tools open source earn credibility that translates directly into interview leverage.
Why Are CLI Tools a Sweet Spot for Rust?
CLI tools benefit from all of Rust's strengths and are exempt from its main tradeoff. You get instant startup, tiny binaries, zero dependencies: and unlike web services, CLI tools don't need to "iterate fast" on UI.
Famous CLI tools written in Rust:
| Tool | Replaces | Performance gain |
|---|---|---|
ripgrep (rg) | grep | 3â10x faster on large codebases |
fd | find | 7x faster with simpler syntax |
bat | cat | Adds syntax highlighting, no speed loss |
eza | ls/exa | Colored, tree view, git status |
delta | diff | Side-by-side, syntax highlighted diffs |
zoxide | cd | Smart directory jumping |
starship | shell prompts | Custom cross-shell prompt, fast |
bottom (btm) | htop/top | System monitor with graphs |
All ship as single static binaries. Installation: download, make executable, done.
The distribution story for Rust CLI tools is dramatically simpler than Python or Node.js: no interpreter installation, no virtual environments, no npm install. A Rust CLI tool compiled for x86_64-unknown-linux-musl is a static binary that runs on any Linux system without any runtime dependencies.
How Do You Set Up a Rust CLI Project?
A Rust CLI project is a standard binary crate: cargo new my-tool. Add clap for argument parsing and you have the foundation of any CLI tool.
[package]
name = "my-tool"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
colored = "2"
indicatif = "0.17"
dialoguer = "0.11"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
anyhow = "1"
[[bin]]
name = "my-tool"
path = "src/main.rs"How Do You Parse Arguments with clap 4?
clap 4's derive API lets you define your CLI as a Rust struct with attributes: it generates argument parsing, help text, shell completions, and validation automatically.
use clap::{Parser, Subcommand, ValueEnum};
/// A powerful file processing tool
#[derive(Parser)]
#[command(name = "my-tool")]
#[command(author = "Alice <[email protected]>")]
#[command(version = "0.1.0")]
#[command(about = "Processes files with style", long_about = None)]
struct Cli {
/// Enable verbose output
#[arg(short, long)]
verbose: bool,
/// Output format
#[arg(short, long, default_value = "text")]
format: OutputFormat,
#[command(subcommand)]
command: Commands,
}
#[derive(ValueEnum, Clone)]
enum OutputFormat {
Text,
Json,
Csv,
}
#[derive(Subcommand)]
enum Commands {
/// Search files for a pattern
Search {
/// Pattern to search for
pattern: String,
/// Files to search (default: stdin)
#[arg(value_name = "FILE")]
files: Vec<std::path::PathBuf>,
/// Maximum results to show
#[arg(short = 'n', long, default_value = "100")]
max_results: usize,
},
/// Process a file
Process {
/// Input file
#[arg(value_name = "INPUT")]
input: std::path::PathBuf,
/// Output file (default: stdout)
#[arg(short, long)]
output: Option<std::path::PathBuf>,
},
}
fn main() {
let cli = Cli::parse();
if cli.verbose {
eprintln!("Verbose mode enabled");
}
match cli.command {
Commands::Search { pattern, files, max_results } => {
search(pattern, files, max_results, cli.format);
}
Commands::Process { input, output } => {
process(input, output, cli.format);
}
}
}
fn search(pattern: String, files: Vec<std::path::PathBuf>, max_results: usize, _fmt: OutputFormat) {
println!("Searching for '{}' in {} files (max {})", pattern, files.len(), max_results);
}
fn process(input: std::path::PathBuf, output: Option<std::path::PathBuf>, _fmt: OutputFormat) {
println!("Processing {:?} â {:?}", input, output);
}Auto-generated help text:
$ my-tool --help
A powerful file processing tool
Usage: my-tool [OPTIONS] <COMMAND>
Commands:
search Search files for a pattern
process Process a file
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Enable verbose output
-f, --format <FORMAT> Output format [default: text] [possible values: text, json, csv]
-h, --help Print help
-V, --version Print versionHow Do You Add Colored Output?
use colored::Colorize;
fn print_status(success: bool, message: &str) {
if success {
println!("{} {}", "â".green().bold(), message);
} else {
eprintln!("{} {}", "â".red().bold(), message);
}
}
fn print_table(headers: &[&str], rows: &[Vec<String>]) {
// Header row
for header in headers {
print!("{:<20}", header.cyan().bold());
}
println!();
// Separator
println!("{}", "â".repeat(headers.len() * 20).dimmed());
// Data rows
for (i, row) in rows.iter().enumerate() {
let style = if i % 2 == 0 { |s: &str| s.normal() } else { |s: &str| s.dimmed() };
for cell in row {
print!("{:<20}", style(cell));
}
println!();
}
}How Do You Show Progress Bars?
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::time::Duration;
fn process_files(files: Vec<String>) {
let multi = MultiProgress::new();
let overall = multi.add(ProgressBar::new(files.len() as u64));
overall.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{elapsed}] [{bar:40.cyan/blue}] {pos}/{len} files"
)
.unwrap()
.progress_chars("=>-"),
);
for file in &files {
let file_bar = multi.add(ProgressBar::new(100));
file_bar.set_style(
ProgressStyle::with_template(" {msg:<30} {bar:20.yellow} {percent}%")
.unwrap(),
);
file_bar.set_message(file.clone());
// Simulate processing
for _ in 0..100 {
std::thread::sleep(Duration::from_millis(5));
file_bar.inc(1);
}
file_bar.finish_and_clear();
overall.inc(1);
}
overall.finish_with_message("Done!");
}
// Spinner for indeterminate tasks
fn long_running_task() {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::with_template("{spinner:.blue} {msg}")
.unwrap()
.tick_strings(&["â ", "â ", "â ı", "â ¸", "â ĵ", "â ´", "â Ĥ", "â §", "â ", "â "]),
);
spinner.set_message("Connecting...");
spinner.enable_steady_tick(Duration::from_millis(80));
std::thread::sleep(Duration::from_secs(2));
spinner.finish_with_message("Connected!");
}How Do You Handle Errors in a CLI Tool?
Use anyhow for application-level CLI error handling: it provides context-rich errors with a single ? operator and automatic Box<dyn Error> compatibility.
use anyhow::{Context, Result};
fn main() -> Result<()> {
let cli = Cli::parse();
run(cli).context("command failed")?;
Ok(())
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
Commands::Search { pattern, files, .. } => {
search_files(&pattern, &files)
.with_context(|| format!("failed to search for '{}'", pattern))?;
}
_ => {}
}
Ok(())
}
fn search_files(pattern: &str, files: &[std::path::PathBuf]) -> Result<Vec<String>> {
let mut results = Vec::new();
for path in files {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read file: {}", path.display()))?;
for (i, line) in content.lines().enumerate() {
if line.contains(pattern) {
results.push(format!("{}:{}: {}", path.display(), i + 1, line));
}
}
}
Ok(results)
}Error output with context:
Error: command failed
Caused by:
0: failed to search for 'hello'
1: failed to read file: /tmp/missing.txt
2: No such file or directory (os error 2)How Do You Handle Configuration Files?
Most CLI tools benefit from a persistent configuration file stored in the user's config directory:
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Default)]
struct Config {
default_format: Option<String>,
max_results: Option<usize>,
color: Option<bool>,
}
fn config_path() -> PathBuf {
let config_dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."));
config_dir.join("my-tool").join("config.toml")
}
fn load_config() -> anyhow::Result<Config> {
let path = config_path();
if !path.exists() {
return Ok(Config::default());
}
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read config: {}", path.display()))?;
toml::from_str(&contents)
.context("failed to parse config file")
}
fn save_config(config: &Config) -> anyhow::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(config)?;
std::fs::write(&path, contents)
.with_context(|| format!("failed to write config: {}", path.display()))
}Use the dirs crate (dirs = "5") to find the correct OS-specific config directory: ~/.config/ on Linux, ~/Library/Application Support/ on macOS, and %APPDATA% on Windows. Hardcoding ~/.config breaks Windows users.
How Do You Ship a Static Binary?
A truly portable Rust binary is compiled for musl on Linux: no dynamic library dependencies:
# Install the musl target (Linux)
rustup target add x86_64-unknown-linux-musl
# Build a fully static binary
cargo build --release --target x86_64-unknown-linux-musl
# The resulting binary has no dynamic dependencies
ldd target/x86_64-unknown-linux-musl/release/my-tool
# â not a dynamic executable
# Strip to minimize size
strip target/x86_64-unknown-linux-musl/release/my-toolFor macOS and Windows, the default release build is already suitable for distribution within the same OS family. Cross-platform distribution typically means building one binary per OS/architecture combination in CI and uploading them to GitHub Releases.
How Do You Add Interactive Prompts and User Input?
The dialoguer crate provides rich terminal UI components for CLI tools that need interactive input:
use dialoguer::{Confirm, Input, Select, MultiSelect, Password, theme::ColorfulTheme};
fn interactive_setup() -> anyhow::Result<()> {
let theme = ColorfulTheme::default();
// Text input
let name: String = Input::with_theme(&theme)
.with_prompt("Project name")
.default("my-project".to_string())
.interact_text()?;
// Yes/no confirmation
let confirmed = Confirm::with_theme(&theme)
.with_prompt("Enable database?")
.default(true)
.interact()?;
// Single selection from list
let framework_options = &["axum", "actix-web", "warp", "poem"];
let framework_idx = Select::with_theme(&theme)
.with_prompt("HTTP framework")
.items(framework_options)
.default(0)
.interact()?;
let framework = framework_options[framework_idx];
// Multi-selection
let feature_options = &["auth", "cors", "rate-limiting", "tracing", "metrics"];
let selected_features = MultiSelect::with_theme(&theme)
.with_prompt("Select features")
.items(feature_options)
.interact()?;
// Password input (masked)
let password: String = Password::with_theme(&theme)
.with_prompt("Database password")
.interact()?;
println!("Creating project: {}", name);
println!("Framework: {}", framework);
println!("Features: {:?}", selected_features);
Ok(())
}Always check atty::is(atty::Stream::Stdin) before launching interactive prompts. If stdin is not a terminal (piped input, CI environment), interactive prompts will block indefinitely. Provide --yes flags and environment variable overrides for automation contexts.
How Do You Write Tests for CLI Tools?
Testing CLI tools requires both unit tests for individual functions and integration tests for the full command:
// src/main.rs: make functions testable
pub fn count_matches(content: &str, pattern: &str) -> usize {
content.lines().filter(|line| line.contains(pattern)).count()
}
// Unit test
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn test_count_matches() {
let content = "hello world\nfoo bar\nhello rust";
assert_eq!(count_matches(content, "hello"), 2);
assert_eq!(count_matches(content, "xyz"), 0);
}
}For integration tests of the full CLI binary, use assert_cmd:
[dev-dependencies]
assert_cmd = "2"
predicates = "3"// tests/integration_tests.rs
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn test_search_command() {
let mut cmd = Command::cargo_bin("my-tool").unwrap();
cmd.args(["search", "hello", "--max-results", "10"])
.write_stdin("hello world\nfoo bar\nhello rust");
cmd.assert()
.success()
.stdout(predicate::str::contains("hello"));
}
#[test]
fn test_missing_subcommand_shows_help() {
let mut cmd = Command::cargo_bin("my-tool").unwrap();
cmd.assert()
.failure()
.stderr(predicate::str::contains("Usage:"));
}assert_cmd runs your actual compiled binary in a subprocess, which catches argument parsing issues, exit codes, and output formatting errors that unit tests cannot detect. Integration tests like these are part of what separates a professional CLI tool from a prototype.
What Common Mistakes Do Rust Developers Make When Building CLI Tools?
-
Mixing stdout and stderr. A well-behaved CLI writes actual output to stdout (so it can be piped) and status/error messages to stderr. Mixing them breaks pipelines:
my-tool | other-commandshould receive only data, not progress messages. Always useprintln!for data output andeprintln!for status and errors. -
Not handling the case where stdout is not a terminal. When your tool's output is piped to a file or another command, colored output writes ANSI escape codes to the file, which is almost never what users want. Check with
colored::control::set_override(is_terminal)or useatty::is(atty::Stream::Stdout)to detect piping and disable color automatically. -
Panicking on missing config files. A first-time user will not have a config file. Always default gracefully when configuration is missing:
Config::default()is the right pattern. Reserve hard errors for malformed config files, not absent ones. -
Not providing machine-readable output. Many CLI tools are composed with other tools or scripts. Adding a
--format jsonoption (backed byserde_json::to_writer(stdout, &result)?) makes your tool scriptable and significantly more useful in automation contexts. -
Not respecting the
NO_COLORenvironment variable. TheNO_COLORconvention (nocolor.is) specifies that if this variable is set, programs should not add color. Thecoloredcrate checksNO_COLORautomatically; if you implement color manually, check it explicitly. -
Shipping a dynamically linked binary as if it were portable. A
glibc-linked Linux binary will fail on distributions with a different glibc version. If you want a truly portable Linux binary, compile againstmuslas described above. Docker images built fromscratchoralpinespecifically require static binaries.
Ready to Build and Ship Polished Rust Tools?
If you want a structured path to building and distributing production-quality Rust CLI tools and services, Rustify's 9-week bootcamp covers the full development lifecycle: from project setup through CI, distribution, and production deployment. Mid-level Rust engineers with CLI tooling experience earn $145Kâ$185K in the US, and a polished open-source CLI tool is one of the strongest portfolio signals in the Rust ecosystem.
Keep Reading
- Rust Developer Salary in the USA (2026)
- How Long to Learn Rust by Background: Hours, Timeline & ROI
- Best Way to Learn Rust in 2026
- Is Rust Hard to Learn?
Frequently Asked Questions
Options in order of user convenience: (1) crates.io: cargo install my-tool (requires Rust); (2) GitHub Releases with pre-built binaries via cargo-dist or a GitHub Actions matrix; (3) Homebrew tap for macOS; (4) AUR for Arch Linux. cargo-dist automates the GitHub Releases workflow with an installer script.
clap 4 can generate completion scripts for bash, zsh, fish, and PowerShell:
// In your CLI tool, add a Completions subcommand
use clap::CommandFactory;
use clap_complete::{generate, Shell};
Commands::Completions { shell } => {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "my-tool", &mut std::io::stdout());
}println! writes to stdout: for actual output that might be piped. eprintln! writes to stderr: for status messages, errors, and verbose output. A well-behaved CLI separates these: $ my-tool | other-command should only pipe the data, not status messages.
Use std::io::stdin() or check if stdin is a pipe with atty::is(atty::Stream::Stdin):
use std::io::{self, BufRead};
fn read_input(file: Option<PathBuf>) -> impl Iterator<Item = String> {
match file {
Some(path) => Box::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()).lines().map(|l| l.unwrap())) as Box<dyn Iterator<Item = String>>,
None => Box::new(io::stdin().lock().lines().map(|l| l.unwrap())),
}
}Use ctrlc = "3" crate to register a signal handler that sets an AtomicBool. Check the flag periodically in your processing loop and clean up before exiting. For async CLIs using tokio, use tokio::signal::ctrl_c() as an async signal future.
The dialoguer crate provides Confirm, Select, MultiSelect, and Input prompt types. Always check whether stdin is a terminal before showing interactive prompts: if stdin is a pipe, prompts will block indefinitely waiting for user input that will never come.
cargo-dist is a tool from Axo that automates the GitHub Releases workflow: it generates CI workflows that build for multiple targets, creates release archives, generates an install script (curl | sh style), and optionally publishes to Homebrew. For CLI tools you want to distribute widely, it is the fastest path to a professional distribution setup. Run cargo dist init to get started.
