Skip to content
Home
Building CLI Apps in Rust: A Practical Guide

Building CLI Apps in Rust: A Practical Guide

Rust Rust 8 min read 1515 words Beginner ExcellentWiki Editorial Team

Rust is an excellent language for building command-line tools. It produces fast, statically-linked binaries, has no runtime overhead, and great libraries for argument parsing and output formatting. Tools like ripgrep, bat, fd, and delta are all written in Rust.

This guide walks through building a CLI application using the clap crate for argument parsing, anyhow for error handling, and indicatif for progress indicators.

Getting Started

Create a new project:

cargo new mycli
cd mycli

Add dependencies to Cargo.toml:

[package]
name = "mycli"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
indicatif = "0.17"
colored = "2"

Argument Parsing with Clap

Clap 4 includes derive macros that define argument parsers declaratively:

use clap::Parser;

#[derive(Parser)]
#[command(name = "mycli")]
#[command(about = "A powerful CLI tool", long_about = None)]
struct Cli {
    /// Input file path
    #[arg(short, long, value_name = "FILE")]
    input: String,

    /// Output file path (optional)
    #[arg(short, long, value_name = "FILE")]
    output: Option<String>,

    /// Verbose output
    #[arg(short, long, default_value_t = false)]
    verbose: bool,

    /// Number of retries
    #[arg(short, long, default_value_t = 3)]
    retries: u32,

