正在加载,请稍候…

Building Production CLI Tools with Rust: clap, async, and Cross-Platform Packaging

Build production CLI tools in Rust using clap derive API for argument parsing, Tokio for async commands, indicatif for progress bars, dialoguer for interactive prompts, and cross for multi-platform binary distribution.

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.