All posts
15 Sep 2026

Beyond the Script: Building, Testing, and Shipping a Modern CLI in Rust

A practical, code-driven guide to building high-performance, production-grade CLI tools in Rust with clap, miette, indicatif, and automated GitHub Actions distribution.

Beyond the Script: Building, Testing, and Shipping a Modern CLI in Rust

Command-line interfaces are the unsung heroes of software engineering. From deployment utilities to database migrations, a well-crafted CLI tool acts as the foundational leverage for development teams. However, writing a CLI in dynamic languages often leads to brittle scripts, while C and C++ implementations risk memory safety vulnerabilities.

Enter Rust. With its blazing-fast performance, zero-cost abstractions, and fearless concurrency, Rust has become the gold standard for writing modern command-line tools. Projects like ripgrep, fd, and bat have redefined what users expect from terminal utilities: they are fast, visually appealing, robust, and ergonomically delightful.

In this deep dive, we are going to build cargo-audit-stats, a production-ready CLI tool that scans project dependencies, interacts with external APIs, renders beautiful progress bars, and handles errors gracefully. By the end of this post, you’ll know how to structure your codebase, parse arguments with clap, present rich terminal UIs with indicatif and miette, write robust integration tests, and automate cross-platform binary releases via GitHub Actions.


1. Ergonomic Argument Parsing with clap

A great CLI begins with a great user interface, and for command-line tools, that interface is your argument parser. We will use clap (derive feature), which lets us define our command structure using standard Rust structs and attributes.

First, initialize a new binary project:

bash
cargo new cargo-audit-stats --bin
cd cargo-audit-stats

Add the necessary dependencies to your Cargo.toml:

[package]
name = "cargo-audit-stats"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4.4", features = ["derive"] }
tokio = { version = "1.35", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
indicatif = "0.17"
miette = { version = "5.10", features = ["fancy"] }
thiserror = "1.0"

Now, let’s set up our CLI arguments in src/cli.rs:

// src/cli.rs
use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(
    name = "cargo-audit-stats",
    author = "Engineering Team <eng@example.com>",
    version = "0.1.0",
    about = "Analyzes and generates security audit statistics for Rust projects",
    long_about = None
)]
pub struct Cli {
    /// Path to Cargo.toml
    #[arg(short, long, value_name = "FILE", default_value = "Cargo.toml")]
    pub manifest_path: PathBuf,

    /// Output results in JSON format
    #[arg(long, default_value_t = false)]
    pub json: bool,

    #[command(subcommand)]
    pub command: Commands,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Fetch vulnerability metrics from the advisory database
    Fetch {
        /// Maximum number of records to process
        #[arg(short, long, default_value_t = 100)]
        limit: usize,
    },
    /// Clean local cache directories
    Clean,
}

By leveraging clap’s derive API, we get comprehensive --help pages, auto-generated shell completions, and strict type safety right out of the box.


2. Gorgeous Error Handling with miette

Standard Result<T, E> combined with unwrap() or rudimentary printing results in poor user experiences when things go wrong. Modern CLIs should explain what went wrong, where it happened, and how to fix it.

We will use miette alongside thiserror to build diagnostic-grade error messages.

// src/errors.rs
use miette::Diagnostic;
use thiserror::Error;

#[derive(Error, Debug, Diagnostic)]
pub enum AuditError {
    #[diagnostic(
        code(audit::manifest_not_found),
        help("Ensure you are running this command inside a directory containing a valid Cargo.toml file.")
    )]
    #[error("Could not find Cargo.toml at path: {path}")]
    ManifestNotFound { path: String },

    #[diagnostic(
        code(audit::network_failure),
        help("Check your internet connection or verify the advisory database endpoint.")
    )]
    #[error("Failed to communicate with the advisory database: {0}")]
    NetworkError(#[from] reqwest::Error),

    #[diagnostic(
        code(audit::io_error),
        help("Verify that you have read and write permissions for this directory.")
    )]
    #[error("I/O operation failed: {0}")]
    IoError(#[from] std::io::Error),
}

When integrated into main.rs, miette will automatically format these errors with source code snippets, highlighted spans, and colorized help text reminiscent of the Rust compiler (rustc).


3. Immersive Terminal UIs with indicatif

Long-running network operations or heavy file system traversal should never leave the user staring at a frozen cursor. The indicatif crate makes adding multi-progress bars, spinners, and download meters trivial.

Let’s write a utility module to simulate a secure fetching sequence with progress tracking:

// src/progress.rs
use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;

pub async fn simulate_fetching_advisories() -> Result<(), crate::errors::AuditError> {
    let pb = ProgressBar::new(100);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")
            .unwrap()
            .progress_chars("#-"),
    );

    pb.set_message("Syncing vulnerability database...");

    for _ in 0..100 {
        tokio::time::sleep(Duration::from_millis(20)).await;
        pb.inc(1);
    }

    pb.finish_with_message("Database synchronized successfully!");
    Ok()
}

