MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Building CLI Tools with Rust: clap, dialoguer, and indicatif

Create professional CLIs in Rust: argument parsing, interactive prompts, and progress bars.

RustCLICommand LineTools

By MinhVo

Introduction

Rust has emerged as the language of choice for building high-performance command-line tools. Tools like ripgrep, fd, bat, delta, starship, and zoxide have demonstrated that Rust CLIs can be dramatically faster than their equivalents in Python, Node.js, or even Go, while providing excellent cross-platform support and a single static binary that users can install without any runtime dependencies.

The Rust ecosystem for CLI development has matured significantly. The clap crate provides a derive-macro-based argument parser that rivals Python's Click in developer ergonomics while generating code that compiles to zero-overhead abstractions. The dialoguer crate offers interactive prompts with a clean API. The indicatif crate provides progress bars and spinners that look great in any terminal. Together, these three crates form the foundation for building professional CLI tools in Rust.

In this comprehensive guide, we will build a complete CLI tool from scratch. We will cover project setup with Cargo, argument parsing with clap's derive API, subcommand patterns, interactive prompts, progress bars, colorful output, error handling with anyhow and thiserror, configuration file support, and strategies for distributing your tool as a single binary. By the end, you will have a production-ready CLI that compiles in milliseconds and runs in microseconds.

Rust programming language logo and terminal

Understanding Rust CLI Tools: Core Concepts

Why Rust for CLIs

Rust CLI tools have several compelling advantages over alternatives. Performance is the most obvious: Rust compiles to native code with no garbage collector, no runtime startup cost, and excellent optimization. A Rust CLI tool typically starts in 1-5ms compared to 100-200ms for Node.js or 50-100ms for Python. For tools that run hundreds of times per day (like git status or ls), this difference is perceptible.

Safety is equally important. Rust's ownership model prevents entire categories of bugs at compile time: no null pointer dereferences, no data races, no use-after-free. For CLI tools that interact with the filesystem, network, and user input, this compile-time safety catches bugs that would otherwise surface as mysterious crashes in production.

Distribution is the final advantage. Rust compiles to a single static binary with no runtime dependencies. Users do not need to install Node.js, Python, or any other runtime. They download the binary, place it in their PATH, and it works. Cross-compilation targets every major platform from a single build machine.

The Rust CLI Ecosystem

The Rust ecosystem for CLI development is rich and well-maintained. clap (Command Line Argument Parser) is the dominant argument parsing library with over 300 million downloads. dialoguer provides interactive prompts. indicatif handles progress bars and spinners. colored and owo-colors provide terminal colors. anyhow and thiserror handle error propagation. serde and toml/serde_yaml handle configuration files. tracing provides structured logging.

Derive Macros and Zero-Cost Abstractions

Rust's derive macros are central to the modern CLI development experience. Instead of manually defining argument parsers (as you would in C or Go), you define a struct with attributes, and a derive macro generates the parsing code at compile time. This is both more ergonomic and more performant than runtime reflection-based approaches used in other languages.

Architecture and Design Patterns

Subcommand Architecture

Most CLI tools follow a subcommand pattern: the tool name is followed by a verb that determines the action. mytool init, mytool build, mytool deploy. In Rust with clap, each subcommand is a separate enum variant with its own arguments:

use clap::{Parser, Subcommand};
 
#[derive(Parser)]
#[command(name = "mytool")]
#[command(about = "A professional CLI tool")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}
 
#[derive(Subcommand)]
enum Commands {
    /// Initialize a new project
    Init {
        /// Project name
        name: String,
        /// Template to use
        #[arg(short, long, default_value = "default")]
        template: String,
    },
    /// Build the current project
    Build {
        /// Watch for changes
        #[arg(short, long)]
        watch: bool,
    },
    /// Deploy to production
    Deploy {
        /// Target environment
        #[arg(short, long, default_value = "staging")]
        env: String,
        /// Dry run without actually deploying
        #[arg(long)]
        dry_run: bool,
    },
}

Error Handling Strategy

Rust CLI tools benefit from a layered error handling approach. Use thiserror to define domain-specific error types for your tool's operations. Use anyhow at the top level to provide context and a user-friendly error display. The Result type propagates errors up the call stack without exceptions, and the ? operator makes this ergonomic:

use thiserror::Error;
 
