How to Write a CLI Application in Rust

Rust has become the go-to language for modern CLI applications, and for good reason. Unlike interpreted languages, Rust compiles to native binaries with zero runtime overhead. You get startup times...

Key Insights

  • Rust’s strong type system and zero-cost abstractions make it ideal for building fast, reliable CLI tools that compile to single binaries with no runtime dependencies
  • The clap crate’s derive macros eliminate boilerplate while providing automatic help generation, argument validation, and subcommand support out of the box
  • Proper error handling with Result types and the ? operator lets you build CLIs that fail gracefully with helpful messages instead of cryptic panics

Introduction & Setup

Rust has become the go-to language for modern CLI applications, and for good reason. Unlike interpreted languages, Rust compiles to native binaries with zero runtime overhead. You get startup times measured in milliseconds, not hundreds of milliseconds. The type system catches entire classes of bugs at compile time, and the ecosystem provides production-ready crates for every CLI concern.

Cross-compilation is straightforward—build Linux binaries on macOS, Windows executables on Linux, all from the same codebase. Your users get a single executable they can drop anywhere in their PATH. No “install Python 3.9 first” or dependency hell.

Let’s start with the basics. Install Rust via rustup if you haven’t already:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Create a new CLI project:

cargo new mytool
cd mytool

This generates a basic structure:

mytool/
├── Cargo.toml
└── src/
    └── main.rs

Your Cargo.toml is where dependencies live. The src/main.rs contains your entry point. Run it with cargo run. Build a release binary with cargo build --release—you’ll find it in target/release/mytool.

Parsing Command-Line Arguments with Clap

Don’t parse arguments manually. The clap crate handles everything: parsing, validation, help text generation, shell completions. The derive macro approach is the cleanest way to define your CLI interface.

Add clap to Cargo.toml:

[dependencies]
clap = { version = "4.4", features = ["derive"] }

Here’s a practical example with required arguments, optional flags, and subcommands:

use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "mytool")]
#[command(about = "A practical CLI tool", long_about = None)]
struct Cli {
    /// Input file to process
    #[arg(short, long)]
    input: PathBuf,

    /// Enable verbose output
    #[arg(short, long)]
    verbose: bool,

    /// Output format
    #[arg(short, long, default_value = "text")]
    format: String,

    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    /// Analyze the input file
    Analyze {
        /// Show detailed statistics
        #[arg(long)]
        detailed: bool,
    },
    /// Convert to different format
    Convert {
        /// Output file path
        #[arg(short, long)]
        output: PathBuf,
    },
}

fn main() {
    let cli = Cli::parse();
    
    if cli.verbose {
        println!("Processing file: {:?}", cli.input);
    }

    match &cli.command {
        Some(Commands::Analyze { detailed }) => {
            println!("Analyzing with detailed={}", detailed);
        }
        Some(Commands::Convert { output }) => {
            println!("Converting to {:?}", output);
        }
        None => {
            println!("No subcommand specified");
        }
    }
}

Run cargo run -- --help and you’ll see automatically generated help text. Clap validates arguments, generates errors for missing required fields, and handles type conversion. The PathBuf type automatically converts string arguments to paths.

Handling User Input and File I/O

Real CLI tools read files, process stdin, and write output. Rust’s Result type forces you to handle errors explicitly. Use the ? operator to propagate errors up the call stack cleanly.

Here’s a file processing example with proper error handling:

use std::fs;
use std::io::{self, BufRead, BufReader};
use std::path::Path;

fn process_file(path: &Path) -> Result<Vec<String>, io::Error> {
    let file = fs::File::open(path)?;
    let reader = BufReader::new(file);
    let mut lines = Vec::new();

    for line in reader.lines() {
        let line = line?;
        // Process each line
        if !line.trim().is_empty() {
            lines.push(line.to_uppercase());
        }
    }

    Ok(lines)
}

fn write_output(lines: &[String], output_path: &Path) -> Result<(), io::Error> {
    let content = lines.join("\n");
    fs::write(output_path, content)?;
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = Path::new("input.txt");
    let output = Path::new("output.txt");

    let processed = process_file(input)?;
    write_output(&processed, output)?;

    println!("Processed {} lines", processed.len());
    Ok(())
}

The ? operator automatically converts errors and returns early if something fails. Returning Result from main() means errors get printed with their error messages instead of causing panics.

For reading from stdin when no file is specified, use this pattern:

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

