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-distor 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 = truefor-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::Terminationor 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 --checkandcargo clippy -- -D warningsas merge gates; MSRV in its own CI job.- Integration:
assert_cmdfor exit codes and stderr snippets; fixtures withtempfile. - 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.
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