#[derive(Error, Debug)]
enum AppError {
    #[error("Project '{0}' already exists")]
    ProjectExists(String),
    #[error("Template '{0}' not found")]
    TemplateNotFound(String),
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Git error: {0}")]
    Git(#[from] git2::Error),
}

Configuration Layering

Support configuration from multiple sources with clear precedence. Clap handles command-line arguments. Environment variables can be read with std::env. Configuration files can be loaded with serde and toml or serde_yaml. Defaults are specified in the struct definitions with #[arg(default_value = ...)].

Step-by-Step Implementation

Project Setup

Create a new Cargo project and add the essential dependencies:

cargo new my-cli-tool
cd my-cli-tool

Add dependencies to Cargo.toml:

[package]
name = "my-cli-tool"
version = "0.1.0"
edition = "2021"
 
[dependencies]
clap = { version = "4", features = ["derive"] }
dialoguer = "0.11"
indicatif = "0.17"
colored = "2"
anyhow = "1"
thiserror = "1"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
dirs = "5"
console = "0.15"

Argument Parsing with clap Derive API

Define your CLI structure using derive macros. The #[command] and #[arg] attributes provide metadata that clap uses to generate help text, validate inputs, and parse arguments:

use clap::{Parser, Subcommand, ValueHint};
use std::path::PathBuf;
 
#[derive(Parser)]
#[command(name = "mytool")]
#[command(version = "0.1.0")]
#[command(about = "A professional project scaffolding tool")]
#[command(long_about = "Create, build, and deploy projects with ease.\n\n\
    Supports multiple templates and interactive configuration.")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
 
    /// Enable verbose output
    #[arg(short, long, global = true)]
    verbose: bool,
 
    /// Configuration file path
    #[arg(short, long, global = true, value_hint = ValueHint::FilePath)]
    config: Option<PathBuf>,
}
 
#[derive(Subcommand)]
enum Commands {
    /// Initialize a new project
    Init {
        /// Project name (used as directory name)
        #[arg(value_hint = ValueHint::Other)]
        name: String,
 
        /// Template to use
        #[arg(short, long)]
        template: Option<String>,
 
        /// Skip interactive prompts and use defaults
        #[arg(long)]
        defaults: bool,
 
        /// Do not initialize git repository
        #[arg(long)]
        no_git: bool,
    },
 
    /// Build the project
    Build {
        /// Watch for file changes and rebuild
        #[arg(short, long)]
        watch: bool,
 
        /// Build for release (optimized)
        #[arg(short, long)]
        release: bool,
 
        /// Output directory
        #[arg(short, long, default_value = "dist")]
        output: PathBuf,
    },
 
    /// Deploy to a target environment
    Deploy {
        /// Target environment
        #[arg(short, long)]
        env: String,
 
        /// Perform a dry run without deploying
        #[arg(long)]
        dry_run: bool,
 
        /// Force deploy even with warnings
        #[arg(long)]
        force: bool,
    },
 
    /// List available templates
    List {
        /// Show detailed information
        #[arg(short, long)]
        detailed: bool,
 
        /// Filter by category
        #[arg(short, long)]
        category: Option<String>,
    },
}
 
fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
 
    match cli.command {
        Commands::Init { name, template, defaults, no_git } => {
            cmd_init(&name, template, defaults, no_git, cli.verbose)
        }
        Commands::Build { watch, release, output } => {
            cmd_build(watch, release, &output, cli.verbose)
        }
        Commands::Deploy { env, dry_run, force } => {
            cmd_deploy(&env, dry_run, force, cli.verbose)
        }
        Commands::List { detailed, category } => {
            cmd_list(detailed, category.as_deref(), cli.verbose)
        }
    }
}

Interactive Prompts with dialoguer

Guide users through complex configuration with interactive prompts:

use dialoguer::{Input, Select, Confirm, MultiSelect, theme::ColorfulTheme};
 
struct ProjectConfig {
    name: String,
    template: String,
    features: Vec<String>,
    git_init: bool,
    package_manager: String,
}
 
fn interactive_init(name: &str) -> anyhow::Result<ProjectConfig> {
    let theme = ColorfulTheme::default();
 
    let template = Select::with_theme(&theme)
        .with_prompt("Select a project template")
        .items(&[
            "Rust CLI Tool",
            "Web API (Axum)",
            "Full-Stack (Leptos)",
            "Library",
        ])
        .default(0)
        .interact()?;
 
    let templates = ["rust-cli", "web-api", "fullstack", "library"];
 
    let features = MultiSelect::with_theme(&theme)
        .with_prompt("Select features")
        .items(&[
            "CI/CD pipeline (GitHub Actions)",
            "Docker configuration",
            "Documentation (mdbook)",
            "Benchmarks",
            "Logging (tracing)",
        ])
        .defaults(&[true, false, false, false, true])
        .interact()?;
 
    let feature_names = ["cicd", "docker", "docs", "benchmarks", "logging"];
    let selected_features: Vec<String> = features
        .iter()
        .map(|&i| feature_names[i].to_string())
        .collect();
 
    let package_manager = Select::with_theme(&theme)
        .with_prompt("Package manager")
        .items(&["cargo", "cargo + cargo-edit", "cargo + cargo-make"])
        .default(0)
        .interact()?;
 
    let git_init = Confirm::with_theme(&theme)
        .with_prompt("Initialize git repository?")
        .default(true)
        .interact()?;
 
    Ok(ProjectConfig {
        name: name.to_string(),
        template: templates[template].to_string(),
        features: selected_features,
        git_init,
        package_manager: ["cargo", "cargo-edit", "cargo-make"][package_manager].to_string(),
    })
}

