Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 042 / 100

Building a CLI tool in Rust: what developers should know

Build fast command-line tools in Rust with clap arguments, clear error messages, good output and single-binary releases.

Expona Admin7 min read

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.

rust
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:

rust
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:

rust
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:

rust
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:

rust
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:

  1. Compile in release mode: cargo build --release
  2. The binary lands in target/release/your-tool (or .exe on Windows)
  3. 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:

rust
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:

rust
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::Result throughout and handle errors at the top level.
  • Ignoring stdout vs. stderr: Use println! for normal output and eprintln! 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 and exit(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::var for 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::Result and use .context() to attach human-readable messages at each layer so users know what went wrong.
  • Add color with colored and progress bars with indicatif to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles