rust-cli-builder

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Rust CLI Tool Builder

Rust CLI 工具构建指南

When to use

适用场景

Use this skill when you need to:
  • Scaffold a new Rust CLI tool from scratch with clap
  • Add subcommands to an existing CLI application
  • Implement config file loading (TOML/JSON/YAML)
  • Set up proper error handling with anyhow/thiserror
  • Add colored and formatted terminal output
  • Structure a CLI project for distribution via cargo install or GitHub releases
当你需要以下功能时,可以使用本技能:
  • 基于clap从零搭建新的Rust CLI工具
  • 为现有CLI应用添加子命令
  • 实现配置文件加载(TOML/JSON/YAML格式)
  • 使用anyhow/thiserror搭建完善的错误处理机制
  • 添加彩色格式化终端输出
  • 规划CLI项目结构,支持通过cargo install或GitHub Releases进行分发

Phase 1: Explore (Plan Mode)

阶段1:调研(规划模式)

Enter plan mode. Before writing any code, explore the existing project:
进入规划模式。在编写代码前,先调研现有项目情况:

If extending an existing project

如果是扩展现有项目

  • Find
    Cargo.toml
    and check current dependencies (clap version, serde, tokio, etc.)
  • Locate the CLI entry point (
    src/main.rs
    or
    src/cli.rs
    )
  • Check if clap is using derive macros or builder pattern
  • Identify existing subcommand structure
  • Look for existing error types, config structs, and output formatting
  • Check if there's a
    src/lib.rs
    separating library logic from CLI
  • 找到
    Cargo.toml
    并检查当前依赖(clap版本、serde、tokio等)
  • 定位CLI入口文件(
    src/main.rs
    src/cli.rs
  • 检查clap是使用派生宏还是构建器模式
  • 识别现有的子命令结构
  • 查找已有的错误类型、配置结构体和输出格式化逻辑
  • 检查是否存在
    src/lib.rs
    将库逻辑与CLI层分离

If starting from scratch

如果是从零开始

  • Check the workspace for any existing Rust projects or workspace
    Cargo.toml
  • Look for a
    .cargo/config.toml
    with custom settings
  • Check for
    rust-toolchain.toml
    to know the target Rust edition
  • 检查工作区中是否有现有Rust项目或工作区
    Cargo.toml
  • 查找是否有包含自定义设置的
    .cargo/config.toml
  • 检查
    rust-toolchain.toml
    以确定目标Rust版本

Phase 2: Interview (AskUserQuestion)

阶段2:访谈(询问用户需求)

Use AskUserQuestion to clarify requirements. Ask in rounds.
通过询问用户问题来明确需求,分多轮进行。

Round 1: Tool purpose and commands

第一轮:工具用途与命令类型

Question: "What kind of CLI tool are you building?"
Header: "Tool type"
Options:
  - "Single command (like ripgrep, curl)" — One main action with flags and arguments
  - "Multi-command (like git, cargo)" — Multiple subcommands under one binary
  - "Interactive REPL (like psql)" — Persistent session with a prompt loop
  - "Pipeline tool (like jq, sed)" — Reads stdin, transforms, writes stdout

Question: "What will the tool operate on?"
Header: "Input"
Options:
  - "Files/directories" — Read, process, or generate files
  - "Network/API" — HTTP requests, TCP connections, API calls
  - "System resources" — Processes, hardware info, OS config
  - "Data streams (stdin/stdout)" — Pipe-friendly text/binary processing
问题:"你要构建的是哪种类型的CLI工具?"
标题:"工具类型"
选项:
  - "单命令工具(如ripgrep、curl)" — 一个主操作,搭配标志和参数
  - "多命令工具(如git、cargo)" — 一个二进制文件下包含多个子命令
  - "交互式REPL(如psql)" — 带提示循环的持久会话
  - "管道工具(如jq、sed)" — 读取标准输入、转换后写入标准输出

问题:"该工具将处理什么内容?"
标题:"输入类型"
选项:
  - "文件/目录" — 读取、处理或生成文件
  - "网络/API" — HTTP请求、TCP连接、API调用
  - "系统资源" — 进程、硬件信息、系统配置
  - "数据流(标准输入/输出)" — 支持管道的文本/二进制处理

Round 2: Subcommands (if multi-command)

第二轮:子命令(针对多命令工具)

Question: "Describe the subcommands you need (e.g., 'init', 'build', 'deploy')"
Header: "Commands"
Options:
  - "2-3 subcommands (I'll describe them)" — Small focused tool
  - "4-8 subcommands with groups" — Medium tool, may need command groups
  - "I have a rough list, help me design the API" — Collaborative command design
问题:"描述你需要的子命令(例如:'init'、'build'、'deploy')"
标题:"命令规划"
选项:
  - "2-3个子命令(我会详细描述)" — 小型聚焦工具
  - "4-8个带分组的子命令" — 中型工具,可能需要命令分组
  - "我有大致列表,帮忙设计API" — 协作式命令设计

Round 3: Configuration and output

第三轮:配置与输出

Question: "How should the tool be configured?"
Header: "Config"
Options:
  - "CLI flags only (Recommended)" — All config via command-line arguments
  - "Config file (TOML)" — Load defaults from ~/.config/toolname/config.toml
  - "Config file + CLI overrides" — Config file for defaults, flags override specific values
  - "Environment variables + flags" — Env vars for secrets, flags for everything else

Question: "What output format does the tool need?"
Header: "Output"
Options:
  - "Human-readable (colored text)" — Pretty terminal output with colors and formatting
  - "Machine-readable (JSON)" — Structured output for piping to other tools
  - "Both (--format flag)" — Default human, --json or --format=json for machines
  - "Minimal (exit codes only)" — Success/failure via exit code, errors to stderr
问题:"工具应如何配置?"
标题:"配置方式"
选项:
  - "仅CLI标志(推荐)" — 所有配置通过命令行参数完成
  - "配置文件(TOML格式)" — 从~/.config/toolname/config.toml加载默认值
  - "配置文件 + CLI覆盖" — 配置文件提供默认值,标志覆盖特定值
  - "环境变量 + 标志" — 环境变量存储密钥,标志处理其他配置

问题:"工具需要哪种输出格式?"
标题:"输出格式"
选项:
  - "人类可读格式(彩色文本)" — 美观的终端彩色格式化输出
  - "机器可读格式(JSON)" — 结构化输出,可传递给其他工具
  - "两者兼具(--format标志)" — 默认人类可读格式,使用--json或--format=json切换为机器格式
  - "极简格式(仅退出码)" — 通过退出码表示成功/失败,错误信息输出到标准错误流

Round 4: Async and error handling

第四轮:异步与错误处理

Question: "Does the tool need async operations?"
Header: "Async"
Options:
  - "No — synchronous is fine (Recommended)" — File I/O, computation, simple operations
  - "Yes — tokio (network I/O)" — HTTP requests, concurrent connections, async file I/O
  - "Yes — tokio multi-threaded" — Heavy parallelism, multiple concurrent tasks

Question: "How should errors be presented to users?"
Header: "Errors"
Options:
  - "Simple messages (anyhow) (Recommended)" — Human-readable error chains, good for most CLIs
  - "Typed errors (thiserror)" — Custom error enum with specific variants for each failure
  - "Both (thiserror for lib, anyhow for bin)" — Library code is typed, CLI wraps with anyhow
问题:"工具是否需要异步操作?"
标题:"异步支持"
选项:
  - "不需要 — 同步即可(推荐)" — 文件I/O、计算、简单操作
  - "需要 — tokio(网络I/O)" — HTTP请求、并发连接、异步文件I/O
  - "需要 — tokio多线程" — 重度并行处理、多并发任务

问题:"应如何向用户展示错误信息?"
标题:"错误展示"
选项:
  - "简单消息(anyhow)(推荐)" — 人类可读的错误链,适用于大多数CLI
  - "类型化错误(thiserror)" — 自定义错误枚举,为每种失败场景定义特定变体
  - "两者兼具(库用thiserror,二进制文件用anyhow)" — 库代码使用类型化错误,CLI层用anyhow包装

Phase 3: Plan (ExitPlanMode)

阶段3:规划(退出规划模式)

Write a concrete implementation plan covering:
  1. Project structure
    Cargo.toml
    dependencies,
    src/
    file layout
  2. CLI definition — clap derive structs for all commands, args, and flags
  3. Config loading — config file format and merge strategy with CLI args
  4. Core logic — main functions for each subcommand, separated from CLI layer
  5. Error types — error enum or anyhow usage, user-facing error messages
  6. Output formatting — colored output, JSON mode, progress indicators
  7. Tests — unit tests for core logic, integration tests for CLI behavior
Present via ExitPlanMode for user approval.
编写具体的实现规划,涵盖以下内容:
  1. 项目结构
    Cargo.toml
    依赖、
    src/
    目录布局
  2. CLI定义 — 所有命令、参数和标志对应的clap派生结构体
  3. 配置加载 — 配置文件格式以及与CLI参数的合并策略
  4. 核心逻辑 — 每个子命令的主函数,与CLI层分离
  5. 错误类型 — 错误枚举或anyhow的使用方式、面向用户的错误消息
  6. 输出格式化 — 彩色输出、JSON模式、进度指示器
  7. 测试 — 核心逻辑的单元测试、CLI行为的集成测试
通过退出规划模式提交规划,等待用户确认。

Phase 4: Execute

阶段4:执行

After approval, implement following this order:
获得批准后,按照以下顺序实现:

Step 1: Project setup (Cargo.toml)

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

toml
[package]
name = "toolname"
version = "0.1.0"
edition = "2021"
description = "Short description of the tool"

[dependencies]
clap = { version = "4", features = ["derive", "env"] }
serde = { version = "1", features = ["derive"] }
anyhow = "1"
toml
[package]
name = "toolname"
version = "0.1.0"
edition = "2021"
description = "Short description of the tool"

[dependencies]
clap = { version = "4", features = ["derive", "env"] }
serde = { version = "1", features = ["derive"] }
anyhow = "1"

Add based on interview:

根据访谈结果添加:

thiserror = "2" # if typed errors

thiserror = "2" # 如果使用类型化错误

tokio = { version = "1", features = ["full"] } # if async

tokio = { version = "1", features = ["full"] } # 如果需要异步

serde_json = "1" # if JSON output

serde_json = "1" # 如果需要JSON输出

toml = "0.8" # if TOML config

toml = "0.8" # 如果需要TOML配置

colored = "2" # if colored output

colored = "2" # 如果需要彩色输出

indicatif = "0.17" # if progress bars

indicatif = "0.17" # 如果需要进度条

dirs = "5" # if config file (~/.config/)

dirs = "5" # 如果需要加载配置文件(~/.config/)

undefined
undefined

Step 2: CLI definition with clap derive

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

rust
use clap::{Parser, Subcommand};

/// Short one-line description of the tool
#[derive(Parser, Debug)]
#[command(name = "toolname", version, about, long_about = None)]
pub struct Cli {
    /// Increase verbosity (-v, -vv, -vvv)
    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
    pub verbose: u8,

    /// Output format
    #[arg(long, default_value = "text", global = true)]
    pub format: OutputFormat,

    /// Path to config file
    #[arg(long, global = true)]
    pub config: Option<std::path::PathBuf>,

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

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Initialize a new project
    Init {
        /// Project name
        name: String,

        /// Template to use
        #[arg(short, long, default_value = "default")]
        template: String,
    },

    /// Build the project
    Build {
        /// Build in release mode
        #[arg(short, long)]
        release: bool,

        /// Target directory
        #[arg(short, long)]
        output: Option<std::path::PathBuf>,
    },

    /// Show project status
    Status,
}

#[derive(clap::ValueEnum, Clone, Debug)]
pub enum OutputFormat {
    Text,
    Json,
}
rust
use clap::{Parser, Subcommand};

/// Short one-line description of the tool
#[derive(Parser, Debug)]
#[command(name = "toolname", version, about, long_about = None)]
pub struct Cli {
    /// Increase verbosity (-v, -vv, -vvv)
    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
    pub verbose: u8,

    /// Output format
    #[arg(long, default_value = "text", global = true)]
    pub format: OutputFormat,

    /// Path to config file
    #[arg(long, global = true)]
    pub config: Option<std::path::PathBuf>,

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

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Initialize a new project
    Init {
        /// Project name
        name: String,

        /// Template to use
        #[arg(short, long, default_value = "default")]
        template: String,
    },

    /// Build the project
    Build {
        /// Build in release mode
        #[arg(short, long)]
        release: bool,

        /// Target directory
        #[arg(short, long)]
        output: Option<std::path::PathBuf>,
    },

    /// Show project status
    Status,
}

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

Step 3: Error handling

步骤3:错误处理

rust
// With anyhow (simple approach):
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)
}