Progress Bars with indicatif

Show progress for long-running operations:

use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
use std::time::Duration;
 
fn scaffold_project(config: &ProjectConfig) -> anyhow::Result<()> {
    let multi = MultiProgress::new();
 
    let spinner = multi.add(ProgressBar::new_spinner());
    spinner.set_style(
        ProgressStyle::with_template("{spinner:.green} {msg}")?
            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
    );
    spinner.set_message("Creating project structure...");
    spinner.enable_steady_tick(Duration::from_millis(100));
 
    // Simulate work
    std::fs::create_dir_all(&config.name)?;
    std::thread::sleep(Duration::from_millis(500));
    spinner.finish_with_message("✓ Project structure created");
 
    let pb = multi.add(ProgressBar::new(config.features.len() as u64));
    pb.set_style(
        ProgressStyle::with_template(
            "{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}"
        )?
        .progress_chars("█▓░"),
    );
 
    for feature in &config.features {
        pb.set_message(format!("Configuring {}...", feature));
        std::thread::sleep(Duration::from_millis(300));
        pb.inc(1);
    }
    pb.finish_with_message("All features configured");
 
    let spinner = multi.add(ProgressBar::new_spinner());
    spinner.set_message("Installing dependencies...");
    spinner.enable_steady_tick(Duration::from_millis(100));
    std::thread::sleep(Duration::from_millis(1000));
    spinner.finish_with_message("✓ Dependencies installed");
 
    if config.git_init {
        let spinner = multi.add(ProgressBar::new_spinner());
        spinner.set_message("Initializing git repository...");
        spinner.enable_steady_tick(Duration::from_millis(100));
        std::process::Command::new("git")
            .args(["init", &config.name])
            .output()?;
        spinner.finish_with_message("✓ Git repository initialized");
    }
 
    Ok(())
}

Terminal progress bars and spinners

Real-World Use Cases

File Processing Tools

Rust excels at building fast file processing CLIs. Tools like ripgrep (grep replacement), fd (find replacement), and bat (cat replacement) process millions of files in seconds. The pattern is simple: parse arguments, walk the filesystem using walkdir or ignore (which respects .gitignore), process each file in parallel using rayon, and output results. Rust's zero-cost abstractions mean the iteration and filtering code compiles to the same machine code as a hand-written C loop.

Database CLIs

Database management tools benefit from Rust's safety guarantees. When your CLI tool runs destructive operations like DROP TABLE or TRUNCATE, compile-time type safety and explicit error handling prevent bugs that could cause data loss. The sqlx crate provides compile-time checked SQL queries, catching syntax errors and type mismatches during compilation rather than at runtime in production.

Infrastructure Tools

Infrastructure tools like Terraform (written in Go, but Rust alternatives exist), Pulumi, and custom deployment scripts benefit from Rust's performance and single-binary distribution. When a deployment pipeline runs your CLI tool hundreds of times per day across thousands of servers, the startup time savings of Rust over interpreted languages add up significantly.

Code Generation Tools

Code generators like cargo-generate, create-tauri-app, and custom scaffolding tools use template engines like tera or handlebars to generate project skeletons from templates. Rust's string handling and filesystem operations are fast enough that scaffolding a complete project takes milliseconds rather than seconds.

