--- name: rust-cli-builder description: Plan and build production-ready Rust CLI tools using clap for argument parsing, with subcommands, config file support, colored output, and proper error handling. Uses interview-driven planning to clarify commands, input/output formats, and distribution strategy before writing any code. tags: [rust, cli, clap, terminal, command-line, devtools] --- # Rust CLI Tool Builder ## 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 ## Phase 1: Explore (Plan Mode) 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 ### 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 ## Phase 2: Interview (AskUserQuestion) 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 ``` ### 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 ``` ### 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 ``` ### 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 ``` ## Phase 3: Plan (ExitPlanMode) 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. ## Phase 4: Execute After approval, implement following this order: ### Step 1: Project setup (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" # Add based on interview: # thiserror = "2" # if typed errors # tokio = { version = "1", features = ["full"] } # if async # serde_json = "1" # if JSON output # toml = "0.8" # if TOML config # colored = "2" # if colored output # indicatif = "0.17" # if progress bars # dirs = "5" # if config file (~/.config/) ``` ### Step 2: CLI definition with clap derive ```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, #[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, }, /// Show project status Status, } #[derive(clap::ValueEnum, Clone, Debug)] pub enum OutputFormat { Text, Json, } ``` ### Step 3: Error handling ```rust // With anyhow (simple approach): use anyhow::{Context, Result}; fn load_config(path: &Path) -> Result { 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 ```rust use serde::Deserialize; use std::path::{Path, PathBuf}; #[derive(Deserialize, Debug, Default)] pub struct Config { pub default_template: Option, pub output_dir: Option, // ... fields from interview } impl Config { pub fn load(explicit_path: Option<&Path>) -> anyhow::Result { 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 ```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(&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 ```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 ```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(()) } ``` ### Step 8: Tests ```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 ``` ## Best practices ### Separate CLI from logic Keep clap structs and argument parsing in `cli.rs`. Put business logic in `commands/`. This makes the core logic testable without invoking the 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'`. ### Respect 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); } ``` ### Exit codes Use meaningful exit codes: 0 for success, 1 for general errors, 2 for usage errors (clap handles this automatically). ### Dev dependencies for testing ```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