// With thiserror (typed approach):
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),
}
rust
// With anyhow (simple approach):
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)
}

// With thiserror (typed approach):
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),
}

Step 4: Config file loading

步骤4:配置文件加载

rust
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>,
    // ... fields from interview
}

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")
    }
}
rust
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")
    }
}

Step 5: Colored output and formatting

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

rust
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 output goes to stdout only
        }
    }

    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 => {
                // Pretty print for humans — customize per subcommand
                println!("{:#?}", data);
            }
            OutputFormat::Json => {
                println!("{}", serde_json::to_string_pretty(data).unwrap());
            }
        }
    }
}
rust
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 output goes to stdout only
        }
    }

    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 => {
                // Pretty print for humans — customize per subcommand
                println!("{:#?}", data);
            }
            OutputFormat::Json => {
                println!("{}", serde_json::to_string_pretty(data).unwrap());
            }
        }
    }
}

Step 6: Main entry point

步骤6:主入口点

rust
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(())
}

// If async (tokio):
// #[tokio::main]
// async fn main() -> anyhow::Result<()> { ... }
rust
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(())
}

// If async (tokio):
// #[tokio::main]
// async fn main() -> anyhow::Result<()> { ... }

Step 7: Subcommand implementations

步骤7:子命令实现

rust
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)?;
    // ... scaffold project files based on template

    out.success(&format!("Created project '{}' with template '{}'", name, template));
    Ok(())
}
rust
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(())
}

