Rust CLI

Use clap (derive or builder), anyhow/thiserror, and stable exit codes for scriptable CLIs. This page covers flow → clap → errors & I/O → CI → skeleton lab (rust-page).

The SKILL defines subcommand layout, global flags (verbose, config), stdin/stdout rules, and TTY detection; human errors on stderr; optional JSON Lines for machines.

Document binary size and MSRV limits if any; use Cargo.toml features and optional deps to slim default builds.

Match CI: cargo fmt, clippy -D warnings, cross-platform paths (std::path), and Windows console encoding notes.

  • Config: precedence/merge for XDG dirs or in-repo .config.
  • Testing: assert_cmd, golden files, temp-dir fixtures.
  • Release: cargo-dist or similar for package name, version, changelog snippets.

CLI main flow (skill-flow-block)

  [ argv / env / config file ]
        │
        ▼
  ┌─────────────┐     clap: parse → validate types & mutex flags
  │  Parse args  │──── fail: stderr usage + non-zero (often 2)
  └─────────────┘
        │
        ▼
  ┌─────────────┐     business logic; map I/O to stable codes
  │ Run command  │──── logs/progress: human on TTY, minimal ANSI when piped
  └─────────────┘
        │
        ▼
  ┌─────────────┐     ok: stdout (or code 0 only); err: stderr + contract code
  │ Output/exit  │──── scriptable: --json, stable column order
  └─────────────┘

Decide success output surface and whether failures are machine-parseable before wiring subcommands; avoid dumping stacks to stderr by default (use RUST_BACKTRACE in release debugging).

clap: subcommands, flags, help

derive: fast iteration and agent generation; encode constraints with #[command(about, long_about)], value_parser, default_value_t.

builder: clearer for dynamic subcommands, localized help, or runtime-built args; if mixed with derive, keep one Command entry—no duplicate parse paths.

  • Globals: global = true for -v/--verbose, --config, visible in subcommand help.
  • POSIX: document - as stdin/stdout in the SKILL.
  • after_help / before_help: examples and common script snippets.
// Cargo.toml dependencies
// [dependencies]
// clap = { version = "4", features = ["derive"] }
// anyhow = "1"
// thiserror = "1"
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"

use clap::{Parser, Subcommand, Args};

/// File processing tool -- POSIX friendly, supports stdout output (-)
#[derive(Parser)]
#[command(
    name = "filetool",
    version,
    about = "File processing tool",
    after_help = "Examples:
  filetool convert --format json input.csv
  filetool stats -v ./data/"
)]
struct Cli {
    /// Global verbose flag (visible to all subcommands)
    #[arg(short, long, global = true)]
    verbose: bool,

    /// Config file path (default ~/.config/filetool/config.toml)
    #[arg(long, global = true, value_name = "FILE")]
    config: Option,

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

#[derive(Subcommand)]
enum Commands {
    /// Convert file format
    Convert(ConvertArgs),
    /// Show directory statistics
    Stats {
        /// Directory path
        #[arg(default_value = ".")]
        path: std::path::PathBuf,
        /// Recursion depth
        #[arg(short, long, default_value_t = 1)]
        depth: u32,
    },
}

#[derive(Args)]
struct ConvertArgs {
    /// Input file (- for stdin)
    input: String,
    /// Output format
    #[arg(short, long, value_parser = ["json", "csv", "toml"])]
    format: String,
    /// Output file (- for stdout, default)
    #[arg(short, long, default_value = "-")]
    output: String,
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    match &cli.command {
        Commands::Convert(args) => run_convert(args, cli.verbose),
        Commands::Stats { path, depth } => run_stats(path, *depth, cli.verbose),
    }
}

fn run_convert(args: &ConvertArgs, verbose: bool) -> anyhow::Result<()> {
    if verbose {
        eprintln!("[verbose] converting {} -> {}", args.input, args.format);
    }
    Ok(())
}

fn run_stats(path: &std::path::Path, depth: u32, _verbose: bool) -> anyhow::Result<()> {
    anyhow::ensure!(path.exists(), "path not found: {}", path.display());
    println!("stats: {:?} depth={}", path, depth);
    Ok(())
}

Errors, exit codes, stderr/stdout

anyhow

  • Binary top: main() -> anyhow::Result<()> with .context() / with_context().
  • Good for “tell the user a paragraph”; not a stable error taxonomy.

thiserror

  • Library/test boundaries: variants map to reasons; implement std::process::Termination or map to exit codes.
  • Separate from clap::error::ErrorKind: usage errors are not generic I/O failures.

Document: 0 success, 1 generic failure, 2 usage/clap parse (common convention), domain codes (e.g. “needs login”)—and surface them in --help or README.

// anyhow/thiserror error chain + serde JSON serialization example
use thiserror::Error;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Programmatically classifiable error types (thiserror)
#[derive(Debug, Error)]
pub enum AppError {
    #[error("config file read failed: {path}")]
    ConfigRead { path: PathBuf, #[source] source: std::io::Error },

    #[error("JSON parse failed: {0}")]
    JsonParse(#[from] serde_json::Error),

    #[error("authentication failed, please log in first")]
    Unauthorized,
}

impl AppError {
    /// Map to POSIX exit codes: 0=success, 1=generic failure, 2=usage, 3=auth
    pub fn exit_code(&self) -> i32 {
        match self {
            AppError::Unauthorized => 3,
            _ => 1,
        }
    }
}

/// serde JSON serialization/deserialization
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
    pub api_url: String,
    pub timeout_secs: u64,
    #[serde(default = "default_retries")]
    pub max_retries: u32,
}

fn default_retries() -> u32 { 3 }

pub fn load_config(path: &PathBuf) -> Result<Config, AppError> {
    let data = std::fs::read_to_string(path)
        .map_err(|e| AppError::ConfigRead { path: path.clone(), source: e })?;
    let cfg: Config = serde_json::from_str(&data)?;
    Ok(cfg)
}

fn main() {
    if let Err(e) = run() {
        eprintln!("error: {e:#}");
        std::process::exit(1);
    }
}

fn run() -> anyhow::Result<()> {
    let path = PathBuf::from("config.json");
    let cfg = load_config(&path)
        .map_err(|e| anyhow::anyhow!("{e}"))
        .context("failed to initialize config")?;
    println!("{}", serde_json::to_string_pretty(&cfg)?);
    Ok(())
}

stdin, TTY, machine-readable output

Use is_terminal::is_terminal or similar for stdout/stderr; color and spinners only on TTY. Avoid interactive prompts when piped.

  • Structured output: JSON Lines or one JSON object; stable keys and ordering for jq.
  • Logging: if using tracing, document default level and env overrides.

Tests & CI (fmt / clippy)

  • cargo fmt --check and cargo clippy -- -D warnings as merge gates; MSRV in its own CI job.
  • Integration: assert_cmd for exit codes and stderr snippets; fixtures with tempfile.
  • Cross-platform: PathBuf; document Windows paths and UTF-8 assumptions in the SKILL when needed.
# Cargo.toml required publishing fields
[package]
name = "filetool"
version = "0.3.1"
edition = "2021"
rust-version = "1.74"           # MSRV: run separately in CI matrix
description = "File format conversion tool"
license = "MIT OR Apache-2.0"
repository = "https://github.com/acme/filetool"
keywords = ["cli", "file", "convert"]
categories = ["command-line-utilities"]
readme = "README.md"

[[bin]]
name = "filetool"
path = "src/main.rs"

[profile.release]
strip = true        # reduce binary size
lto = true
codegen-units = 1
# .github/workflows/ci.yml — cross-platform GitHub Actions matrix
name: CI
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with: { components: rustfmt, clippy }
      - run: cargo fmt --check
      - run: cargo clippy -- -D warnings
      - run: cargo test

  build-matrix:
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
          - os: ubuntu-latest
            target: aarch64-unknown-linux-gnu
            use_cross: true
          - os: macos-latest
            target: x86_64-apple-darwin
          - os: macos-latest
            target: aarch64-apple-darwin
          - os: windows-latest
            target: x86_64-pc-windows-msvc
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with: { targets: "${{ matrix.target }}" }
      - name: Build (cross)
        if: matrix.use_cross
        run: |
          cargo install cross --git https://github.com/cross-rs/cross
          cross build --release --target ${{ matrix.target }}
      - name: Build (native)
        if: "!matrix.use_cross"
        run: cargo build --release --target ${{ matrix.target }}
      - uses: actions/upload-artifact@v4
        with:
          name: filetool-${{ matrix.target }}
          path: target/${{ matrix.target }}/release/filetool*

Cargo & main skeleton lab (rust-page JS)

Toggle deps and API style to emit paste-ready Cargo.toml and src/main.rs outlines; crate names are normalized to safe Rust identifiers.

clap style
Errors & structure

After paste, fill subcommand bodies, config paths, and exit-code tables; the builder path only sketches Command::new steps to avoid huge generated code on this page.

---
name: rust-cli-tool
description: Build a publishable Rust CLI with clap derive, anyhow/thiserror, and serde
tags: [rust, cli, clap, anyhow, serde]
---
# Argument Parsing
- clap derive: Parser + Subcommand + Args layered; global = true for global flags
- value_parser constrains valid values; - for stdin/stdout explicitly documented in SKILL
- after_help contains example invocation snippets for quick user reference

# Error Handling
- Binary main() -> anyhow::Result<()>; .context() adds call-stack context
- Library/boundary uses thiserror enum; variants map to documented exit codes
- Exit code convention: 0=success, 1=generic failure, 2=usage error (clap default), 3+=domain codes
- Errors to stderr; machine-readable output to stdout as JSON

# Serialization
- serde derive + serde_json: Config/Output structs with Serialize/Deserialize
- Optional fields use #[serde(default)] for backward compatibility; output field order stable for jq

# Release Checklist
- Cargo.toml: name/version/rust-version/description/license/repository all present
- cargo fmt --check + cargo clippy -- -D warnings as CI merge gates
- cargo test (including integration tests with assert_cmd validating exit codes and stderr)
- Cross-platform matrix: linux-x64, linux-arm64(cross), macos-x64, macos-arm64, windows
- profile.release: strip=true, lto=true to reduce binary size

All skills More skills