Building Production CLI Tools with Rust: clap, async, and Cross-Platform Packaging
Rust has become one of the best languages for building CLI tools. The combination of excellent startup time, zero-cost abstractions, rich ecosystem (clap, indicatif, dialoguer), and easy cross-compilation makes it ideal for developer tools and system utilities. This guide covers the full journey from argument parsing to distributing cross-platform binaries.
Project Setup
cargo new my-cli-tool
cd my-cli-tool
Cargo.toml:
[package]
name = "my-cli-tool"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "mytool"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
indicatif = "0.17"
console = "0.15"
dialoguer = "0.11"
dirs = "5"
Argument Parsing with clap
The derive API provides a clean, declarative way to define CLI interfaces:
use clap::{Parser, Subcommand, Args, ValueEnum};
#[derive(Parser, Debug)]
#[command(
name = "mytool",
version,
about = "A production-grade CLI tool",
long_about = "My CLI tool - does amazing things with files and APIs"
)]
struct Cli {
/// Configuration file path
#[arg(short, long, env = "MYTOOL_CONFIG", global = true)]
config: Option<PathBuf>,
/// Increase verbosity (-v, -vv, -vvv)
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
/// Output format
#[arg(long, default_value = "text", global = true)]
format: OutputFormat,
#[command(subcommand)]
command: Commands,
}
#[derive(ValueEnum, Debug, Clone)]
enum OutputFormat {
Text,
Json,
Table,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Process files in a directory
Process(ProcessArgs),
/// Fetch data from an API
Fetch(FetchArgs),
/// Interactive configuration wizard
Configure,
}
#[derive(Args, Debug)]
struct ProcessArgs {
/// Input directory
#[arg(short, long)]
input: PathBuf,
/// Output directory
#[arg(short, long)]
output: PathBuf,
/// Number of parallel workers
#[arg(short = 'j', long, default_value = "4")]
workers: usize,
/// Dry run - don't write output
#[arg(long)]
dry_run: bool,
/// File extensions to include
#[arg(long, num_args = 1..)]
extensions: Vec<String>,
}
#[derive(Args, Debug)]
struct FetchArgs {
/// API endpoint URL
url: String,
/// Authentication token
#[arg(long, env = "MYTOOL_API_TOKEN")]
token: Option<String>,
/// Maximum retries
#[arg(long, default_value = "3")]
retries: u32,
/// Request timeout in seconds
#[arg(long, default_value = "30")]
timeout: u64,
}
Main Entry Point with Async
use anyhow::{Context, Result};
use std::path::PathBuf;
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Setup logging based on verbosity
let log_level = match cli.verbose {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(log_level))
.init();
// Load config
let config = load_config(cli.config.as_deref()).await
.context("Failed to load configuration")?;
match cli.command {
Commands::Process(args) => process_command(args, &config, cli.format).await,
Commands::Fetch(args) => fetch_command(args, &config, cli.format).await,
Commands::Configure => configure_command().await,
}
}
Configuration File Management
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Serialize, Deserialize, Default)]
struct Config {
api_token: Option<String>,
default_workers: Option<usize>,
output_dir: Option<PathBuf>,
endpoints: Vec<String>,
}
async fn load_config(path: Option<&Path>) -> Result<Config> {
let config_path = match path {
Some(p) => p.to_path_buf(),
None => {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
config_dir.join("mytool").join("config.toml")
}
};
if !config_path.exists() {
return Ok(Config::default());
}
let content = tokio::fs::read_to_string(&config_path)
.await
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
toml::from_str(&content)
.with_context(|| format!("Failed to parse config: {}", config_path.display()))
}
async fn save_config(config: &Config, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let content = toml::to_string_pretty(config)?;
tokio::fs::write(path, content).await?;
Ok(())
}
Progress Bars with indicatif
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::time::Duration;
async fn process_command(args: ProcessArgs, config: &Config, format: OutputFormat) -> Result<()> {
let files = collect_files(&args.input, &args.extensions).await?;
let mp = MultiProgress::new();
let pb = mp.add(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("#>-"),
);
pb.enable_steady_tick(Duration::from_millis(100));
let semaphore = Arc::new(tokio::sync::Semaphore::new(args.workers));
let pb_clone = pb.clone();
let handles: Vec<_> = files
.into_iter()
.map(|file| {
let sem = Arc::clone(&semaphore);
let out_dir = args.output.clone();
let pb = pb_clone.clone();
tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
let result = process_file(&file, &out_dir).await;
pb.inc(1);
result
})
})
.collect();
let mut errors = 0;
for handle in handles {
if let Err(e) = handle.await? {
pb.println(format!("Error: {}", e));
errors += 1;
}
}
pb.finish_with_message(format!("Done! {} errors", errors));
Ok(())
}
Interactive Dialogs
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
async fn configure_command() -> Result<()> {
println!("Welcome to mytool configuration wizard");
let api_token: String = Password::with_theme(&ColorfulTheme::default())
.with_prompt("API Token")
.interact()?;
let default_workers: usize = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Default number of workers")
.default(4)
.validate_with(|input: &usize| {
if *input > 0 && *input <= 64 {
Ok(())
} else {
Err("Workers must be between 1 and 64")
}
})
.interact()?;
let output_formats = vec!["text", "json", "table"];
let format_idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Default output format")
.items(&output_formats)
.default(0)
.interact()?;
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Save configuration?")
.default(true)
.interact()?;
if confirmed {
let config = Config {
api_token: Some(api_token),
default_workers: Some(default_workers),
..Default::default()
};
let config_path = dirs::config_dir()
.unwrap()
.join("mytool")
.join("config.toml");
save_config(&config, &config_path).await?;
println!("Configuration saved to {}", config_path.display());
}
Ok(())
}
Error Handling
use thiserror::Error;
#[derive(Error, Debug)]
enum CliError {
#[error("File not found: {path}")]
FileNotFound { path: PathBuf },
#[error("API request failed: {status} - {message}")]
ApiError { status: u16, message: String },
#[error("Configuration error: {0}")]
Config(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
// Use anyhow for application-level error handling
async fn fetch_command(args: FetchArgs, config: &Config, format: OutputFormat) -> Result<()> {
let token = args.token
.or_else(|| config.api_token.clone())
.ok_or_else(|| anyhow::anyhow!(
"No API token provided. Use --token or set MYTOOL_API_TOKEN"
))?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(args.timeout))
.build()?;
let response = client
.get(&args.url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.context("Failed to send request")?;
if !response.status().is_success() {
anyhow::bail!("API returned {}: {}", response.status(), response.text().await?);
}
let data: serde_json::Value = response.json().await?;
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&data)?),
OutputFormat::Text => println!("{:#?}", data),
OutputFormat::Table => print_table(&data),
}
Ok(())
}
Cross-Platform Packaging
Build for multiple targets:
# Install cross-compilation toolchain
cargo install cross
# Build for Linux (from macOS/Windows)
cross build --release --target x86_64-unknown-linux-gnu
# Build for Windows
cross build --release --target x86_64-pc-windows-gnu
# Build for macOS ARM
cargo build --release --target aarch64-apple-darwin
# Create compressed releases
tar czf mytool-linux-x86_64.tar.gz -C target/x86_64-unknown-linux-gnu/release mytool
zip mytool-windows-x86_64.zip target/x86_64-pc-windows-gnu/release/mytool.exe
GitHub Actions release workflow:
name: Release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: mytool-linux-x86_64
- os: macos-latest
target: aarch64-apple-darwin
artifact: mytool-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: mytool-windows-x86_64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- run: cargo build --release --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/mytool*
Testing CLI Commands
#[cfg(test)]
mod tests {
use assert_cmd::Command;
use predicates::str::contains;
#[test]
fn test_help() {
Command::cargo_bin("mytool")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(contains("A production-grade CLI tool"));
}
#[test]
fn test_version() {
Command::cargo_bin("mytool")
.unwrap()
.arg("--version")
.assert()
.success()
.stdout(contains(env!("CARGO_PKG_VERSION")));
}
#[tokio::test]
async fn test_process_command() {
let input_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
Command::cargo_bin("mytool")
.unwrap()
.args(["process", "-i", input_dir.path().to_str().unwrap(),
"-o", output_dir.path().to_str().unwrap()])
.assert()
.success();
}
}
Conclusion
Rust is an exceptional choice for CLI tools in 2026. The clap derive API produces beautiful, self-documenting argument parsers. Tokio enables async command execution without blocking. indicatif provides production-quality progress reporting. Cross-compilation makes distributing to all platforms straightforward. Together with Rust's compile-time guarantees, you get CLI tools that are fast, reliable, and a pleasure to use and maintain.