第十七章:CLI工具开发——命令行的艺术
本章导读:命令行工具是程序员的瑞士军刀。Rust 凭借其高性能、零依赖可执行文件,成为构建 CLI 工具的理想选择。本章我们将学习如何使用
clap构建专业的命令行工具,从参数解析到输出美化,打造令人愉悦的命令行体验。
🛠️ 17.1 CLI 工具设计原则
📋 17.1.1 优秀的 CLI 工具特征
- 直观:
mytool input.txt -o output.txt比mytool --input=input.txt --output=output.txt更简洁 - 宽容:支持
-h和--help,提供有意义的错误提示 - 稳定:遵循 Unix 哲学,做好一件事
- 可组合:支持管道,与其他工具配合
📦 17.1.2 项目初始化
cargo new mycli
cd mycli
# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
colored = "2"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
🎯 17.2 使用 Clap
Clap 是 Rust 生态中最流行的命令行解析库。
📝 17.2.1 基本用法
use clap::Parser;
/// 一个简单的文件处理工具
#[derive(Parser, Debug)]
#[command(name = "mycli")]
#[command(about = "一个文件处理工具", long_about = None)]
struct Args {
/// 输入文件路径
#[arg(short, long)]
input: String,
/// 输出文件路径
#[arg(short, long)]
output: Option<String>,
/// 详细输出
#[arg(short, long)]
verbose: bool,
/// 并行度
#[arg(short = 'j', long, default_value = "4")]
jobs: usize,
}
fn main() {
let args = Args::parse();
println!("输入: {}", args.input);
println!("输出: {:?}", args.output);
println!("详细: {}", args.verbose);
println!("并行: {}", args.jobs);
}
运行效果:
$ mycli --help
一个简单的文件处理工具
Usage: mycli [OPTIONS] --input <INPUT>
Options:
-i, --input <INPUT> 输入文件路径
-o, --output <OUTPUT> 输出文件路径
-v, --verbose 详细输出
-j, --jobs <JOBS> 并行度 [default: 4]
-h, --help Print help
🌳 17.2.2 子命令
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "gitcli")]
#[command(about = "一个简单的 Git CLI", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// 添加文件到暂存区
Add {
/// 要添加的文件
#[arg(required = true)]
files: Vec<String>,
/// 添加所有文件
#[arg(short, long)]
all: bool,
},
/// 提交更改
Commit {
/// 提交消息
#[arg(short, long)]
message: String,
/// 修改上次提交
#[arg(short, long)]
amend: bool,
},
/// 查看状态
Status {
/// 短格式输出
#[arg(short, long)]
short: bool,
},
/// 推送到远程
Push {
/// 远程名称
#[arg(default_value = "origin")]
remote: String,
/// 强制推送
#[arg(short, long)]
force: bool,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { files, all } => {
if all {
println!("添加所有文件");
} else {
println!("添加文件: {:?}", files);
}
}
Commands::Commit { message, amend } => {
if amend {
println!("修改提交: {}", message);
} else {
println!("提交: {}", message);
}
}
Commands::Status { short } => {
if short {
println!("短格式状态");
} else {
println!("完整状态");
}
}
Commands::Push { remote, force } => {
if force {
println!("强制推送到 {}", remote);
} else {
println!("推送到 {}", remote);
}
}
}
}
🔄 17.2.3 参数验证
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
struct Args {
/// 输入文件(必须存在)
#[arg(short, long, value_parser = clap::value_parser!(PathBuf))]
input: PathBuf,
/// 数量范围 1-100
#[arg(short, long, value_parser = clap::value_parser!(u8).range(1..=100))]
count: u8,
/// 模式选择
#[arg(short, long, value_enum)]
mode: Mode,
}
#[derive(clap::ValueEnum, Clone, Debug)]
enum Mode {
Fast,
Normal,
Slow,
}
fn main() {
let args = Args::parse();
println!("输入: {:?}", args.input);
println!("数量: {}", args.count);
println!("模式: {:?}", args.mode);
}
🎨 17.3 美化输出
🌈 17.3.1 彩色输出
use colored::Colorize;
fn main() {
// 基本颜色
println!("{}", "红色文字".red());
println!("{}", "绿色文字".green());
println!("{}", "蓝色文字".blue());
println!("{}", "黄色文字".yellow());
// 样式组合
println!("{}", "粗体红色".red().bold());
println!("{}", "下划线绿色".green().underline());
println!("{}", "斜体蓝色".blue().italic());
// 背景色
println!("{}", "白底黑字".black().on_white());
println!("{}", "红底白字".white().on_red());
// 条件着色
let success = true;
let status = if success {
"成功".green()
} else {
"失败".red()
};
println!("状态: {}", status);
}
📊 17.3.2 进度条
use indicatif::{ProgressBar, ProgressStyle};
fn main() {
// 创建进度条
let pb = ProgressBar::new(100);
// 自定义样式
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
.unwrap()
.progress_chars("##-"));
// 模拟工作
for _ in 0..100 {
pb.inc(1);
std::thread::sleep(std::time::Duration::from_millis(50));
}
pb.finish_with_message("完成!");
}
📋 17.3.3 表格输出
use prettytable::{Table, Row, Cell, format};
fn main() {
// 创建表格
let mut table = Table::new();
// 设置格式
table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
// 添加标题
table.set_titles(Row::new(vec![
Cell::style("ID".to_string()).bold(),
Cell::style("名称".to_string()).bold(),
Cell::style("状态".to_string()).bold(),
]));
// 添加行
table.add_row(Row::new(vec![
Cell::new("1"),
Cell::new("任务A"),
Cell::new("完成").style_spec("Fg"),
]));
table.add_row(Row::new(vec![
Cell::new("2"),
Cell::new("任务B"),
Cell::new("进行中").style_spec("Fy"),
]));
table.add_row(Row::new(vec![
Cell::new("3"),
Cell::new("任务C"),
Cell::new("待开始").style_spec("Fr"),
]));
// 打印表格
table.printstd();
}
📁 17.4 文件操作
📂 17.4.1 读写文件
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
fn main() -> Result<()> {
let path = "example.txt";
// 写入文件
fs::write(path, "Hello, Rust CLI!")
.with_context(|| format!("无法写入文件: {}", path))?;
// 读取文件
let content = fs::read_to_string(path)
.with_context(|| format!("无法读取文件: {}", path))?;
println!("内容: {}", content);
Ok(())
}
🔍 17.4.2 遍历目录
use std::fs;
use std::path::Path;
fn list_files(dir: &Path, depth: usize) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let indent = " ".repeat(depth);
if path.is_dir() {
println!("{}📁 {}/", indent, path.file_name().unwrap().to_string_lossy());
list_files(&path, depth + 1);
} else {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
println!("{}📄 {} ({} bytes)", indent, path.file_name().unwrap().to_string_lossy(), size);
}
}
}
}
fn main() {
list_files(Path::new("."), 0);
}
📝 17.4.3 配置文件
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Default)]
struct Config {
name: String,
version: String,
settings: Settings,
}
#[derive(Serialize, Deserialize, Default)]
struct Settings {
verbose: bool,
output_dir: String,
}
impl Config {
fn load() -> Self {
let config_path = Self::config_path();
if config_path.exists() {
let content = fs::read_to_string(&config_path).unwrap_or_default();
toml::from_str(&content).unwrap_or_default()
} else {
Self::default()
}
}
fn save(&self) {
let config_path = Self::config_path();
let content = toml::to_string_pretty(self).unwrap_or_default();
let _ = fs::write(config_path, content);
}
fn config_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".mycli.toml")
}
}
fn main() {
let config = Config::load();
println!("配置: {:?}", config);
// 修改并保存
let mut config = Config::load();
config.name = "MyCLI".to_string();
config.save();
}
🔄 17.5 交互式输入
❓ 17.5.1 基本询问
use dialoguer::{Input, Confirm, Select, MultiSelect};
fn main() {
// 文本输入
let name: String = Input::new()
.with_prompt("请输入你的名字")
.default("Rustacean".to_string())
.interact_text()
.unwrap();
println!("你好, {}!", name);
// 确认
let proceed = Confirm::new()
.with_prompt("确认继续?")
.default(true)
.interact()
.unwrap();
if !proceed {
println!("已取消");
return;
}
// 单选
let items = vec!["选项A", "选项B", "选项C"];
let selection = Select::new()
.with_prompt("请选择一个选项")
.items(&items)
.default(0)
.interact()
.unwrap();
println!("你选择了: {}", items[selection]);
// 多选
let selections = MultiSelect::new()
.with_prompt("选择你喜欢的语言(空格选择,回车确认)")
.items(&["Rust", "Python", "JavaScript", "Go"])
.defaults(&[true, false, false, false])
.interact()
.unwrap();
println!("你选择了: {:?}", selections);
}
🔐 17.5.2 密码输入
use dialoguer::Password;
fn main() {
let password = Password::new()
.with_prompt("请输入密码")
.with_confirmation("确认密码", "密码不匹配")
.interact()
.unwrap();
println!("密码已设置(长度: {})", password.len());
}
🧪 17.6 实战:构建文件搜索工具
use clap::Parser;
use colored::Colorize;
use indicatif::ProgressBar;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Result;
/// 文件搜索工具
#[derive(Parser)]
#[command(name = "ff")]
#[command(about = "快速文件搜索工具")]
struct Args {
/// 搜索模式
pattern: String,
/// 搜索目录
#[arg(short, long, default_value = ".")]
dir: PathBuf,
/// 显示隐藏文件
#[arg(short = 'a', long)]
all: bool,
/// 忽略大小写
#[arg(short, long)]
ignore_case: bool,
/// 最大深度
#[arg(short = 'd', long, default_value = "10")]
max_depth: usize,
/// 显示文件大小
#[arg(short = 's', long)]
show_size: bool,
}
struct SearchResult {
path: PathBuf,
size: u64,
}
fn search(
dir: &Path,
pattern: &str,
args: &Args,
depth: usize,
results: &mut Vec<SearchResult>,
) {
if depth > args.max_depth {
return;
}
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let file_name = path.file_name().unwrap().to_string_lossy();
// 跳过隐藏文件
if !args.all && file_name.starts_with('.') {
continue;
}
// 检查匹配
let name_to_check = if args.ignore_case {
file_name.to_lowercase()
} else {
file_name.to_string()
};
let pattern_to_check = if args.ignore_case {
pattern.to_lowercase()
} else {
pattern.to_string()
};
if name_to_check.contains(&pattern_to_check) {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
results.push(SearchResult { path: path.clone(), size });
}
// 递归搜索目录
if path.is_dir() {
search(&path, pattern, args, depth + 1, results);
}
}
}
}
fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if size >= GB {
format!("{:.1}G", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.1}M", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.1}K", size as f64 / KB as f64)
} else {
format!("{}B", size)
}
}
fn main() -> Result<()> {
let args = Args::parse();
println!("{} \"{}\" in {}",
"Searching".green().bold(),
args.pattern,
args.dir.display()
);
println!();
let pb = ProgressBar::new_spinner();
pb.set_message("搜索中...");
let mut results = Vec::new();
search(&args.dir, &args.pattern, &args, 0, &mut results);
pb.finish_and_clear();
if results.is_empty() {
println!("{}", "未找到匹配文件".yellow());
} else {
println!("{} {}:", "Found".green().bold(), results.len());
for result in results {
if args.show_size {
println!(" {} ({})", result.path.display().to_string().cyan(), format_size(result.size).dimmed());
} else {
println!(" {}", result.path.display().to_string().cyan());
}
}
}
Ok(())
}
📦 17.7 发布与分发
🔨 17.7.1 优化编译
# Cargo.toml
[profile.release]
opt-level = 3 # 最高优化
lto = true # 链接时优化
codegen-units = 1 # 单代码生成单元
strip = true # 剥离符号
📋 17.7.2 跨平台编译
# 添加目标
rustup target add x86_64-pc-windows-gnu
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-apple-darwin
# 编译
cargo build --release --target x86_64-pc-windows-gnu
cargo build --release --target x86_64-unknown-linux-musl
📝 17.7.3 发布到 crates.io
# 登录
cargo login YOUR_API_KEY
# 发布
cargo publish
📝 本章小结
本章我们学习了 CLI 工具开发:
| 库 | 用途 |
|---|---|
clap | 命令行参数解析 |
colored | 彩色输出 |
indicatif | 进度条 |
dialoguer | 交互式输入 |
anyhow | 错误处理 |
关键原则:
- 遵循 Unix 哲学
- 提供友好的帮助信息
- 支持标准输入输出
- 美化输出但不花哨
动手实验:
- 构建一个
todoCLI 工具,支持添加、列表、完成、删除操作。- 实现一个 JSON 格式化工具,支持
jsonfmt file.json --indent 2。- 创建一个简单的 HTTP 服务器:
serve --port 8080 --dir ./public。- 为搜索工具添加正则表达式支持。