    /// Config file path
    #[arg(short = 'c', long, default_value = "config.toml")]
    config: String,
---

fn main() {
    let cli = Cli::parse();

    if cli.verbose {
        eprintln!("Processing file: {}", cli.input);
    }

    println!("Input: {}", cli.input);
    if let Some(output) = cli.output {
        println!("Output: {}", output);
    }
---

Run with cargo run -- --input data.txt --verbose.

Argument Types

Clap validates types at parse time:

#[derive(Parser)]
struct Opts {
    /// Integer (validated by clap)
    #[arg(short)]
    count: u32,

    /// Float
    #[arg(short)]
    ratio: f64,

    /// Enum
    #[arg(value_enum)]
    mode: Mode,

    /// List of values
    #[arg(short, long, num_args = 1..)]
    items: Vec<String>,

    /// Key=value pairs
    #[arg(short, long, value_parser = parse_key_val)]
    define: Vec<(String, String)>,
---

#[derive(clap::ValueEnum, Clone)]
enum Mode {
    Fast,
    Accurate,
    Balanced,
---

fn parse_key_val(s: &str) -> anyhow::Result<(String, String)> {
    let mut parts = s.splitn(2, '=');
    let key = parts.next().ok_or_else(|| anyhow::anyhow!("missing key"))?.to_string();
    let val = parts.next().ok_or_else(|| anyhow::anyhow!("missing value"))?.to_string();
    Ok((key, val))
---

Subcommands

Subcommands create multi-tool interfaces like git commit, git push:

#[derive(Parser)]
#[command(name = "taskctl")]
enum Command {
    /// Create a new task
    Create(CreateArgs),

    /// List all tasks
    List(ListArgs),

    /// Complete a task
    Complete { id: u32 },
---

#[derive(clap::Args)]
struct CreateArgs {
    /// Task title
    title: String,

    /// Task priority
    #[arg(short, long, default_value_t = 5)]
    priority: u32,
---

#[derive(clap::Args)]
struct ListArgs {
    /// Filter by status
    #[arg(short, long)]
    status: Option<String>,

    /// Show all tasks including completed
    #[arg(short, long)]
    all: bool,
---

fn main() -> anyhow::Result<()> {
    let command = Command::parse();

    match command {
        Command::Create(args) => create_task(&args),
        Command::List(args) => list_tasks(&args),
        Command::Complete { id } => complete_task(id),
    }
---

Usage:

taskctl create "Write documentation" --priority 3
taskctl list --status pending
taskctl complete 42

Error Handling with Anyhow

anyhow provides ergonomic error handling:

use anyhow::{Context, Result};

fn read_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config from {}", path))?;

    let config: Config = serde_json::from_str(&content)
        .with_context(|| "Failed to parse config JSON")?;

    Ok(config)
---

fn process_file(input: &str, output: &str) -> Result<u64> {
    let data = std::fs::read(input)
        .with_context(|| format!("Cannot read input file: {}", input))?;

    let processed: Vec<u8> = data.iter().map(|b| b ^ 0xFF).collect();

    std::fs::write(output, &processed)
        .with_context(|| format!("Failed to write output: {}", output))?;

    Ok(processed.len() as u64)
---

fn main() -> Result<()> {
    let cli = Cli::parse();
    let config = read_config(&cli.config)?;

    match process_file(&cli.input, &cli.output.unwrap_or_else(|| "output.bin".into())) {
        Ok(bytes) => {
            println!("Processed {} bytes", bytes);
            Ok(())
        }
        Err(e) => {
            eprintln!("Error: {:#}", e);
            std::process::exit(1);
        }
    }
---

The {:#} format with anyhow prints a chain of error contexts, making debugging easier for users.

Output Formatting

Colored Output

use colored::*;

fn print_status(level: &str, message: &str) {
    match level {
        "info" => println!("{} {}", "INFO".green(), message),
        "warn" => println!("{} {}", "WARN".yellow(), message),
        "error" => println!("{} {}", "ERROR".red().bold(), message),
        _ => println!("{}", message),
    }
---

Tables

fn print_tasks(tasks: &[Task]) {
    println!("{:<5} {:<30} {:<10} {:<10}", "ID", "Title", "Priority", "Status");
    println!("{}", "-".repeat(60));

    for task in tasks {
        let status = if task.completed { "done".green() } else { "pending".yellow() };
        println!("{:<5} {:<30} {:<10} {}", task.id, task.title, task.priority, status);
    }
---

JSON Output

Support --json flag for machine-readable output:

fn print_output(data: &impl serde::Serialize, json: bool) -> Result<()> {
    if json {
        println!("{}", serde_json::to_string_pretty(data)?);
    } else {
        // human-readable output
        print_human(data);
    }
    Ok(())
---

Progress Indicators

Long-running operations benefit from progress bars:

use indicatif::{ProgressBar, ProgressStyle};
use std::thread;
use std::time::Duration;

fn process_files(files: &[String]) -> Result<()> {
    let pb = ProgressBar::new(files.len() as u64);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")?
            .progress_chars("#>-"),
    );

    for file in files {
        pb.set_message(format!("Processing {}", file));
        thread::sleep(Duration::from_millis(500));  // Simulate work
        pb.inc(1);
    }

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

Spinners

use indicatif::ProgressBar;

fn search_pattern(pattern: &str, path: &str) -> Result<Vec<String>> {
    let spinner = ProgressBar::new_spinner();
    spinner.set_message(format!("Searching for '{}' in {}", pattern, path));
    spinner.enable_steady_tick(Duration::from_millis(100));

    let results = perform_search(pattern, path)?;

    spinner.finish_and_clear();
    Ok(results)
---

File System Operations

CLI tools often work with the filesystem:

use std::path::{Path, PathBuf};

fn find_config() -> Result<PathBuf> {
    // XDG base directory specification
    let xdg_config = std::env::var("XDG_CONFIG_HOME")
        .map(|d| PathBuf::from(d).join("mycli/config.toml"))
        .unwrap_or_else(|_| {
            dirs::config_dir()
                .unwrap_or_else(|| PathBuf::from("~/.config"))
                .join("mycli/config.toml")
        });

    if xdg_config.exists() {
        return Ok(xdg_config);
    }

    let local = PathBuf::from("./mycli.toml");
    if local.exists() {
        return Ok(local);
    }

    anyhow::bail!("No config file found. Create one at {} or ./mycli.toml", xdg_config.display());
---

Structured CLI Design

Exit Codes

Use exit codes to signal different failure modes:

fn main() {
    let result = run();

    match result {
        Ok(exit_code) => std::process::exit(exit_code),
        Err(e) => {
            eprintln!("Error: {:#}", e);
            std::process::exit(1);
        }
    }
---

fn run() -> Result<i32> {
    // 0 = success, 1 = no results, 2 = partial failure
    Ok(0)
---

Stderr for Diagnostics, Stdout for Data

Follow the Unix philosophy: print data to stdout, diagnostics and errors to stderr:

// Good: normal output to stdout
println!("{}", result);

// Good: progress and errors to stderr
eprintln!("Processing file {} of {}", current, total);
eprintln!("Error: file not found: {}", path);

This allows piping: mycli list --json | jq '.items' without progress messages interfering.

Testing CLI Applications

#[cfg(test)]
mod tests {
    use super::*;
    use assert_cmd::Command;

    #[test]
    fn test_help() {
        let mut cmd = Command::cargo_bin("mycli").unwrap();
        cmd.arg("--help")
            .assert()
            .success();
    }

    #[test]
    fn test_version() {
        let mut cmd = Command::cargo_bin("mycli").unwrap();
        cmd.arg("--version")
            .assert()
            .success()
            .stdout(predicates::str::contains("0.1.0"));
    }
---

Add assert_cmd and predicates to dev-dependencies for CLI integration testing.

Summary

Rust’s type system, performance, and cross-compilation make it ideal for CLI tools. Clap provides ergonomic argument parsing with derive macros. Anyhow simplifies error handling with context chains. Indicatif adds professional progress indicators. Colored output, JSON serialization, and testable command structures complete the picture. Start with a clear subcommand structure, handle errors gracefully, and format output for both humans and machines.

Argument Parsing with Clap

Clap is the standard argument parsing library for Rust CLIs. Derive mode uses #[derive(Parser)] on a struct to define arguments, options, and subcommands with minimal boilerplate. It generates help text, shell completions, and error messages automatically. Builder mode provides more flexibility for programmatic use cases.

Terminal Output and Formatting

Crates like colored and termion add color and styling to terminal output. indicatif provides progress bars and spinners for long-running operations. dialoguer enables interactive prompts with autocomplete, confirmation dialogs, and multi-select menus. These libraries transform simple CLIs into polished user experiences.

Rust in Production: Case Studies

Rust has been adopted in production by major technology companies for performance-critical and safety-critical systems. Mozilla uses Rust in the Firefox browser engine (Servo/Quantum) for improved memory safety and performance in CSS layout and rendering. Dropbox rewrote their sync engine core in Rust, achieving significant performance improvements and eliminating entire classes of bugs. Cloudflare uses Rust for edge computing infrastructure, including their Pingora HTTP proxy that handles 10% of the world’s internet traffic. Figma rewrote their multiplayer editing engine in Rust, achieving predictable performance and eliminating crashes. Discord used Rust for their read states service, handling millions of concurrent connections with minimal resources. These case studies demonstrate Rust’s value in systems where performance, reliability, and memory safety are critical. The common pattern: rewrite performance-critical components in Rust while maintaining the rest of the system in the original language, proving that incremental adoption works in practice.

Embedded Systems Programming

Rust is gaining traction in embedded systems for its memory safety guarantees without a garbage collector or runtime. The cortex-m-quickstart template provides a starting point for ARM Cortex-M microcontrollers. The RTIC framework provides real-time interrupt-driven concurrency. Embassy offers an async embedded runtime with USB, networking, and BLE support. Rust’s type system prevents common embedded errors like misconfiguring peripherals, incorrect register access, and data races between interrupt handlers and main code. The embedded ecosystem continues to mature with HAL implementations for major microcontroller families from STM, Nordic, and Espressif.

FAQ

What background knowledge is recommended? A basic understanding of programming fundamentals will help, but many concepts are explained from first principles.

How can I practice these skills? Start with small projects, contribute to open source, and build a portfolio. Consistent practice is more effective than occasional deep dives.

What tools do I need to get started? Most topics require only a text editor, the relevant runtime, and package manager. Specific tool recommendations are included throughout.

For a comprehensive overview, read our article on Getting Started With Rust.

For a comprehensive overview, read our article on Rust Async Programming.

Section: Rust 1515 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top