CLI Basics
10 examples to get you started with Rust CLIs: 7 basic and 3 intermediate.
Prerequisites
- Rust 1.97.0 (edition 2024)
cargo add clap --features derive anyhowfor most examples
Basic Examples
1. Minimal Binary Entry Point
Every CLI starts with a main that returns a result.
fn main() -> anyhow::Result<()> {
println!("Hello from mytool");
Ok(())
}- Return
Resultso errors propagate cleanly - Use
anyhowfor application-level error handling - Keep
mainthin; delegate logic to library functions - Enables
?operator for early exit with context
Related: Error Reporting - friendly CLI errors
2. Reading Positional Args
std::env::args works for the simplest tools.
use std::env;
fn main() -> anyhow::Result<()> {
let name = env::args().nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: greet NAME"))?;
println!("Hello, {name}!");
Ok(())
}args().nth(0)is the binary name- Exit with a non-zero code on failure (
anyhowdoes this automatically) - Fine for prototypes; switch to
clapfor real tools - Document usage in stderr messages
Related: clap - proper argument parsing
3. Meaningful Exit Codes
Shell scripts and CI check $? after your command.
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) if e.is::<std::io::Error>() => ExitCode::from(2),
Err(_) => ExitCode::FAILURE,
}
}0means success; distinct codes for distinct failures- Document exit codes in
--help - Print errors to stderr, data to stdout
- Libraries should return
Result; binaries map to exit codes
4. stdin, stdout, and stderr
CLIs compose via pipes when stdout stays clean.
use std::io::{self, BufRead};
fn main() -> anyhow::Result<()> {
let stdin = io::stdin();
for line in stdin.lock().lines() {
println!("{}", line?.to_uppercase());
}
Ok(())
}stdoutfor data output (pipeable)stderrfor logs and diagnostics- Enables
cat file.txt | mytool | other - Verbose flags should log to stderr
5. Subcommands
Organize verbs like git commit or cargo build.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "items")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Add { name: String },
Remove { id: u64 },
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Add { name } => println!("added {name}"),
Command::Remove { id } => println!("removed {id}"),
}
Ok(())
}- One subcommand per user-facing verb
- Each subcommand gets its own flags and help
clapgenerates--helpper subcommand- Foundation for multi-feature tools
Related: clap - derive-based parsing
6. Environment Variables
12-factor apps read config from the environment.
use std::env;
fn api_key() -> anyhow::Result<String> {
env::var("API_KEY").map_err(|_| anyhow::anyhow!("set API_KEY"))
}- Never print secrets to stdout
- Flags should override env vars (document precedence)
- Use
RUST_LOGfor tracing in production CLIs - Document required variables in
--help
Related: Configuration & Env - layering config
7. Library + Binary Split
Put logic in src/lib.rs, keep src/main.rs as a thin wrapper.
# Cargo.toml
[lib]
name = "mytool"
path = "src/lib.rs"
[[bin]]
name = "mytool"
path = "src/main.rs"// src/lib.rs
pub fn process(input: &str) -> String {
input.to_uppercase()
}- Unit test the library without spawning a process
- Reuse logic in integration tests and benchmarks
mainonly parses args and calls library functions- Standard pattern for production Rust CLIs
Intermediate Examples
8. Colored Output
Use colored or owo-colors for human-facing messages.
use colored::Colorize;
fn main() {
eprintln!("{}", "error: file not found".red().bold());
println!("{}", "done".green());
}- Respect
NO_COLORand--no-color - Color stderr warnings; keep stdout plain for piping
- Disable color when stdout is not a TTY
- See terminal output page for progress bars
Related: Terminal Output - indicatif and styling
9. Progress for Long Operations
Show feedback when work takes more than a second.
use indicatif::{ProgressBar, ProgressStyle};
fn main() {
let bar = ProgressBar::new(100);
bar.set_style(ProgressStyle::with_template(
"[{bar:40}] {pos}/{len} {msg}",
).unwrap());
for i in 0..100 {
bar.set_message(format!("item {i}"));
bar.inc(1);
}
bar.finish_with_message("complete");
}- Progress bars go to stderr
- Use spinners for unknown-duration tasks
- Disable progress when
--quietor non-TTY - Pair with tracing for server-side logs
Related: Terminal Output - full guide
10. Package and Distribute
Ship a global binary with cargo install or release artifacts.
[package]
name = "mytool"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "mytool"
path = "src/main.rs"cargo install --path .
mytool --help- Pin Rust version in
rust-toolchain.tomlfor reproducible builds - Use
cargo-distfor cross-platform release archives - Publish to crates.io or GitHub Releases
- Document install steps in README
Related: Packaging & Distribution - cargo-dist
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.