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:
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:
clapfor intuitive, self-documenting command-line interfaces,miettefor compiler-grade diagnostic error reporting,indicatiffor fluid, responsive terminal feedback,assert_cmdfor bulletproof automated regression testing, and- 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.