Building CLI Tools in Rust: Complete Guide 2026

Max WellsMax WellsFounder of Rustify

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: clap for arguments, indicatif for progress, colored/owo-colors for terminal colors, and serde+toml/json for 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: confy or manual serde+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:

ToolReplacesPerformance gain
ripgrep (rg)grep3–10x faster on large codebases
fdfind7x faster with simpler syntax
batcatAdds syntax highlighting, no speed loss
ezals/exaColored, tree view, git status
deltadiffSide-by-side, syntax highlighted diffs
zoxidecdSmart directory jumping
starshipshell promptsCustom cross-shell prompt, fast
bottom (btm)htop/topSystem 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 version

How 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!("{:&lt;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!("{:&lt;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:&lt;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-tool

For 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-command should receive only data, not progress messages. Always use println! for data output and eprintln! 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 use atty::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 json option (backed by serde_json::to_writer(stdout, &result)?) makes your tool scriptable and significantly more useful in automation contexts.

  • Not respecting the NO_COLOR environment variable. The NO_COLOR convention (nocolor.is) specifies that if this variable is set, programs should not add color. The colored crate checks NO_COLOR automatically; 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 against musl as described above. Docker images built from scratch or alpine specifically 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

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.


Sources

Ready to Land a $120k+ Rust Job in the US or Europe?