RustCLI工具构建器Skill rust-cli-builder

这个技能用于帮助开发者使用Rust编程语言和clap库快速、高效地构建命令行界面工具。它提供从规划、设计到实现、测试的全流程指导,包括子命令支持、配置文件处理、彩色输出和错误处理。适用于CLI工具开发、后端工具构建、DevOps自动化等场景。关键词:Rust, CLI, clap, 命令行工具, 开发, 规划, 测试, 错误处理, 配置文件。

后端开发 0 次安装 0 次浏览 更新于 3/21/2026

name: rust-cli-builder description: 使用clap进行参数解析,规划并构建生产就绪的Rust CLI工具,支持子命令、配置文件、彩色输出和适当的错误处理。在编写任何代码之前,采用面试驱动的规划来明确命令、输入/输出格式和分发策略。 tags: [rust, cli, clap, terminal, command-line, devtools]

Rust CLI 工具构建器

何时使用

当您需要时使用此技能:

  • 使用clap从头开始搭建一个新的Rust CLI工具
  • 向现有的CLI应用程序添加子命令
  • 实现配置文件加载(TOML/JSON/YAML)
  • 使用anyhow/thiserror设置适当的错误处理
  • 添加彩色和格式化的终端输出
  • 构建用于通过cargo install或GitHub发布分发的CLI项目结构

阶段1:探索(计划模式)

进入计划模式。在编写任何代码之前,探索现有项目:

如果扩展现有项目

  • 找到Cargo.toml并检查当前依赖(clap版本、serde、tokio等)
  • 定位CLI入口点(src/main.rssrc/cli.rs
  • 检查clap是否使用派生宏或构建器模式
  • 识别现有的子命令结构
  • 查找现有的错误类型、配置结构和输出格式化
  • 检查是否有src/lib.rs将库逻辑与CLI分离

如果从头开始

  • 检查工作区是否有现有的Rust项目或工作区Cargo.toml
  • 查找带有自定义设置的.cargo/config.toml
  • 检查rust-toolchain.toml以了解目标Rust版本

阶段2:面试(AskUserQuestion)

使用AskUserQuestion澄清需求。分轮次提问。

第1轮:工具目的和命令

问题:"您正在构建什么样的CLI工具?"
标题:"工具类型"
选项:
  - "单命令(如ripgrep、curl)" — 一个主要操作带有标志和参数
  - "多命令(如git、cargo)" — 一个二进制下的多个子命令
  - "交互式REPL(如psql)" — 带有提示循环的持久会话
  - "管道工具(如jq、sed)" — 读取stdin,转换,写入stdout

问题:"工具将操作什么?"
标题:"输入"
选项:
  - "文件/目录" — 读取、处理或生成文件
  - "网络/API" — HTTP请求、TCP连接、API调用
  - "系统资源" — 进程、硬件信息、OS配置
  - "数据流(stdin/stdout)" — 管道友好的文本/二进制处理

第2轮:子命令(如果是多命令)

问题:"描述您需要的子命令(例如,'init'、'build'、'deploy')"
标题:"命令"
选项:
  - "2-3个子命令(我将描述它们)" — 小型专注工具
  - "4-8个子命令带分组" — 中型工具,可能需要命令分组
  - "我有一个粗略列表,帮助我设计API" — 协作命令设计

第3轮:配置和输出

问题:"工具应如何配置?"
标题:"配置"
选项:
  - "仅CLI标志(推荐)" — 所有配置通过命令行参数
  - "配置文件(TOML)" — 从~/.config/toolname/config.toml加载默认值
  - "配置文件 + CLI覆盖" — 配置文件用于默认值,标志覆盖特定值
  - "环境变量 + 标志" — 环境变量用于秘密,标志用于其他一切

问题:"工具需要什么输出格式?"
标题:"输出"
选项:
  - "人类可读(彩色文本)" — 带有颜色和格式化的漂亮终端输出
  - "机器可读(JSON)" — 结构化输出用于管道到其他工具
  - "两者(--format标志)" — 默认人类,--json或--format=json用于机器
  - "最小(仅退出代码)" — 成功/失败通过退出代码,错误到stderr

第4轮:异步和错误处理

问题:"工具需要异步操作吗?"
标题:"异步"
选项:
  - "否 — 同步即可(推荐)" — 文件I/O、计算、简单操作
  - "是 — tokio(网络I/O)" — HTTP请求、并发连接、异步文件I/O
  - "是 — tokio多线程" — 重度并行、多个并发任务

问题:"错误应如何呈现给用户?"
标题:"错误"
选项:
  - "简单消息(anyhow)(推荐)" — 人类可读的错误链,适用于大多数CLI
  - "类型化错误(thiserror)" — 自定义错误枚举,每个失败有特定变体
  - "两者(thiserror用于库,anyhow用于二进制)" — 库代码是类型化的,CLI用anyhow包装

阶段3:计划(ExitPlanMode)

编写一个具体的实施计划,涵盖:

  1. 项目结构Cargo.toml依赖、src/文件布局
  2. CLI定义 — 所有命令、参数和标志的clap派生结构
  3. 配置加载 — 配置文件格式和与CLI参数的合并策略
  4. 核心逻辑 — 每个子命令的主要函数,与CLI层分离
  5. 错误类型 — 错误枚举或anyhow使用,面向用户的错误消息
  6. 输出格式化 — 彩色输出、JSON模式、进度指示器
  7. 测试 — 核心逻辑的单元测试、CLI行为的集成测试

通过ExitPlanMode呈现供用户批准。

阶段4:执行

批准后,按此顺序实施:

步骤1:项目设置(Cargo.toml)

[package]
name = "toolname"
version = "0.1.0"
edition = "2021"
description = "工具的简短描述"

[dependencies]
clap = { version = "4", features = ["derive", "env"] }
serde = { version = "1", features = ["derive"] }
anyhow = "1"
# 根据面试添加:
# thiserror = "2"              # 如果使用类型化错误
# tokio = { version = "1", features = ["full"] }  # 如果异步
# serde_json = "1"             # 如果JSON输出
# toml = "0.8"                 # 如果TOML配置
# colored = "2"                # 如果彩色输出
# indicatif = "0.17"           # 如果进度条
# dirs = "5"                   # 如果配置文件(~/.config/)

步骤2:使用clap派生的CLI定义

use clap::{Parser, Subcommand};

/// 工具的单行简短描述
#[derive(Parser, Debug)]
#[command(name = "toolname", version, about, long_about = None)]
pub struct Cli {
    /// 增加详细程度(-v, -vv, -vvv)
    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
    pub verbose: u8,

    /// 输出格式
    #[arg(long, default_value = "text", global = true)]
    pub format: OutputFormat,

    /// 配置文件路径
    #[arg(long, global = true)]
    pub config: Option<std::path::PathBuf>,

    #[command(subcommand)]
    pub command: Commands,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// 初始化新项目
    Init {
        /// 项目名称
        name: String,

        /// 使用的模板
        #[arg(short, long, default_value = "default")]
        template: String,
    },

    /// 构建项目
    Build {
        /// 发布模式构建
        #[arg(short, long)]
        release: bool,

        /// 目标目录
        #[arg(short, long)]
        output: Option<std::path::PathBuf>,
    },

    /// 显示项目状态
    Status,
}

#[derive(clap::ValueEnum, Clone, Debug)]
pub enum OutputFormat {
    Text,
    Json,
}

步骤3:错误处理

// 使用anyhow(简单方法):
use anyhow::{Context, Result};

fn load_config(path: &Path) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config file: {}", path.display()))?;
    let config: Config = toml::from_str(&content)
        .context("Invalid TOML in config file")?;
    Ok(config)
}

