- Introduction — The Quiet CLI Renaissance
- Your First CLI — clap's derive API
- Subcommands — A git-Style Tool
- Why Rust Now — Performance Without a GC
- Distribution — A Single Static Binary
- The Crates You'll Want
- The Habits Good CLIs Keep
- Wrapping Up
- References
Introduction — The Quiet CLI Renaissance
Over the past few years, a surprising number of the command-line tools you use daily have been quietly rewritten in Rust. grep became ripgrep (rg), find became fd, cat became bat, ls became eza, cd became zoxide (z). Add bacon, which watches your files and reruns tests; dust, which shows disk usage; and bottom (btm), a process viewer — the list keeps growing.
This is not an accident. Command-line tools sit squarely in Rust's sweet spot. They need to start fast (no GC warmup), stay predictably fast (no GC pauses), ship easily (a single binary with no runtime dependencies), and be safe (handle countless files and encodings without crashing). This post traces the background of that renaissance and then shows, in code, what you actually need to build a CLI of your own.
If Rust syntax is new to you, follow along in this site's Rust Learning Lab to build up the basics of ownership and traits as you read.
Your First CLI — clap's derive API
The de facto standard for argument parsing in the Rust ecosystem is clap. It used to register arguments one by one in a builder style, but the recommended approach today is the derive API. You define a struct and annotate it with #[derive(Parser)], and each field becomes a command-line argument.
Start by adding the dependency to Cargo.toml.
[dependencies]
clap = { version = "4", features = ["derive"] }
Now let us build a tiny grep clone that searches for a pattern inside a file.
use clap::Parser;
use std::fs;
/// A tiny tool that searches for a pattern in a file
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// The pattern to look for
pattern: String,
/// The path of the file to search
path: std::path::PathBuf,
/// Whether to ignore case
#[arg(short, long)]
ignore_case: bool,
}
fn main() {
let cli = Cli::parse();
let content = fs::read_to_string(&cli.path).expect("could not read the file");
for (num, line) in content.lines().enumerate() {
let found = if cli.ignore_case {
line.to_lowercase().contains(&cli.pattern.to_lowercase())
} else {
line.contains(&cli.pattern)
};
if found {
println!("{}: {}", num + 1, line);
}
}
}
Several things are worth noticing. The fields pattern and path become positional arguments. The ignore_case field, annotated with #[arg(short, long)], becomes a -i or --ignore-case flag. The /// doc comment above each field becomes its help text. And thanks to #[command(version, about)], --version and --help come for free.
Build it and run --help, and you get a clean usage screen you never wrote a line of.
$ minigrep --help
A tiny tool that searches for a pattern in a file
Usage: minigrep [OPTIONS] <PATTERN> <PATH>
Arguments:
<PATTERN> The pattern to look for
<PATH> The path of the file to search
Options:
-i, --ignore-case Whether to ignore case
-h, --help Print help
-V, --version Print version
The key idea is that the type is the contract. Because you declared path as a PathBuf, clap converts the string into a path for you; because you declared ignore_case as a bool, the presence or absence of the flag becomes true or false automatically. If an argument is missing or malformed, clap prints an error and exits on its own.
Subcommands — A git-Style Tool
A single tool with several subcommands, like git commit and git push, is a very common shape. In clap you express it with an enum: each variant becomes a subcommand.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add a new item
Add {
/// The text to add
text: String,
},
/// List the items
List {
/// Whether to include completed items
#[arg(short, long)]
all: bool,
},
/// Mark an item as done
Done {
/// The id of the item to complete
id: u32,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { text } => {
println!("added: {}", text);
}
Commands::List { all } => {
println!("list (include done: {})", all);
}
Commands::Done { id } => {
println!("marked done: item {}", id);
}
}
}
Now the tool is invoked as todo add "buy milk", todo list --all, todo done 3. Each subcommand has its own arguments and flags, and when you branch with match, the compiler checks that you handled every variant. Add a subcommand and you must update the match too, so you cannot forget to handle it.
Why Rust Now — Performance Without a GC
There are several reasons these tools are fast, but the most fundamental is that there is no garbage collector.
Runtimes like Go, Java, and Node are convenient but levy two taxes. First, there is a delay at startup to initialize the runtime. Command-line tools are invoked thousands of times, so a per-invocation startup delay adds up and becomes noticeable. Second, the GC intervenes while the program runs and causes unpredictable pauses. Rust manages memory with compile-time ownership rules, so there is no runtime GC at all. The program starts instantly and runs at a steady speed without stalls.
Two low-level weapons build on top of that.
- SIMD — Modern CPUs provide vector instructions that process several bytes with a single instruction. The
memchrcrate that ripgrep relies on uses SIMD to scan 16, 32, or 64 bytes at once looking for a specific byte. That is far faster than a naive byte-by-byte loop. - mmap — Instead of copying a file out of the kernel buffer, you map it directly into the process's virtual address space and treat it like a giant byte array. When scanning one large file, a whole copy step disappears.
Do not misread this, though. "It is fast because it is Rust" is only half true. Rust gives you the foundation — lightweight abstractions and safe concurrency — but the essence of the speed lies in algorithms and design. If you want the real reasons ripgrep is fast, this blog covers everything from regex-engine choice to parallel traversal in Why ripgrep Is So Fast.
Distribution — A Single Static Binary
The success of a CLI tool hinges not only on performance but on the ease of distribution, and here Rust has a decisive edge.
Run cargo build --release and you get a single executable. That file needs no interpreter, no runtime, no node_modules. Your users do not have to match a Python version or install a JVM; they just download one binary, give it execute permission, and run it.
# release build (optimizations on)
cargo build --release
# the one and only artifact lives here
./target/release/mytool
# for fully static linking, use the musl target
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
Building for the musl target in particular statically includes even the C standard library, producing a fully static binary that does not depend at all on the system library versions of the target machine. Such a binary works even when dropped into a minimal Docker image (scratch or alpine). Distribution ending at "copy one file" is a big draw for both CLI authors and their users.
The Crates You'll Want
If argument parsing is clap, there is a set of crates that fill the other common needs. Good CLIs are usually built from this combination.
- anyhow — Makes application-level error handling pleasant. It unifies diverse error types into one, propagates them with the
?operator, and lets you attach context along the failure path. - indicatif — Draws progress bars and spinners. For a tool that processes many files, showing the user how far along it is makes a real difference.
- crossterm — Controls the terminal cross-platform. Colors, cursor movement, clearing the screen, detecting key input — all handled identically on Windows, macOS, and Linux.
Here is an example of attaching context to an error with anyhow.
use anyhow::{Context, Result};
use std::fs;
fn load_config(path: &str) -> Result<String> {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read config file: {}", path))?;
Ok(content)
}
fn main() -> Result<()> {
let config = load_config("config.toml")?;
println!("read {} bytes of config", config.len());
Ok(())
}
By letting main return a Result, Rust prints the message and exits with a nonzero code when an error occurs. Thanks to the description added with with_context, the user sees an understandable message like "failed to read config file" rather than a low-level "file not found."
Drawing a progress bar with indicatif is just as simple.
use indicatif::ProgressBar;
use std::thread;
use std::time::Duration;
fn main() {
let total = 1000;
let bar = ProgressBar::new(total);
for _ in 0..total {
// in reality you would process one file here
thread::sleep(Duration::from_millis(2));
bar.inc(1);
}
bar.finish_with_message("done");
}
The Habits Good CLIs Keep
Being fast and safe is not enough. The tools above are loved because they attend carefully to the user experience. Keep these in mind when you build your own.
- Respect the standard streams. Send normal output to stdout, and errors and diagnostics to stderr. That way, when a user pipes with
mytool | other, diagnostic messages do not pollute the data. - Detect pipes. When output is connected to a pipe rather than a terminal, it is courteous to turn off color codes. Most tools behave this way and let you force it with an option like
--color alwaysif needed. - Honor exit codes. Zero for success, nonzero for failure. A shell script must be able to put your tool in a conditional.
- Choose good defaults. Make the behavior users usually want the default with no configuration, and open the exceptional cases behind flags. This is why ripgrep respects
.gitignoreby default. - Provide shell completions. clap can generate completion scripts for bash, zsh, and fish. It seems minor, but it lifts usability a lot.
If you want to develop a feel for CLIs by working with a version-control tool, you can experiment with how commands behave in this site's Git Playground.
Wrapping Up
The CLI renaissance is the result of fit, not language fandom. Fast startup, predictable performance, single-binary distribution, and memory safety — these four match the problem of command-line tools precisely, and on top of that fit were born tools like ripgrep, fd, and bat.
Your first tool does not need to be grand. Start with a single struct annotated with #[derive(Parser)], add subcommands, polish your errors with anyhow, and show progress with indicatif when it helps. And finally, keep the small habits like standard streams and exit codes, and your tool too can join the list people keep at their fingertips every day.
References
- clap official documentation — https://docs.rs/clap/
- clap derive tutorial — https://docs.rs/clap/latest/clap/_derive/_tutorial/index.html
- The Rust Programming Language, Chapter 12 (building minigrep) — https://doc.rust-lang.org/book/ch12-00-an-io-project.html
- Command Line Applications in Rust (book) — https://rust-cli.github.io/book/
- anyhow crate — https://docs.rs/anyhow/
- indicatif crate — https://docs.rs/indicatif/
- crossterm crate — https://docs.rs/crossterm/
- ripgrep repository — https://github.com/BurntSushi/ripgrep
- fd repository — https://github.com/sharkdp/fd
- bat repository — https://github.com/sharkdp/bat
현재 단락 (1/145)
Over the past few years, a surprising number of the command-line tools you use daily have been quiet...