Step 8: Tests

步骤8:测试

rust
#[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");
    }
}

// Integration tests (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::starts_with("{"));
}
rust
#[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");
    }
}

// Integration tests (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::starts_with("{"));
}

Project structure reference

项目结构参考

toolname/
├── Cargo.toml
├── src/
│   ├── main.rs          # Entry point, CLI parsing, command dispatch
│   ├── cli.rs           # Clap derive structs (Cli, Commands, Args)
│   ├── config.rs        # Config file loading and merging
│   ├── output.rs        # Output formatting (text/JSON/colored)
│   ├── error.rs         # Error types (if using thiserror)
│   └── commands/
│       ├── mod.rs
│       ├── init.rs      # Init subcommand logic
│       ├── build.rs     # Build subcommand logic
│       └── status.rs    # Status subcommand logic
└── tests/
    └── cli.rs           # Integration tests with assert_cmd
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的集成测试

Best practices

最佳实践

Separate CLI from logic

分离CLI与核心逻辑

Keep clap structs and argument parsing in
cli.rs
. Put business logic in
commands/
. This makes the core logic testable without invoking the CLI.
将clap结构体和参数解析放在
cli.rs
中,业务逻辑放在
commands/
目录下。这样核心逻辑无需调用CLI即可进行测试。

Use stderr for status, stdout for data

