Rust CLIs are a compounding pleasure: fast, small, statically linked, distributed in seconds. The ecosystem in 2026 is mature. This post is the working playbook.

clap derive

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "blog", version, about = "Manage blog content")]
struct Cli {
    #[arg(short, long, global = true)]
    verbose: bool,
    
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    New { title: String, #[arg(short, long)] section: String },
    Build,
    Deploy { #[arg(long)] dry_run: bool },
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::New { title, section } => new_post(&title, &section),
        Commands::Build => build(),
        Commands::Deploy { dry_run } => deploy(dry_run),
    }
}

--help, --version, completions for free. Type-checked. Self-documenting.

Async CLI

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Commands::Fetch { url } => {
            let body = reqwest::get(url).await?.text().await?;
            println!("{body}");
        }
        _ => {}
    }
    Ok(())
}

Tokio + clap = async CLI in 30 lines.

Progress bars

use indicatif::{ProgressBar, ProgressStyle};

let pb = ProgressBar::new(items.len() as u64);
pb.set_style(ProgressStyle::with_template(
    "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}"
).unwrap());

for item in items {
    process(&item).await?;
    pb.inc(1);
    pb.set_message(format!("processing {}", item.name));
}
pb.finish_with_message("done");

Spinners, bars, multi-progress for parallel tasks. Stdout-aware (no garbage in pipes).

Pretty errors

anyhow for error context, color-eyre for pretty stack traces:

use color_eyre::eyre::{Result, WrapErr};

fn main() -> Result<()> {
    color_eyre::install()?;
    let path = "config.toml";
    let cfg: Config = toml::from_str(&std::fs::read_to_string(path)
        .wrap_err_with(|| format!("reading {path}"))?)
        .wrap_err_with(|| format!("parsing {path}"))?;
    Ok(())
}

User sees:

Error: parsing config.toml

Caused by:
   0: missing field `database_url`

Useful, not just a panic. See Rust Error Handling .

TUI with ratatui

For full-screen interactive UIs:

use ratatui::{prelude::*, widgets::*};

fn ui(f: &mut Frame) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(0)])
        .split(f.size());
    
    f.render_widget(Block::default().borders(Borders::ALL).title("Posts"), chunks[0]);
    
    let items: Vec<ListItem> = posts.iter().map(|p| ListItem::new(p.title.as_str())).collect();
    f.render_widget(List::new(items).block(Block::default().borders(Borders::ALL)), chunks[1]);
}

Ratatui (formerly tui-rs) powers gitui, helix file picker, atac, gpg-tui, and many more. For interactive tools where standard CLI feels limiting.

Config loading

Layered config: defaults → file → env → flags.

use serde::Deserialize;
use config::{Config, File, Environment};

#[derive(Deserialize)]
struct Settings {
    database_url: String,
    log_level: String,
}

let cfg = Config::builder()
    .add_source(File::with_name("config").required(false))
    .add_source(Environment::with_prefix("APP"))
    .build()?;

let settings: Settings = cfg.try_deserialize()?;

figment is another popular alternative.

Shell completions

use clap_complete::{generate, shells::Bash};

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Completions { #[arg(value_enum)] shell: clap_complete::Shell },
    // ...
}

if let Commands::Completions { shell } = cli.command {
    generate(shell, &mut Cli::command(), "blog", &mut std::io::stdout());
}

User runs blog completions bash > /etc/bash_completion.d/blog. Tab completion works.

Cross-platform binaries

cargo install works for Rust users. For everyone else: GitHub Releases with prebuilt binaries.

# .github/workflows/release.yml
- uses: taiki-e/upload-rust-binary-action@v1
  with:
    bin: blog
    target: ${{ matrix.target }}
    archive: $bin-$target-$tag

Targets: x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin, x86_64-pc-windows-msvc. Single workflow ships 4–6 binaries per release.

Reducing binary size

[profile.release]
opt-level = "z"   # optimize for size
lto = true        # link-time optimization
codegen-units = 1
strip = true
panic = "abort"

Plus cargo install cargo-bloat to find what’s bloating the binary. Often a single dependency owns 40%+ of size.

Distribution

  • cargo install <crate> — Rust users.
  • GitHub Releases — prebuilt binaries.
  • Homebrew tap — Mac users.
  • Cargo binstall (cargo install cargo-binstall) — installs prebuilt binary, skips compile.
  • Docker image — for ephemeral environments.

Common mistakes

1. Slow startup

Rust binaries should start in <50ms. If yours doesn’t: too many crates loaded eagerly. Lazy-load via OnceCell.

2. Synchronous IO blocking the runtime

tokio::spawn_blocking for sync ops in async context.

3. Panic on user error

Bad input → unwrap → panic message. User sees Rust-internal noise. Use anyhow and return Err with context.

4. Hard-coded paths

~/.config/myapp/ should come from dirs::config_dir(), cross-platform.

5. No --json output

Pipe-friendly tools have a JSON output mode. Add it from day one for scripting.

Real-world Rust CLIs

  • ripgrep — fastest grep.
  • fd — find replacement.
  • bat — cat with syntax highlighting.
  • eza — ls replacement.
  • zoxide — smart cd.
  • just — task runner.
  • starship — prompt.

All open-source; great reading for patterns.

Read this next

If you want my Rust CLI starter (clap + tokio + indicatif + tracing + cross-platform release), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .