第十七章:CLI工具开发——命令行的艺术

第十七章:CLI工具开发——命令行的艺术

本章导读:命令行工具是程序员的瑞士军刀。Rust 凭借其高性能、零依赖可执行文件,成为构建 CLI 工具的理想选择。本章我们将学习如何使用 clap 构建专业的命令行工具,从参数解析到输出美化,打造令人愉悦的命令行体验。


🛠️ 17.1 CLI 工具设计原则

📋 17.1.1 优秀的 CLI 工具特征

  • 直观mytool input.txt -o output.txtmytool --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 哲学
  • 提供友好的帮助信息
  • 支持标准输入输出
  • 美化输出但不花哨

动手实验

  1. 构建一个 todo CLI 工具,支持添加、列表、完成、删除操作。
  2. 实现一个 JSON 格式化工具,支持 jsonfmt file.json --indent 2
  3. 创建一个简单的 HTTP 服务器:serve --port 8080 --dir ./public
  4. 为搜索工具添加正则表达式支持。
← 返回目录