DEV Community

楊東霖
楊東霖

Posted on • Originally published at devtoolkit.cc

How to Build a CLI Tool with Rust: Step-by-Step Tutorial

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 --release produces 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, and anyhow are 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
Enter fullscreen mode Exit fullscreen mode

This creates the following structure:

fxr/
├── Cargo.toml
├── src/
│   └── main.rs
└── .gitignore
Enter fullscreen mode Exit fullscreen mode

The Cargo.toml file is your project manifest. Let's set it up with all the dependencies we'll need:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

Add the test dependencies to your Cargo.toml:

[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.1"
tempfile = "3.10"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Performance Tips

CLI tools are expected to be fast. Here are strategies to maximize performance in your Rust CLI:

  • Use BufReader and BufWriter: Wrapping file handles in std::io::BufReader reduces 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:

Enter fullscreen mode Exit fullscreen mode
  • Use rayon for parallelism: For CPU-bound operations like searching across many files, add rayon and replace .iter() with .par_iter():
use rayon::prelude::*;

let results: Vec<_> = files
    .par_iter()
    .filter_map(|path| search_file(path, &pattern).ok())
    .flatten()
    .collect();
Enter fullscreen mode Exit fullscreen mode
  • Memory-map large files: For very large files, use the memmap2 crate 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 &str instead of String where possible. Use Cow<str> when a function sometimes needs to allocate and sometimes doesn't. Pre-allocate vectors with Vec::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 --help and --version flags (clap handles this automatically)
  • Respect the NO_COLOR environment 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
Enter fullscreen mode Exit fullscreen mode

Add Shell Completions

Clap can generate shell completions for bash, zsh, fish, and PowerShell. Add a hidden completions subcommand:


Enter fullscreen mode Exit fullscreen mode

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 rayon for parallel file processing to make your grep 4-8x faster on multi-core machines
  • Implement a .fxrignore file (similar to .gitignore) using the ignore crate from the ripgrep project
  • Add a watch mode using the notify crate that re-runs searches when files change
  • Build a TUI (terminal user interface) with ratatui for 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)