fn read_input(path: Option<&Path>) -> Result<String, io::Error> {
    match path {
        Some(p) => fs::read_to_string(p),
        None => {
            let mut buffer = String::new();
            io::stdin().read_to_string(&mut buffer)?;
            Ok(buffer)
        }
    }
}

Adding Color and Formatting to Output

Terminal colors make CLIs more usable. Success messages in green, errors in red, warnings in yellow. The colored crate is simple and effective.

Add dependencies:

[dependencies]
colored = "2.1"
indicatif = "0.17"

Use colored output and progress indicators:

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

fn main() {
    // Colored output
    println!("{}", "Success: File processed".green());
    println!("{}", "Error: File not found".red());
    println!("{}", "Warning: Using default config".yellow());

    // Bold and combinations
    println!("{}", "Critical Error".red().bold());

    // Progress bar for long operations
    let items = 100;
    let pb = ProgressBar::new(items);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
            .unwrap()
            .progress_chars("#>-"),
    );

    for i in 0..items {
        pb.set_message(format!("Processing item {}", i));
        // Simulate work
        thread::sleep(Duration::from_millis(20));
        pb.inc(1);
    }

    pb.finish_with_message("Done!");
}

Progress bars dramatically improve UX for operations that take more than a second. Users know the tool is working, not frozen.

Configuration and Environment Variables

Most CLIs need configuration. Support both config files and environment variables, with a clear precedence order. The config crate handles merging multiple sources.

Add dependencies:

[dependencies]
config = "0.13"
serde = { version = "1.0", features = ["derive"] }

Create a configuration structure:

use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;
use std::env;

#[derive(Debug, Deserialize)]
struct Settings {
    database_url: String,
    api_key: Option<String>,
    timeout_seconds: u64,
    verbose: bool,
}

impl Settings {
    fn new() -> Result<Self, ConfigError> {
        let config_path = env::var("CONFIG_PATH")
            .unwrap_or_else(|_| "config.toml".to_string());

        let s = Config::builder()
            // Start with defaults
            .set_default("timeout_seconds", 30)?
            .set_default("verbose", false)?
            // Load from config file if it exists
            .add_source(File::with_name(&config_path).required(false))
            // Override with environment variables (prefix with APP_)
            .add_source(Environment::with_prefix("APP"))
            .build()?;

        s.try_deserialize()
    }
}

fn main() -> Result<(), ConfigError> {
    let settings = Settings::new()?;
    println!("Database: {}", settings.database_url);
    println!("Timeout: {}s", settings.timeout_seconds);
    Ok(())
}

Create a config.toml:

database_url = "postgres://localhost/mydb"
timeout_seconds = 60
verbose = true

Users can override with environment variables: APP_DATABASE_URL=postgres://prod/db APP_VERBOSE=false ./mytool

Testing and Distribution

Write integration tests that exercise your CLI as users would. Capture stdout and stderr to verify output.

Create tests/integration_test.rs:

use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;

#[test]
fn test_file_processing() {
    let temp = TempDir::new().unwrap();
    let input_path = temp.path().join("input.txt");
    let output_path = temp.path().join("output.txt");

    fs::write(&input_path, "hello\nworld\n").unwrap();

    let mut cmd = Command::cargo_bin("mytool").unwrap();
    cmd.arg("--input")
        .arg(&input_path)
        .arg("convert")
        .arg("--output")
        .arg(&output_path)
        .assert()
        .success()
        .stdout(predicate::str::contains("Processed"));

    let output = fs::read_to_string(&output_path).unwrap();
    assert_eq!(output, "HELLO\nWORLD");
}

#[test]
fn test_missing_file() {
    let mut cmd = Command::cargo_bin("mytool").unwrap();
    cmd.arg("--input")
        .arg("nonexistent.txt")
        .assert()
        .failure()
        .stderr(predicate::str::contains("No such file"));
}

Add test dependencies to Cargo.toml:

[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.0"
tempfile = "3.8"

Build release binaries with cargo build --release. For cross-compilation, install targets:

rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

Distribute via GitHub releases or publish to crates.io so users can install with cargo install mytool. Your single binary has no dependencies—it just works.

Rust gives you the tools to build CLIs that are fast, reliable, and pleasant to use. Start with clap for arguments, handle errors properly with Result, add color for better UX, and test thoroughly. Your users will appreciate tools that start instantly and fail with helpful messages.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.