This guide walks through a practical pattern for building Rust CLIs you can confidently ship and maintain.
You will build a small command-line app that:
clapanyhow and thiserrortracingUse this as a template for internal tooling, automation scripts, or public developer utilities.
rustup update)Result)Optional but recommended:
just for command recipescargo-nextest for faster test runsrustup update
cargo install just --locked
cargo install cargo-nextest --locked
cargo new devtool --bin
cd devtool
Add dependencies:
[package]
name = "devtool"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
clapStart with a command structure that can grow without breaking UX.
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "devtool")]
#[command(about = "Developer automation helper", long_about = None)]
struct Cli {
/// Verbose logging (repeat for more detail)
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Validate a JSON file
Validate {
/// Path to JSON file
#[arg(value_name = "FILE")]
file: std::path::PathBuf,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
init_tracing(cli.verbose);
match cli.command {
Commands::Validate { file } => validate_file(&file)?,
}
Ok(())
}
Use typed domain errors internally, then bubble to anyhow::Result at boundaries.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ValidationError {
#[error("failed to read file '{path}': {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("invalid JSON in '{path}': {source}")]
Json {
path: String,
#[source]
source: serde_json::Error,
},
}
mod error;
use anyhow::{Context, Result};
use serde_json::Value;
use std::fs;
use std::path::Path;
use tracing::{debug, info};
fn validate_file(path: &Path) -> Result<()> {
let display = path.display().to_string();
let raw = fs::read_to_string(path)
.with_context(|| format!("unable to read input file: {display}"))?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("unable to parse JSON: {display}"))?;
debug!(keys = ?json.as_object().map(|o| o.keys().count()), "json parsed");
info!(file = %display, "validation successful");
println!("OK: {display}");
Ok(())
}
fn init_tracing(verbose: u8) {
let level = match verbose {
0 => "info",
1 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level)),
)
.with_target(false)
.compact()
.init();
}
Usage:
cargo run -- validate ./example.json
RUST_LOG=debug cargo run -- validate ./example.json
Use integration tests to verify user-facing behavior.
use assert_cmd::Command;
use predicates::str::contains;
#[test]
fn validate_succeeds_for_valid_json() {
let mut cmd = Command::cargo_bin("devtool").unwrap();
cmd.arg("validate")
.arg("tests/fixtures/ok.json")
.assert()
.success()
.stdout(contains("OK:"));
}
#[test]
fn validate_fails_for_bad_json() {
let mut cmd = Command::cargo_bin("devtool").unwrap();
cmd.arg("validate")
.arg("tests/fixtures/bad.json")
.assert()
.failure()
.stderr(contains("unable to parse JSON"));
}
Fixture files:
{"name":"devtool","version":1}
{"name":
String errors everywhereResult<T, String> loses context and source chains. Prefer typed errors (thiserror) plus context (anyhow::Context).
Missing files and parse failures are normal CLI outcomes. Return informative errors and non-zero exit codes instead of panic!.
A flat CLI with many flags gets unmaintainable quickly. Use subcommands early (validate, format, sync, etc.).
Unit tests alone won’t catch argument parsing, exit code behavior, or output regressions.
Default to concise logs (info) and make debug output opt-in with -v/RUST_LOG.
Before publishing or sharing your CLI:
cargo fmt --checkcargo clippy --all-targets --all-features -- -D warningscargo test (or cargo nextest run)cargo run -- --help shows clear command and examplesOptional release hardening:
--locked in CI builds (cargo build --locked)cargo build --release)A shippable Rust CLI is mostly about operational discipline:
clap)thiserror + anyhow)tracing)If you standardize this structure across your tools, new CLIs become faster to ship and easier to maintain.