Skip to main content
โšก Calmops

Building CLI Tools with Rust

Creating Fast, Safe, and User-Friendly Command-Line Applications

Command-line tools are the backbone of developer workflows, DevOps pipelines, and system administration. Yet many are written in languages that sacrifice either performance or safety. Rust changes this equation: you can write CLI tools that are simultaneously fast, memory-safe, and easy to deploy (single compiled binary, no runtime needed).

This article explores why Rust is exceptional for CLI development and how to build professional-grade command-line applications.

Core Concepts & Terminology

What Makes a Good CLI Tool?

Before diving into code, let’s understand what separates excellent CLI tools from mediocre ones:

  1. Argument Parsing: Correctly interpreting user flags, options, and positional arguments
  2. Error Handling: Clear, actionable error messages when things go wrong
  3. Exit Codes: Proper exit status for shell scripting integration
  4. Performance: Minimal startup time and fast execution
  5. Discoverability: Helpful --help output and documentation
  6. Cross-Platform: Works on Linux, macOS, Windows without modification

Why Rust for CLI Tools?

Performance

  • Native compilation to machine code (no JVM startup overhead like Java)
  • No garbage collection pauses
  • Binary sizes typically 2-10 MB (comparable to C, vastly smaller than Go for simple tools)

Safety

  • Memory safety without garbage collection
  • No uninitialized variables
  • Checked error handling via Result<T, E>
  • Thread-safe by default

Distribution

  • Single static binary (copy and run, no dependencies)
  • Easy cross-compilation to different OS/architectures
  • Cargo handles version management elegantly

Developer Experience

  • Excellent error messages at compile time
  • Strong type system catches bugs before runtime
  • Rich ecosystem of crate libraries

Core Concepts: CLI Architecture Patterns

The MVC Pattern for CLI Tools

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  User Input     โ”‚  (Arguments, stdin, config files)
โ”‚  (Arguments)    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Argument Parsing & Validation  โ”‚  (Model: Define what CLI accepts)
โ”‚  (Clap/StructOpt)               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Business Logic                 โ”‚  (Controller: Do the work)
โ”‚  (Execute commands, process)    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Output Formatting              โ”‚  (View: Pretty printing)
โ”‚  (JSON, tables, colored text)   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Output/Result  โ”‚  (Exit code + printed output)
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Exit Codes Convention

0  โ†’ Success
1  โ†’ General error
2  โ†’ Misuse of shell command
126 โ†’ Permission denied
127 โ†’ Command not found

Getting Started: Building Your First CLI Tool

Project Setup

# Create a new CLI project
cargo new my-cli-tool
cd my-cli-tool

# Add dependencies
cargo add clap --features derive
cargo add serde_json
cargo add anyhow
cargo add colored

Cargo.toml Configuration

[package]
name = "my-cli-tool"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "my-tool"
path = "src/main.rs"

[dependencies]
clap = { version = "4.4", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
colored = "2.0"
regex = "1.10"

[profile.release]
opt-level = 3          # Optimize for speed
strip = true           # Strip debug symbols
lto = true             # Link-time optimization

Hello World CLI

use clap::Parser;

#[derive(Parser)]
#[command(name = "my-tool")]
#[command(about = "A useful command-line tool", long_about = None)]
struct Cli {
    /// Name of the person to greet
    #[arg(short, long)]
    name: String,

    /// Times to greet
    #[arg(short, long, default_value = "1")]
    count: u32,
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    for _ in 0..cli.count {
        println!("Hello, {}!", cli.name);
    }

    Ok(())
}

Usage:

$ cargo run -- --name Alice --count 3
Hello, Alice!
Hello, Alice!
Hello, Alice!

$ cargo run -- --help
Usage: my-tool --name <NAME> [OPTIONS]

Options:
  -n, --name <NAME>
  -c, --count <COUNT>
  -h, --help

Advanced CLI Patterns

Pattern 1: Subcommands (Like git, cargo, docker)

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "mytool")]
#[command(about = "A tool with subcommands", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Initialize a new project
    Init {
        /// Project name
        #[arg(value_name = "NAME")]
        name: String,
    },
    /// Build the project
    Build {
        /// Build in release mode
        #[arg(short, long)]
        release: bool,
    },
    /// Run the project
    Run {
        /// Arguments to pass to the program
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Init { name } => {
            println!("Initializing project: {}", name);
        }
        Commands::Build { release } => {
            let mode = if release { "release" } else { "debug" };
            println!("Building in {} mode", mode);
        }
        Commands::Run { args } => {
            println!("Running with args: {:?}", args);
        }
    }

    Ok(())
}