// 使用thiserror(类型化方法):
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("Config file not found: {path}")]
    ConfigNotFound { path: std::path::PathBuf },

    #[error("Invalid config: {0}")]
    InvalidConfig(#[from] toml::de::Error),

    #[error("Network error: {0}")]
    Network(#[from] reqwest::Error),

    #[error("{0}")]
    Custom(String),
}

步骤4:配置文件加载

use serde::Deserialize;
use std::path::{Path, PathBuf};

#[derive(Deserialize, Debug, Default)]
pub struct Config {
    pub default_template: Option<String>,
    pub output_dir: Option<PathBuf>,
    // ... 来自面试的字段
}

impl Config {
    pub fn load(explicit_path: Option<&Path>) -> anyhow::Result<Self> {
        let path = match explicit_path {
            Some(p) => p.to_path_buf(),
            None => Self::default_path(),
        };

        if !path.exists() {
            return Ok(Config::default());
        }

        let content = std::fs::read_to_string(&path)?;
        let config: Config = toml::from_str(&content)?;
        Ok(config)
    }

    fn default_path() -> PathBuf {
        dirs::config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("toolname")
            .join("config.toml")
    }
}

步骤5:彩色输出和格式化

use colored::Colorize;

pub struct Output {
    format: OutputFormat,
    verbose: u8,
}

impl Output {
    pub fn new(format: OutputFormat, verbose: u8) -> Self {
        Self { format, verbose }
    }

    pub fn success(&self, msg: &str) {
        match self.format {
            OutputFormat::Text => eprintln!("{} {}", "✓".green().bold(), msg),
            OutputFormat::Json => {} // JSON输出仅到stdout
        }
    }

    pub fn error(&self, msg: &str) {
        match self.format {
            OutputFormat::Text => eprintln!("{} {}", "✗".red().bold(), msg),
            OutputFormat::Json => {
                let err = serde_json::json!({"error": msg});
                println!("{}", serde_json::to_string(&err).unwrap());
            }
        }
    }

    pub fn info(&self, msg: &str) {
        if self.verbose >= 1 {
            match self.format {
                OutputFormat::Text => eprintln!("{} {}", "ℹ".blue(), msg),
                OutputFormat::Json => {}
            }
        }
    }

    pub fn data<T: serde::Serialize>(&self, data: &T) {
        match self.format {
            OutputFormat::Text => {
                // 为人类漂亮打印 — 每个子命令自定义
                println!("{:#?}", data);
            }
            OutputFormat::Json => {
                println!("{}", serde_json::to_string_pretty(data).unwrap());
            }
        }
    }
}

步骤6:主要入口点

use clap::Parser;

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    let config = Config::load(cli.config.as_deref())?;
    let output = Output::new(cli.format.clone(), cli.verbose);

    match cli.command {
        Commands::Init { name, template } => {
            cmd_init(&name, &template, &config, &output)?;
        }
        Commands::Build { release, output_dir } => {
            let dir = output_dir
                .or(config.output_dir.clone())
                .unwrap_or_else(|| PathBuf::from("./dist"));
            cmd_build(release, &dir, &output)?;
        }
        Commands::Status => {
            cmd_status(&config, &output)?;
        }
    }

    Ok(())
}

// 如果异步(tokio):
// #[tokio::main]
// async fn main() -> anyhow::Result<()> { ... }

步骤7:子命令实施

fn cmd_init(name: &str, template: &str, config: &Config, out: &Output) -> anyhow::Result<()> {
    let template = if template == "default" {
        config.default_template.as_deref().unwrap_or("default")
    } else {
        template
    };

    out.info(&format!("Using template: {}", template));

    let project_dir = Path::new(name);
    if project_dir.exists() {
        anyhow::bail!("Directory '{}' already exists", name);
    }

    std::fs::create_dir_all(project_dir)?;
    // ... 基于模板搭建项目文件

    out.success(&format!("Created project '{}' with template '{}'", name, template));
    Ok(())
}

步骤8:测试

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_default() {
        let config = Config::default();
        assert!(config.default_template.is_none());
    }

    #[test]
    fn test_config_parse_toml() {
        let toml_str = r#"
            default_template = "react"
            output_dir = "./build"
        "#;
        let config: Config = toml::from_str(toml_str).unwrap();
        assert_eq!(config.default_template.unwrap(), "react");
    }
}

// 集成测试(tests/cli.rs):
use assert_cmd::Command;
use predicates::prelude::*;

#[test]
fn test_help_flag() {
    Command::cargo_bin("toolname")
        .unwrap()
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("Usage:"));
}

#[test]
fn test_version_flag() {
    Command::cargo_bin("toolname")
        .unwrap()
        .arg("--version")
        .assert()
        .success();
}

#[test]
fn test_init_creates_directory() {
    let dir = tempfile::tempdir().unwrap();
    let project_name = dir.path().join("test-project");

    Command::cargo_bin("toolname")
        .unwrap()
        .args(["init", project_name.to_str().unwrap()])
        .assert()
        .success();

    assert!(project_name.exists());
}

#[test]
fn test_init_existing_directory_fails() {
    let dir = tempfile::tempdir().unwrap();

    Command::cargo_bin("toolname")
        .unwrap()
        .args(["init", dir.path().to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("already exists"));
}

#[test]
fn test_json_output_format() {
    Command::cargo_bin("toolname")
        .unwrap()
        .args(["--format", "json", "status"])
        .assert()
        .success()
        .stdout(predicate::str::startsWith("{"));
}

项目结构参考

toolname/
├── Cargo.toml
├── src/
│   ├── main.rs          # 入口点,CLI解析,命令分发
│   ├── cli.rs           # Clap派生结构(Cli、Commands、Args)
│   ├── config.rs        # 配置文件加载和合并
│   ├── output.rs        # 输出格式化(文本/JSON/彩色)
│   ├── error.rs         # 错误类型(如果使用thiserror)
│   └── commands/
│       ├── mod.rs
│       ├── init.rs      # Init子命令逻辑
│       ├── build.rs     # Build子命令逻辑
│       └── status.rs    # Status子命令逻辑
└── tests/
    └── cli.rs           # 使用assert_cmd的集成测试

最佳实践

将CLI与逻辑分离

将clap结构和参数解析保持在cli.rs中。将业务逻辑放在commands/中。这使得核心逻辑可测试,无需调用CLI。

使用stderr处理状态,stdout处理数据

人类可读的消息(进度、成功、错误)发送到stderr。机器可读的数据发送到stdout。这允许用户干净地管道输出:toolname status --format json | jq '.items'

尊重NO_COLOR

检查NO_COLOR环境变量并在设置时禁用颜色:

if std::env::var("NO_COLOR").is_ok() {
    colored::control::set_override(false);
}

退出代码

使用有意义的退出代码:0表示成功,1表示一般错误,2表示使用错误(clap自动处理)。

测试的开发依赖

[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"

完成前检查清单

  • [ ] clap派生结构有文档注释(它们成为–help文本)
  • [ ] 所有子命令有简短和长描述
  • [ ] 配置文件有合理的默认值,并且在缺失时不会出错
  • [ ] --format json输出有效、可解析的JSON到stdout
  • [ ] 错误显示上下文(文件路径、出错原因、如何修复)
  • [ ] 集成测试验证CLI行为端到端
  • [ ] cargo clippy通过,无警告
  • [ ] 运行cargo fmt