Rust

Production-Ready Rust CLI Workflow

Build a practical Rust CLI with clap, robust errors, structured logging, and release checks

Production-Ready Rust CLI Workflow

This guide walks through a practical pattern for building Rust CLIs you can confidently ship and maintain.

Overview

You will build a small command-line app that:

  • parses arguments with clap
  • handles errors cleanly with anyhow and thiserror
  • uses structured logging with tracing
  • validates behavior with tests and a release checklist

Use this as a template for internal tooling, automation scripts, or public developer utilities.

Prerequisites

  • Rust stable toolchain (rustup update)
  • Basic Rust familiarity (modules, structs, Result)
  • Git initialized for your project

Optional but recommended:

  • just for command recipes
  • cargo-nextest for faster test runs
Terminal
rustup update
cargo install just --locked
cargo install cargo-nextest --locked

Step 1: Create the project scaffold

Terminal
cargo new devtool --bin
cd devtool

Add dependencies:

Cargo.toml
[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"

Step 2: Define CLI shape with clap

Start with a command structure that can grow without breaking UX.

src/main.rs
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(())
}

Step 3: Add robust error handling

Use typed domain errors internally, then bubble to anyhow::Result at boundaries.

src/error.rs
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,
    },
}
src/main.rs
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(())
}

Step 4: Configure logging and runtime ergonomics

src/main.rs
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:

Terminal
cargo run -- validate ./example.json
RUST_LOG=debug cargo run -- validate ./example.json

Step 5: Add CLI behavior tests

Use integration tests to verify user-facing behavior.

tests/validate_cli.rs
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:

tests/fixtures/ok.json
{"name":"devtool","version":1}
tests/fixtures/bad.json
{"name":

Common pitfalls

1) Returning String errors everywhere

Result<T, String> loses context and source chains. Prefer typed errors (thiserror) plus context (anyhow::Context).

2) Panicking on expected user mistakes

Missing files and parse failures are normal CLI outcomes. Return informative errors and non-zero exit codes instead of panic!.

3) Skipping subcommands too long

A flat CLI with many flags gets unmaintainable quickly. Use subcommands early (validate, format, sync, etc.).

4) No integration tests

Unit tests alone won’t catch argument parsing, exit code behavior, or output regressions.

5) Uncontrolled log noise

Default to concise logs (info) and make debug output opt-in with -v/RUST_LOG.

Validation checklist

Before publishing or sharing your CLI:

  • cargo fmt --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test (or cargo nextest run)
  • cargo run -- --help shows clear command and examples
  • Invalid input returns non-zero status + actionable message
  • README includes install and quick-start usage

Optional release hardening:

  • Add --locked in CI builds (cargo build --locked)
  • Build release binary and smoke test (cargo build --release)
  • Tag and publish with changelog if this is a public crate

Summary

A shippable Rust CLI is mostly about operational discipline:

  • clear command design (clap)
  • strong errors (thiserror + anyhow)
  • controllable observability (tracing)
  • integration tests for real user flows

If you standardize this structure across your tools, new CLIs become faster to ship and easier to maintain.