Usage:

$ mytool init my-project
Initializing project: my-project

$ mytool build --release
Building in release mode

$ mytool run -- arg1 arg2
Running with args: ["arg1", "arg2"]

Pattern 2: Configuration File Handling

use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use clap::Parser;

#[derive(Parser)]
struct Cli {
    /// Config file path
    #[arg(short, long)]
    config: Option<PathBuf>,
}

#[derive(Debug, Serialize, Deserialize)]
struct Config {
    debug: bool,
    verbose: u32,
    database_url: String,
}

impl Config {
    fn from_file(path: &PathBuf) -> anyhow::Result<Self> {
        let content = fs::read_to_string(path)?;
        let config = serde_json::from_str(&content)?;
        Ok(config)
    }

    fn default() -> Self {
        Config {
            debug: false,
            verbose: 0,
            database_url: String::from("localhost:5432"),
        }
    }
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    let config = if let Some(config_path) = cli.config {
        Config::from_file(&config_path)?
    } else {
        Config::default()
    };

    println!("Config: {:?}", config);
    Ok(())
}

Config file (config.json):

{
  "debug": true,
  "verbose": 2,
  "database_url": "postgres://localhost/mydb"
}

Pattern 3: User-Friendly Error Messages

use std::path::Path;
use anyhow::{Context, Result};
use colored::Colorize;

fn read_config_file(path: &Path) -> Result<String> {
    std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config file at {:?}", path))?
        .parse()
        .context("Config file is not valid UTF-8")
}

fn main() -> Result<()> {
    match read_config_file(Path::new("config.toml")) {
        Ok(content) => println!("{}", content),
        Err(e) => {
            eprintln!("{}: {}", "Error".red().bold(), e);
            for source in std::error::Error::sources(&e).skip(1) {
                eprintln!("  {}: {}", "Caused by".yellow(), source);
            }
            std::process::exit(1);
        }
    }

    Ok(())
}

Output:

Error: Failed to read config file at "config.toml"

Caused by: No such file or directory (os error 2)

Pattern 4: Progress Indicators

use std::time::Duration;

// Using indicatif crate
use indicatif::{ProgressBar, ProgressStyle};

fn main() -> anyhow::Result<()> {
    let pb = ProgressBar::new(100);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")?
            .progress_chars("#>-")
    );

    for i in 0..100 {
        std::thread::sleep(Duration::from_millis(50));
        pb.inc(1);
    }

    pb.finish_with_message("Done!");
    Ok(())
}

Add to Cargo.toml:

indicatif = "0.17"

Pattern 5: JSON Output for Machine Readability

use serde::{Deserialize, Serialize};
use clap::Parser;

#[derive(Parser)]
struct Cli {
    /// Output as JSON
    #[arg(long)]
    json: bool,
}

#[derive(Serialize, Deserialize)]
struct Result {
    success: bool,
    message: String,
    data: Option<String>,
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    let result = Result {
        success: true,
        message: "Operation completed".to_string(),
        data: Some("important_data".to_string()),
    };

    if cli.json {
        println!("{}", serde_json::to_string_pretty(&result)?);
    } else {
        println!("โœ“ {}", result.message);
        if let Some(data) = result.data {
            println!("  Data: {}", data);
        }
    }

    Ok(())
}

Output:

# Normal output
$ mytool
โœ“ Operation completed
  Data: important_data

# JSON output
$ mytool --json
{
  "success": true,
  "message": "Operation completed",
  "data": "important_data"
}

Pattern 6: Reading from stdin and piping

use std::io::{self, BufRead};

fn main() -> anyhow::Result<()> {
    let stdin = io::stdin();
    let reader = stdin.lock();

    for line in reader.lines() {
        let line = line?;
        // Process each line from stdin
        println!("Processed: {}", line.to_uppercase());
    }

    Ok(())
}

Usage:

$ echo -e "hello\nworld" | mytool
Processed: HELLO
Processed: WORLD

$ cat file.txt | mytool

Real-World Example: A File Search Tool

use std::fs;
use std::path::PathBuf;
use clap::Parser;
use anyhow::{Context, Result};
use colored::Colorize;
use regex::Regex;

#[derive(Parser)]
#[command(name = "fsearch")]
#[command(about = "Search for files matching a pattern", long_about = None)]
struct Cli {
    /// Search pattern (regex)
    pattern: String,

    /// Directory to search in
    #[arg(short, long, default_value = ".")]
    dir: PathBuf,

