
用 Rust 构建生产级 CLI 工具:clap、异步与跨平台打包
Rust 已成为构建 CLI 工具的最佳语言之一。其出色的启动时间、零成本抽象、丰富的生态系统(clap、indicatif、dialoguer)以及便捷的交叉编译能力,使其成为开发工具和系统实用程序的理想选择。本指南涵盖了从参数解析到分发跨平台二进制文件的完整流程。
项目设置
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"
使用 clap 进行参数解析
derive API 提供了一种简洁、声明式的方式来定义 CLI 接口:
use clap::{Parser, Subcommand, Args, ValueEnum};
#[derive(Parser, Debug)]
#[command(
name = "mytool",
version,
about = "一个生产级 CLI 工具",
long_about = "我的 CLI 工具 - 对文件和 API 执行出色的操作"
)]
struct Cli {
/// 配置文件路径
#[arg(short, long, env = "MYTOOL_CONFIG", global = true)]
config: Option<PathBuf>,
/// 增加详细程度 (-v, -vv, -vvv)
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
/// 输出格式
#[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(ProcessArgs),
/// 从 API 获取数据
Fetch(FetchArgs),
/// 交互式配置向导
Configure,
}
#[derive(Args, Debug)]
struct ProcessArgs {
/// 输入目录
#[arg(short, long)]
input: PathBuf,
/// 输出目录
#[arg(short, long)]
output: PathBuf,
/// 并行工作线程数
#[arg(short = 'j', long, default_value = "4")]
workers: usize,
/// 试运行 - 不写入输出
#[arg(long)]
dry_run: bool,
/// 要包含的文件扩展名
#[arg(long, num_args = 1..)]
extensions: Vec<String>,
}
#[derive(Args, Debug)]
struct FetchArgs {
/// API 端点 URL
url: String,
/// 认证令牌
#[arg(long, env = "MYTOOL_API_TOKEN")]
token: Option<String>,
/// 最大重试次数
#[arg(long, default_value = "3")]
retries: u32,
/// 请求超时时间(秒)
#[arg(long, default_value = "30")]
timeout: u64,
}
异步主入口点
use anyhow::{Context, Result};
use std::path::PathBuf;
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// 根据详细程度设置日志级别
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();
// 加载配置
let config = load_config(cli.config.as_deref()).await
.context("无法加载配置")?;
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,
}
}
配置文件管理
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!("找不到配置目录"))?;
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!("无法读取配置: {}", config_path.display()))?;
toml::from_str(&content)
.with_context(|| format!("无法解析配置: {}", 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(())
}
使用 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!("错误: {}", e));
errors += 1;
}
}
pb.finish_with_message(format!("完成!{} 个错误", errors));
Ok(())
}
交互式对话框
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
async fn configure_command() -> Result<()> {
println!("欢迎使用 mytool 配置向导");
let api_token: String = Password::with_theme(&ColorfulTheme::default())
.with_prompt("API 令牌")
.interact()?;
let default_workers: usize = Input::with_theme(&ColorfulTheme::default())
.with_prompt("默认工作线程数")
.default(4)
.validate_with(|input: &usize| {
if *input > 0 && *input <= 64 {
Ok(())
} else {
Err("工作线程数必须在 1 到 64 之间")
}
})
.interact()?;
let output_formats = vec!["text", "json", "table"];
let format_idx = Select::with_theme(&ColorfulTheme::default())
.with_prompt("默认输出格式")
.items(&output_formats)
.default(0)
.interact()?;
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("保存配置?")
.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!("配置已保存至 {}", config_path.display());
}
Ok(())
}
错误处理
use thiserror::Error;
#[derive(Error, Debug)]
enum CliError {
#[error("文件未找到: {path}")]
FileNotFound { path: PathBuf },
#[error("API 请求失败: {status} - {message}")]
ApiError { status: u16, message: String },
#[error("配置错误: {0}")]
Config(String),
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error),
}
// 使用 anyhow 进行应用级错误处理
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!(
"未提供 API 令牌。请使用 --token 或设置 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("发送请求失败")?;
if !response.status().is_success() {
anyhow::bail!("API 返回 {}: {}", 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(())
}
跨平台打包
为多个目标构建:
# 安装交叉编译工具链
cargo install cross
# 为 Linux 构建(从 macOS/Windows)
cross build --release --target x86_64-unknown-linux-gnu
# 为 Windows 构建
cross build --release --target x86_64-pc-windows-gnu
# 为 macOS ARM 构建
cargo build --release --target aarch64-apple-darwin
# 创建压缩发布包
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 发布工作流:
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*
测试 CLI 命令
#[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("一个生产级 CLI 工具"));
}
#[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();
}
}
结论
Rust 是 2026 年构建 CLI 工具的绝佳选择。clap derive API 生成美观、自文档化的参数解析器。Tokio 支持异步命令执行而不阻塞。indicatif 提供生产级进度报告。交叉编译使得分发到所有平台变得简单。结合 Rust 的编译时保证,您将获得快速、可靠且易于使用和维护的 CLI 工具。