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, §ion),
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 .