    /// Recurse into subdirectories
    #[arg(short, long)]
    recurse: bool,

    /// Print full paths
    #[arg(short, long)]
    full: bool,

    /// Count matches only
    #[arg(short, long)]
    count: bool,
}

fn search_directory(
    dir: &PathBuf,
    pattern: &Regex,
    recurse: bool,
    full: bool,
    count: &mut u32,
) -> Result<()> {
    let entries = fs::read_dir(dir)
        .with_context(|| format!("Failed to read directory: {:?}", dir))?;

    for entry in entries {
        let entry = entry?;
        let path = entry.path();

        if path.is_dir() {
            if recurse {
                search_directory(&path, pattern, recurse, full, count)?;
            }
        } else {
            let file_name = path.file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("");

            if pattern.is_match(file_name) {
                *count += 1;
                let display_path = if full {
                    path.to_string_lossy()
                } else {
                    std::borrow::Cow::Borrowed(file_name)
                };

                println!("{}", display_path.green());
            }
        }
    }

    Ok(())
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    // Compile regex pattern
    let regex = Regex::new(&cli.pattern)
        .with_context(|| format!("Invalid regex pattern: {}", cli.pattern))?;

    let mut count = 0;
    search_directory(&cli.dir, &regex, cli.recurse, cli.full, &mut count)?;

    if cli.count {
        println!("\n{} matches found", count);
    }

    Ok(())
}

Usage:

$ fsearch '\.rs$' --recurse --full
./src/main.rs
./src/lib.rs
./tests/integration_test.rs

$ fsearch '\.rs$' --recurse --count
3 matches found

Common Pitfalls & Best Practices

1. Poor Error Messages

โŒ Bad: Generic or unhelpful errors

fn process_file(path: &str) -> Result<String> {
    Ok(std::fs::read_to_string(path)?)
}
// Error: No such file or directory (os error 2)

โœ… Good: Contextual, actionable errors

use anyhow::Context;

fn process_file(path: &str) -> Result<String> {
    std::fs::read_to_string(path)
        .with_context(|| {
            format!(
                "Could not read file '{}'. Please check the path exists and you have read permissions.",
                path
            )
        })
}

2. Blocking on User Input

โŒ Bad: No timeout on input

let mut input = String::new();
std::io::stdin().read_line(&mut input)?; // Hangs forever if piped from /dev/null

โœ… Good: Handle end-of-file gracefully

let mut input = String::new();
match std::io::stdin().read_line(&mut input) {
    Ok(0) => println!("EOF reached"),
    Ok(_) => { /* process input */ },
    Err(e) => eprintln!("Error reading input: {}", e),
}

3. Inefficient Directory Traversal

โŒ Bad: Loading all files into memory

let files: Vec<_> = walkdir::WalkDir::new(".")
    .into_iter()
    .collect(); // Could be millions of entries!

โœ… Good: Process lazily

for entry in walkdir::WalkDir::new(".") {
    let entry = entry?;
    // Process one file at a time
}

4. Missing –help and –version Flags

โŒ Bad: Manual help handling

fn main() {
    let args: Vec<_> = std::env::args().collect();
    if args.contains(&"--help".to_string()) {
        println!("Usage: ...");
    }
}

โœ… Good: Let clap handle it

use clap::Parser;

#[derive(Parser)]
#[command(version)]
#[command(about = "Your tool description")]
struct Cli {
    // ...
}

fn main() {
    let _cli = Cli::parse(); // Automatically handles --help and --version
}

5. Inconsistent Exit Codes

โŒ Bad: Always exit with 0 or generic error

fn main() {
    if let Err(e) = run() {
        eprintln!("Error: {}", e);
        // Implicitly exits with 0 (success!) - wrong!
    }
}

โœ… Good: Use appropriate exit codes

fn main() {
    if let Err(e) = run() {
        eprintln!("Error: {}", e);
        std::process::exit(1); // Exit with error code
    }
}

6. Unbounded Resource Consumption

โŒ Bad: No limits on memory/CPU

let all_lines: Vec<_> = reader.lines().collect()?; // May exhaust memory on large files

โœ… Good: Set reasonable limits

const MAX_LINES: usize = 10_000;
for (i, line) in reader.lines().enumerate() {
    if i >= MAX_LINES {
        anyhow::bail!("File exceeds maximum of {} lines", MAX_LINES);
    }
    // Process line
}

Rust vs. Alternatives for CLI Tools