标准错误流输出状态,标准输出流输出数据

Human-readable messages (progress, success, errors) go to
stderr
. Machine-readable data goes to
stdout
. This lets users pipe output cleanly:
toolname status --format json | jq '.items'
.
人类可读的消息(进度、成功、错误)输出到
stderr
,机器可读的数据输出到
stdout
。这样用户可以干净地管道输出:
toolname status --format json | jq '.items'

Respect NO_COLOR

尊重NO_COLOR环境变量

Check the
NO_COLOR
environment variable and disable colors when set:
rust
if std::env::var("NO_COLOR").is_ok() {
    colored::control::set_override(false);
}
检查
NO_COLOR
环境变量,若已设置则禁用彩色输出:
rust
if std::env::var("NO_COLOR").is_ok() {
    colored::control::set_override(false);
}

Exit codes

退出码

Use meaningful exit codes: 0 for success, 1 for general errors, 2 for usage errors (clap handles this automatically).
使用有意义的退出码:0表示成功,1表示通用错误,2表示使用错误(clap会自动处理)。

Dev dependencies for testing

测试用开发依赖

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

Checklist before finishing

完成前检查清单

  • clap
    derive structs have doc comments (they become --help text)
  • All subcommands have short and long descriptions
  • Config file has sensible defaults and doesn't error when missing
  • --format json
    outputs valid, parseable JSON to stdout
  • Errors show context (file paths, what went wrong, how to fix it)
  • Integration tests verify CLI behavior end-to-end
  • cargo clippy
    passes with no warnings
  • cargo fmt
    has been run
  • clap
    派生结构体包含文档注释(会成为--help文本)
  • 所有子命令都有简短和详细描述
  • 配置文件有合理的默认值,不存在时不会报错
  • --format json
    输出合法、可解析的JSON到标准输出流
  • 错误信息包含上下文(文件路径、问题原因、修复方法)
  • 集成测试端到端验证CLI行为
  • cargo clippy
    检查无警告
  • 已运行
    cargo fmt
    格式化代码