Wire this together in your main.rs entry point:

// src/main.rs
mod cli;
mod errors;
mod progress;

use clap::Parser;
use cli::{Cli, Commands};
use errors::AuditError;
use miette::Result;

#[tokio::main]
async fn main() -> Result<()> {
    let args = Cli::parse();

    // Verify manifest existence
    if !args.manifest_path.exists() {
        return Err(AuditError::ManifestNotFound {
            path: args.manifest_path.display().to_string(),
        }.into());
    }

    match args.command {
        Commands::Fetch { limit } => {
            println!("Fetching up to {} advisories...", limit);
            progress::simulate_fetching_advisories().await?;
        }
        Commands::Clean => {
            println!("Cleaning local caches...");
        }
    }

    Ok()
}

4. Writing Bulletproof Integration Tests

Unit tests are great, but for CLI tools, you want to verify that the compiled binary behaves correctly when executed with various flags, inputs, and environment variables. Rust makes writing integration tests inside the tests/ directory remarkably straightforward using assert_cmd and predicates.

Add test dependencies to Cargo.toml under [dev-dependencies]:

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

Now, create an integration test file tests/cli_integration.rs:

use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;

#[test]
fn test_missing_manifest_fails_gracefully() {
    let mut cmd = Command::cargo_bin("cargo-audit-stats").unwrap();
    
    cmd.arg("--manifest-path")
       .arg("non_existent_cargo.toml");

    cmd.assert()
       .failure()
       .stderr(predicate::str::contains("Could not find Cargo.toml"));
}

#[test]
fn test_fetch_subcommand_succeeds() {
    let dir = tempdir().unwrap();
    let manifest_path = dir.path().join("Cargo.toml");
    std::fs::write(&manifest_path, "[package]\nname = 'test'\nversion = '0.1.0'").unwrap();

    let mut cmd = Command::cargo_bin("cargo-audit-stats").unwrap();
    cmd.arg("--manifest-path")
       .arg(&manifest_path)
       .arg("fetch")
       .arg("--limit");

    cmd.assert()
       .success()
       .stdout(predicate::str::contains("Fetching up to 10 advisories").or(predicate::str::contains("Fetching up to 100 advisories")));
}

Run your test suite with:

cargo test

5. Automated Cross-Platform Releases via GitHub Actions

Building your CLI for Linux, macOS, and Windows locally is tedious. We can automate building optimized binaries, creating archives, and attaching them to GitHub Releases using GitHub Actions.

Create a workflow file at .github/workflows/release.yml:

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    name: Release - ${{ matrix.platform.os_name }}
    runs-on: ${{ matrix.platform.os }}
    strategy:
      matrix: 
        platform:
          - os: ubuntu-latest
            os_name: linux-x86_64
            target: x86_64-unknown-linux-gnu
            bin: cargo-audit-stats
          - os: macos-latest
            os_name: macos-x86_64
            target: x86_64-apple-darwin
            bin: cargo-audit-stats
          - os: macos-latest
            os_name: macos-aarch64
            target: aarch64-apple-darwin
            bin: cargo-audit-stats
          - os: windows-latest
            os_name: windows-x86_64
            target: x86_64-pc-windows-msvc
            bin: cargo-audit-stats.exe

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          target: ${{ matrix.platform.target }}

      - name: Build binary
        uses: actions-rs/cargo-build@v1
        with:
          use-cross: true
          command: build
          args: --release --target ${{ matrix.platform.target }}

      - name: Package binary
        shell: bash
        run: |
          staging="cargo-audit-stats-${{ matrix.platform.os_name }}"
          mkdir -p "$staging"
          
          if [ "${{ matrix.platform.os }}" = "windows-latest" ]; then
            cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}" "$staging/"
            7z a "$staging.zip" "$staging"
            echo "ASSET=$staging.zip" >> $GITHUB_ENV
          else
            cp "target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}" "$staging/"
            tar czf "$staging.tar.gz" "$staging"
            echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV
          fi

      - name: Upload Release Asset
        uses: softprops/action-gh-release@v1
        with:
          files: ${{ env.asset }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Tagging and Shipping

When you push a git tag matching v*, GitHub Actions will spin up isolated virtual machines for Linux, macOS, and Windows, compile release-optimized, statically linked binaries, pack them neatly into .tar.gz or .zip archives, and publish them directly to your repository’s Releases page.


Summary

Building command-line tools in Rust bridges the gap between script-level convenience and systems-level performance. By combining:

  1. clap for intuitive, self-documenting command-line interfaces,
  2. miette for compiler-grade diagnostic error reporting,
  3. indicatif for fluid, responsive terminal feedback,
  4. assert_cmd for bulletproof automated regression testing, and
  5. GitHub Actions for frictionless multi-target releases,

you can craft developer tooling that users love to invoke.

Now go forth, refactor your shell scripts into robust Rust binaries, and ship tools that feel like magic.

More posts