Aspect Rust Go Python Node.js
Binary Size 2-10 MB 5-15 MB N/A (needs runtime) N/A (needs runtime)
Startup Time <10ms <50ms 100-500ms 200-1000ms
Memory Usage 1-5 MB 5-20 MB 10-50 MB 50-100 MB
Error Handling Compile-time Runtime checks Runtime checks Runtime checks
Learning Curve Medium (steep) Low Low Medium
Distribution Single binary Single binary Requires Python Requires Node.js
Cross-Platform Easy Easy Easy Easy
Dependency Size Small Small Medium Large
Type Safety Excellent Good Weak Weak

When to Choose Rust for CLI Tools

โœ… Use Rust when:

  • Performance and startup time matter
  • You need a single self-contained binary
  • Safety and correctness are critical
  • You want compile-time guarantees

โœ… Use Go when:

  • Fast development speed matters
  • Simplicity is prioritized over safety
  • Goroutines are essential for concurrency

โœ… Use Python when:

  • Rapid prototyping is needed
  • Cross-platform GUI isn’t required
  • Data science integration is needed

โœ… Use Node.js when:

  • You need JavaScript ecosystem
  • Frontend integration is required

Professional CLI Best Practices

1. Semantic Versioning

[package]
version = "1.2.3"  # Major.Minor.Patch

2. Shell Completion Scripts

# Generate bash completion
mytool --generate-completion bash > /etc/bash_completion.d/mytool

# Generate zsh completion
mytool --generate-completion zsh > /usr/share/zsh/site-functions/_mytool

Use clap_complete crate:

clap_complete = "4.4"

3. Configuration File Locations

Follow XDG standards on Unix:

  • Config: $XDG_CONFIG_HOME/mytool/config or ~/.config/mytool/config
  • Data: $XDG_DATA_HOME/mytool or ~/.local/share/mytool
  • Cache: $XDG_CACHE_HOME/mytool or ~/.cache/mytool
use std::path::PathBuf;

fn get_config_dir() -> PathBuf {
    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
        PathBuf::from(config_home)
    } else {
        let mut path = dirs::home_dir().unwrap();
        path.push(".config");
        path
    }
}

4. Logging Configuration

use log::{info, warn, error};

fn main() -> Result<()> {
    env_logger::Builder::from_default_env()
        .filter_level(log::LevelFilter::Info)
        .init();

    info!("Application started");
    warn!("This is a warning");
    error!("An error occurred");

    Ok(())
}

In Cargo.toml:

log = "0.4"
env_logger = "0.10"

Resources & Learning Materials

Official Resources

Example CLI Tools in Rust

  • ripgrep - Fast grep replacement
  • fd - User-friendly find
  • bat - Cat with syntax highlighting
  • exa - Modern ls replacement
  • tokei - Line counter

Useful Crates

  • clap - Argument parsing (we covered this)
  • structopt - Derive macros for clap
  • anyhow - Error handling
  • serde - Serialization/deserialization
  • regex - Pattern matching
  • walkdir - Directory traversal
  • indicatif - Progress bars
  • colored - Terminal colors
  • dirs - Standard directory paths
  • dotenv - Load environment variables
  • tempfile - Temporary files/directories
  • crossterm - Terminal manipulation (cross-platform)

Deployment and Distribution

Building Release Binary

# Build optimized binary
cargo build --release

# Binary location: target/release/my-tool

Cross-Compilation

# Add target
rustup target add x86_64-pc-windows-gnu
rustup target add aarch64-apple-darwin

# Build for Windows from Linux
cargo build --release --target x86_64-pc-windows-gnu

# Build for ARM Mac from Intel Mac
cargo build --release --target aarch64-apple-darwin

Publishing to Crates.io

# Publish your CLI tool
cargo publish

# Users can then install via:
cargo install my-tool

Conclusion

Rust is an exceptional choice for building CLI tools. The combination of:

  • Performance (instant startup, minimal overhead)
  • Safety (memory safety at compile time)
  • Usability (excellent error messages, strong types)
  • Distribution (single static binary)

…makes Rust uniquely suited to command-line applications that integrate seamlessly into developer workflows and DevOps pipelines.

The ecosystem of argument parsing, error handling, and output formatting libraries makes building professional CLI tools straightforward. Whether you’re building internal automation, open-source tools, or production infrastructure utilities, Rust provides the foundations for applications that are both reliable and performant.

Start small with a simple wrapper around an existing process, and gradually expand to more complex tools. You’ll find that Rust’s compiler catches many edge cases before they reach production, resulting in fewer surprises in production systems.

Comments