Rust has quietly become the go-to language for building command-line tools. From ripgrep to bat to fd, the most beloved CLI utilities of the past few years are written in Rust — and for good reason. Rust gives you C-level performance, memory safety without a garbage collector, and an ecosystem of crates specifically designed for CLI development. If you've ever wanted to build a fast, reliable command-line tool that works everywhere, Rust is the right choice.
In this tutorial, we'll build a complete CLI tool from scratch — a file search and transform utility called fxr that can find files, search content, convert between formats, and produce beautifully colored output. Along the way, you'll learn argument parsing with clap, error handling with anyhow, JSON and YAML processing, progress bars, testing strategies, cross-compilation, and distribution. By the end, you'll have a production-ready CLI tool and the knowledge to build your own.
If you're working with JSON or YAML data alongside your CLI tool, our JSON Formatter is great for quickly inspecting output, and the JSON vs YAML vs TOML comparison can help you decide which format your tool should support.
Why Rust for CLI Tools?
Before we start coding, let's understand why Rust dominates the CLI space. There are languages that are easier to learn (Python), languages with larger standard libraries (Go), and languages with faster compile times (C). But Rust hits a sweet spot that matters specifically for CLI tools:
- Instant startup time: Rust binaries start in milliseconds, not seconds. There's no runtime to initialize, no JIT to warm up. When a user types your command, it executes immediately. This matters enormously for CLI tools that get called thousands of times per day in scripts and pipelines.
-
Single binary distribution:
cargo build --releaseproduces one statically-linked binary. No Python virtualenvs, no Node.js installations, no DLL hell. Copy the binary to a machine and it works. - Memory safety without GC: Rust's ownership system prevents segfaults, buffer overflows, and data races at compile time. Your CLI tool won't randomly crash on edge-case inputs.
-
Incredible ecosystem: Crates like
clap,serde,colored,indicatif, andanyhoware mature, well-maintained, and purpose-built for CLI development. - Cross-compilation: Build for Linux, macOS, and Windows from a single machine. Ship your tool everywhere without maintaining separate codebases.
- Fearless refactoring: Rust's type system catches entire categories of bugs at compile time. When your tool grows from 200 lines to 20,000, the compiler keeps you safe.
Tools like ripgrep (a grep replacement) are 5-10x faster than their C counterparts while being memory-safe. That's the Rust advantage — you don't have to choose between speed and safety.
Project Setup with Cargo
Every Rust project starts with Cargo, Rust's build system and package manager. Let's create our project:
cargo init fxr
cd fxr
This creates the following structure:
fxr/
├── Cargo.toml
├── src/
│ └── main.rs
└── .gitignore
The Cargo.toml file is your project manifest. Let's set it up with all the dependencies we'll need:
Let's break down each dependency:
- clap — argument parsing with derive macros for declarative CLI definitions
- serde / serde_json / serde_yaml — serialization and deserialization for JSON and YAML
- colored — ANSI color codes for terminal output
- anyhow — ergonomic error handling for application code
- thiserror — derive macro for defining custom error types
- indicatif — progress bars and spinners
- walkdir — recursive directory traversal
- regex — regular expression matching
The [profile.release] section enables Link-Time Optimization (LTO), reduces codegen units for better optimization, and strips debug symbols — shrinking the final binary significantly.
Argument Parsing with Clap (Derive API)
Clap's derive API lets you define your entire CLI interface as Rust structs and enums. The library generates the parser, help text, shell completions, and validation from your type definitions. Here's the foundation of our tool:
This definition gives us a CLI with four subcommands: find, grep, convert, and stats. Clap automatically generates help text, validates arguments, and parses everything into strongly-typed Rust values. Running fxr --help produces professional documentation with no extra effort.
Notice how each subcommand's arguments are self-contained in their enum variant. The #[arg] attributes control short flags, long flags, default values, and help text. The global = true on verbose and format means they can appear before or after the subcommand.
Building Subcommands
Now let's wire up the main function and implement each subcommand. We'll start with the entry point:
The main function returns anyhow::Result<()>, which means any error in our subcommands will be printed to stderr with a nice error message and a non-zero exit code. No manual error printing needed.
Reading and Writing Files
File I/O is the bread and butter of CLI tools. Rust's standard library makes this straightforward, and anyhow adds context to errors:
The .with_context() method from anyhow wraps errors with human-readable context. Instead of a cryptic "No such file or directory" error, the user sees "Failed to read file: config.yaml: No such file or directory". This small detail makes a huge difference in CLI usability.
Implementing the Find Command
The find subcommand uses walkdir for recursive directory traversal and regex for pattern matching:
JSON and YAML Parsing with Serde
One of our tool's key features is converting between JSON and YAML. Serde makes this remarkably clean — you deserialize from one format and serialize to another:
The beauty of this approach is that serde_json::Value acts as a universal intermediate representation. You can deserialize any JSON or YAML into it, manipulate it if needed, and serialize it back to any format. For more complex tools, you'd define typed structs with #[derive(Serialize, Deserialize)] for compile-time validation.
If you're debugging the JSON or YAML output of your tool during development, our online JSON Formatter can help you spot structural issues instantly.
Colored Terminal Output
Color transforms a wall of text into scannable, informative output. The colored crate adds ANSI color codes via method chaining on strings:
The colored crate automatically detects whether stdout is a terminal and disables colors when output is piped to another command or a file. This means fxr find "*.rs" | wc -l works correctly without ANSI escape codes polluting the output. You can also force behavior with the NO_COLOR environment variable (respecting the no-color.org standard).
Progress Bars with Indicatif
For long-running operations, progress bars give users confidence that the tool is working. Indicatif provides rich, customizable progress indicators:
The progress bar shows elapsed time, a visual bar, current position, total count, and estimated time remaining. For operations where you don't know the total count upfront, use ProgressBar::new_spinner() instead for an indeterminate spinner.
Error Handling with Anyhow and Thiserror
Rust's error handling is one of its greatest strengths, but it can be verbose without the right tools. The community has settled on a two-crate pattern:
- anyhow — for application-level error handling where you want to propagate errors with context
- thiserror — for library-level error handling where you want to define structured error types
Here's how to define custom errors with thiserror for the parts of your tool that might be used as a library:
In your application code, use anyhow's Result type and the ? operator to propagate errors up the call stack. Add context at boundaries where the error message alone wouldn't be helpful:
The ensure! macro is like assert! but returns an error instead of panicking. The bail! macro is a shorthand for returning an error immediately. Both produce clean error messages that help users fix the problem.
Implementing the Grep Command
Let's build the content search subcommand, which ties together file traversal, regex matching, colored output, and a progress bar:
Unit and Integration Testing
Rust has a built-in test framework that makes testing CLI tools straightforward. You'll typically write three types of tests: unit tests for individual functions, integration tests for subcommands, and snapshot tests for output formatting.
Unit Tests
Place unit tests in the same file as the code they test, inside a #[cfg(test)] module:
Integration Tests
Integration tests live in a tests/ directory and test your binary as a whole. Use the assert_cmd crate for ergonomic binary testing:
Add the test dependencies to your Cargo.toml:
[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.1"
tempfile = "3.10"
Run all tests with cargo test. Rust compiles and runs unit tests and integration tests separately, and reports results clearly.
Cross-Compilation
One of Rust's greatest strengths for CLI tools is cross-compilation. You can build binaries for Linux, macOS, and Windows from a single machine:
# Install cross-compilation targets
rustup target add x86_64-unknown-linux-musl
rustup target add x86_64-apple-darwin
rustup target add x86_64-pc-windows-msvc
rustup target add aarch64-unknown-linux-musl
rustup target add aarch64-apple-darwin
# Build for Linux (static, works on any distro)
cargo build --release --target x86_64-unknown-linux-musl
# Build for macOS (Intel)
cargo build --release --target x86_64-apple-darwin
# Build for macOS (Apple Silicon)
cargo build --release --target aarch64-apple-darwin
# Build for Windows
cargo build --release --target x86_64-pc-windows-msvc
For a smoother cross-compilation experience, consider using the cross tool, which uses Docker containers with pre-configured toolchains:
cargo install cross
cross build --release --target x86_64-unknown-linux-musl
cross build --release --target aarch64-unknown-linux-musl
The musl target produces fully static binaries on Linux that work on any distribution without shared library dependencies — ideal for CLI tools that need to "just work" everywhere.
Distribution: Getting Your Tool to Users
A great CLI tool is worthless if nobody can install it. Here are the three main distribution channels:
1. Cargo Install (Rust users)
Publish your crate to crates.io and anyone with Rust installed can run:
cargo install fxr
To publish, run cargo publish after setting up your crates.io API token. Make sure your Cargo.toml has all required fields: name, version, description, license, and repository.
2. GitHub Releases (Everyone)
Use GitHub Actions to automatically build and publish binaries for every tagged release. Here's a workflow that builds for all major platforms:
3. Homebrew (macOS/Linux)
Create a Homebrew formula so users can install with brew install fxr. Create a tap repository and add a formula:
# Formula/fxr.rb
class Fxr < Formula
desc "A fast file search and transform utility"
homepage "https://github.com/yourusername/fxr"
version "0.1.0"
on_macos do
on_arm do
url "https://github.com/yourusername/fxr/releases/download/v0.1.0/fxr-macos-arm64.tar.gz"
sha256 "YOUR_SHA256_HERE"
end
on_intel do
url "https://github.com/yourusername/fxr/releases/download/v0.1.0/fxr-macos-amd64.tar.gz"
sha256 "YOUR_SHA256_HERE"
end
end
on_linux do
url "https://github.com/yourusername/fxr/releases/download/v0.1.0/fxr-linux-amd64.tar.gz"
sha256 "YOUR_SHA256_HERE"
end
def install
bin.install "fxr"
end
end
Real-World Example: Putting It All Together
Let's see our complete tool in action. Here's the full main.rs wired together, showing how all the pieces fit:
With the tool built, here are example invocations:
# Find all Rust files in the current directory
fxr find "\.rs$"
# Search for TODO comments in Python files
fxr grep "TODO|FIXME" --include py --verbose
# Convert a JSON config to YAML
fxr convert config.json -o config.yaml --pretty
# Get directory statistics as JSON
fxr --format json stats ./src
# Find large files with depth limit
fxr find ".*" -D 3 --ext log --verbose
Performance Tips
CLI tools are expected to be fast. Here are strategies to maximize performance in your Rust CLI:
-
Use
BufReaderandBufWriter: Wrapping file handles instd::io::BufReaderreduces syscalls dramatically. For a grep-like tool, this alone can provide 5-10x speedup. -
Lock stdout: Each
println!call locks and unlocks stdout. For tight loops, lock once and write repeatedly:
-
Use
rayonfor parallelism: For CPU-bound operations like searching across many files, addrayonand replace.iter()with.par_iter():
use rayon::prelude::*;
let results: Vec<_> = files
.par_iter()
.filter_map(|path| search_file(path, &pattern).ok())
.flatten()
.collect();
-
Memory-map large files: For very large files, use the
memmap2crate to avoid loading entire files into memory. -
Compile with LTO: The
[profile.release]settings we defined earlier enable Link-Time Optimization, which lets the compiler optimize across crate boundaries. This typically provides 10-20% performance improvement. -
Avoid unnecessary allocations: Use
&strinstead ofStringwhere possible. UseCow<str>when a function sometimes needs to allocate and sometimes doesn't. Pre-allocate vectors withVec::with_capacity()when you know the approximate size.
Best Practices for CLI Tools
Great CLI tools follow conventions that users expect. Here are the practices that separate professional tools from toy projects:
Respect Standard Conventions
- Exit with code 0 on success, non-zero on failure
- Write normal output to stdout and errors/diagnostics to stderr
- Support
--helpand--versionflags (clap handles this automatically) - Respect the
NO_COLORenvironment variable - Support piping — detect when stdout isn't a terminal and adjust output accordingly
Provide Multiple Output Formats
Human-readable output is great for interactive use, but scripts need machine-readable formats. Supporting --format json and --format yaml makes your tool composable with other tools in the Unix pipeline tradition. We built this into our tool from the beginning.
Write Helpful Error Messages
An error message should tell the user what went wrong and ideally how to fix it. Compare these two errors:
// Bad
Error: No such file or directory
// Good
Error: Could not read config file: ./config.yaml
Cause: No such file or directory
Hint: Create a config file with 'fxr init' or specify a path with --config
Add Shell Completions
Clap can generate shell completions for bash, zsh, fish, and PowerShell. Add a hidden completions subcommand:
Include a Config File
For tools with many options, support a config file (usually ~/.config/fxr/config.toml) so users don't have to repeat flags. Use the directories crate to find the right config location on each platform.
Version Your Output Format
If other tools depend on your JSON output, include a schema version field. This lets you evolve the output format without breaking downstream consumers.
Next Steps
You now have the knowledge to build professional CLI tools with Rust. Here's where to go from here:
- Add
rayonfor parallel file processing to make your grep 4-8x faster on multi-core machines - Implement a
.fxrignorefile (similar to.gitignore) using theignorecrate from the ripgrep project - Add a
watchmode using thenotifycrate that re-runs searches when files change - Build a TUI (terminal user interface) with
ratatuifor interactive file browsing - Publish to crates.io and set up the GitHub Actions release pipeline
The Rust CLI ecosystem is mature and well-documented. Crates like clap, serde, and anyhow have been battle-tested by thousands of projects. You're building on a solid foundation.
For testing the JSON output of your CLI tool, check out our JSON Formatter. And if you're building REST API integrations into your CLI, our REST API Testing Guide covers the patterns you'll need.
Free Developer Tools
If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.
Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder
🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.
Top comments (0)