Best Practices for Production

  1. Use anyhow::Result for application errors: The anyhow crate provides rich error context and a clean ? operator experience. Use anyhow::Context to add descriptive messages to errors as they propagate up the call stack.

  2. Use thiserror for library errors: If your CLI contains reusable library code, define proper error types with thiserror that callers can match on. Reserve anyhow for the application layer.

  3. Provide shell completions: Use clap's built-in shell completion generation for bash, zsh, fish, and PowerShell. Add a completions subcommand that generates the completion script. This dramatically improves the user experience.

  4. Respect NO_COLOR and TERM: The colored and console crates respect the NO_COLOR environment variable automatically. Never force colors when the terminal does not support them or when output is piped.

  5. Use tracing for structured logging: The tracing crate provides structured, leveled logging that integrates with the Rust async ecosystem. Use tracing-subscriber with a fmt layer for human-readable output and a json layer for machine consumption in CI/CD.

  6. Test with assert_cmd and predicates: The assert_cmd crate provides integration testing for CLI tools. It spawns your binary, captures output, and asserts on exit codes, stdout, and stderr using composable predicates.

  7. Optimize binary size: Use [profile.release] settings in Cargo.toml to optimize for size. Set strip = true to remove debug symbols, lto = true for link-time optimization, and opt-level = "z" for size optimization. This can reduce binary size by 50-80%.

  8. Provide a --quiet flag: Not every invocation needs colorful output. Support --quiet to suppress non-essential output, making your tool composable with other commands in scripts and pipelines.

Common Pitfalls and Solutions

PitfallImpactSolution
Forgetting --release flagBinary is 10-100x slower than it should beAlways build with cargo build --release for distribution; debug builds are only for development
Not handling UTF-8 properlyPanics on non-ASCII filenames or inputUse OsString for file paths and String::from_utf8_lossy() for user input
Blocking the async runtimeTool hangs when mixing sync and async codeUse tokio::task::spawn_blocking for filesystem and CPU-intensive operations in async contexts
Large binary sizeUsers complain about download sizeEnable LTO, strip symbols, and consider --codegen-opt-level=z for size optimization
Not respecting .gitignoreProcessing generated files, node_modules, etc.Use the ignore crate which automatically respects .gitignore patterns
Panicking on unwrapUnhelpful error messages in productionReplace all .unwrap() with .context("description")? using anyhow

Performance Optimization

Parallel Processing with Rayon

Rust's rayon crate makes parallel processing trivial. Convert any iterator to a parallel iterator by changing .iter() to .par_iter():

use rayon::prelude::*;
use walkdir::WalkDir;
 
fn process_files(root: &Path) -> anyhow::Result<Vec<Result>> {
    let files: Vec<PathBuf> = WalkDir::new(root)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
        .map(|e| e.into_path())
        .collect();
 
    let results: Vec<Result> = files
        .par_iter()
        .filter_map(|path| process_file(path).ok())
        .collect();
 
    Ok(results)
}

Compile-Time Optimization

Use compile-time computation wherever possible. The include_str! and include_bytes! macros embed files at compile time, eliminating runtime file reads for templates and configuration defaults. Const functions compute values at compile time, and lazy_static! or once_cell initialize expensive values once.

Comparison with Alternatives

FeatureRust + clapGo + cobraPython + ClickNode.js + Commander
Startup time1-5ms5-10ms50-100ms100-200ms
Binary size1-10MB5-15MBN/A (interpreted)N/A (interpreted)
Memory usageVery lowLowModerateModerate
Compile timeSlow (first build)FastN/AN/A
Type safetyExcellentGoodModerate (with hints)Moderate (with TS)
Ecosystem maturityGoodGoodExcellentExcellent
Cross-compilationGood (with cross)ExcellentN/AN/A

Rust is the best choice for CLI tools where startup time, memory usage, and binary distribution matter most. Go is a strong alternative with faster compilation and simpler error handling. Python and Node.js are better for rapid prototyping and when the tool is part of a larger ecosystem in those languages.

Advanced Patterns

Configuration File Support

Add TOML configuration file support with serde:

use serde::Deserialize;
use std::path::PathBuf;
 
#[derive(Deserialize, Default)]
struct Config {
    #[serde(default)]
    project: ProjectConfig,
    #[serde(default)]
    deploy: DeployConfig,
}
 
#[derive(Deserialize, Default)]
struct ProjectConfig {
    default_template: Option<String>,
    output_dir: Option<PathBuf>,
}
 
#[derive(Deserialize, Default)]
struct DeployConfig {
    default_env: Option<String>,
    region: Option<String>,
}
 
fn load_config(cli_path: Option<&Path>) -> anyhow::Result<Config> {
    let config_path = cli_path
        .map(PathBuf::from)
        .unwrap_or_else(|| {
            dirs::config_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join("mytool")
                .join("config.toml")
        });
 
    if config_path.exists() {
        let content = std::fs::read_to_string(&config_path)?;
        Ok(toml::from_str(&content)?)
    } else {
        Ok(Config::default())
    }
}

Shell Completions

Generate shell completions with a dedicated subcommand:

use clap::CommandFactory;
use clap_complete::{generate, shells::{Bash, Zsh, Fish}};
 
fn generate_completions(shell: &str) {
    let mut cmd = Cli::command();
    match shell {
        "bash" => generate(Bash, &mut cmd, "mytool", &mut std::io::stdout()),
        "zsh" => generate(Zsh, &mut cmd, "mytool", &mut std::io::stdout()),
        "fish" => generate(Fish, &mut cmd, "mytool", &mut std::io::stdout()),
        _ => eprintln!("Unsupported shell: {}", shell),
    }
}

Dry Run Pattern

Implement dry-run mode for destructive operations:

fn deploy(env: &str, dry_run: bool, force: bool) -> anyhow::Result<()> {
    let plan = create_deployment_plan(env)?;
 
    println!("Deployment plan for {}:", env);
    for step in &plan.steps {
        println!("  → {}", step.description);
    }
 
    if dry_run {
        println!("\n{}", "Dry run — no changes made.".yellow());
        return Ok(());
    }
 
    if !force && plan.has_warnings() {
        let confirm = Confirm::new()
            .with_prompt("Warnings detected. Continue?")
            .default(false)
            .interact()?;
        if !confirm {
            return Ok(());
        }
    }
 
    for step in &plan.steps {
        step.execute()?;
    }
 
    Ok(())
}

Testing Strategies

Unit Testing Argument Parsing

Test that your argument parser accepts valid inputs and rejects invalid ones:

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;
 
    #[test]
    fn test_init_with_name() {
        let cli = Cli::try_parse_from(["mytool", "init", "my-project"]).unwrap();
        match cli.command {
            Commands::Init { name, .. } => assert_eq!(name, "my-project"),
            _ => panic!("Expected Init command"),
        }
    }
 
    #[test]
    fn test_init_with_all_flags() {
        let cli = Cli::try_parse_from([
            "mytool", "init", "my-project",
            "--template", "rust-cli",
            "--no-git",
            "--defaults",
        ]).unwrap();
        match cli.command {
            Commands::Init { name, template, no_git, defaults, .. } => {
                assert_eq!(name, "my-project");
                assert_eq!(template, Some("rust-cli".to_string()));
                assert!(no_git);
                assert!(defaults);
            }
            _ => panic!("Expected Init command"),
        }
    }
 
    #[test]
    fn test_verbose_global_flag() {
        let cli = Cli::try_parse_from(["mytool", "-v", "build"]).unwrap();
        assert!(cli.verbose);
    }
}

Integration Testing with assert_cmd

Test the compiled binary end-to-end:

use assert_cmd::Command;
use predicates::prelude::*;
 
#[test]
fn test_help_output() {
    Command::cargo_bin("mytool").unwrap()
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("A professional project scaffolding tool"));
}
 
#[test]
fn test_missing_subcommand() {
    Command::cargo_bin("mytool").unwrap()
        .assert()
        .failure();
}
 
#[test]
fn test_list_templates() {
    Command::cargo_bin("mytool").unwrap()
        .args(["list", "--detailed"])
        .assert()
        .success()
        .stdout(predicate::str::contains("rust-cli"));
}

Future Outlook

The Rust CLI ecosystem continues to mature rapidly. The clap crate's derive API has stabilized and become the standard approach. New crates like ratatui are enabling rich terminal user interfaces (TUIs) that go beyond simple text output. The cargo-dist project automates building and distributing Rust binaries across platforms with GitHub Actions.

WebAssembly compilation is an emerging trend. Rust CLI tools can be compiled to WASM and run in browsers, enabling web-based developer tools that share code with their CLI counterparts. The wasm-bindgen and wasm-pack tools make this increasingly practical.

The cargo-binstall project enables installing Rust binaries directly from GitHub releases without compiling from source, eliminating the main pain point of Rust tool distribution. Users can cargo binstall mytool and get a pre-compiled binary in seconds.

Conclusion

Rust is an exceptional language for building CLI tools that are fast, safe, and easy to distribute. The key takeaways from this guide are:

  1. Use clap's derive API for argument parsing — it provides the ergonomics of Python's Click with the performance of native code
  2. Layer your error handling with thiserror for domain errors and anyhow for application-level error propagation
  3. Make prompts interactive with dialoguer but always support non-interactive mode via flags for CI/CD
  4. Show progress with indicatif — spinners and progress bars transform the perception of long-running operations
  5. Optimize for binary size with LTO, symbol stripping, and size-optimized builds for fast downloads
  6. Test at both levels — unit test argument parsing with try_parse_from and integration test the binary with assert_cmd

Start with a simple subcommand, get the argument parsing and error handling right, then incrementally add features. The Rust compiler will catch many bugs at compile time that other languages would only catch at runtime, giving you confidence in your tool's correctness.