Skip to content

Build Rust CLI Tools with Clap in 2026

Terminal showing a Rust CLI tool with colored output and progress bars

Rust has become the go-to language for high-performance CLI tools. ripgrep, bat, fd, delta, and zoxide all prove that Rust CLIs are fast, reliable, and loved by developers. This tutorial walks you through building production-grade command-line tools in Rust using Clap v4, from project setup to cross-platform distribution.

You will learn the Clap derive API for argument parsing, proper project structure with library and binary crates, error handling with anyhow and thiserror, colored output with progress bars, configuration management, comprehensive testing, cross-compilation, and shipping binaries to users. By the end, you will have built a complete file query tool from scratch.

1. Why Rust for CLI Tools

Rust offers a unique combination of features that make it ideal for command-line applications. Here is why teams choose Rust over Go, Python, or Node.js for CLI tooling.

Zero-cost abstractions. Rust's type system and ownership model let you write high-level, expressive code that compiles down to the same machine code you would get from hand-optimized C. Iterators, pattern matching, and generics add zero runtime overhead.

Single static binary. Rust compiles to a single executable with no runtime dependencies. No JVM, no interpreter, no shared libraries to manage. Users download one file and run it. Compare this to Python (virtualenvs, pip) or Node.js (node_modules, npm).

Cross-compilation. Rust's compiler supports dozens of target triples out of the box. Build for Linux, macOS, and Windows from a single CI machine. The cross tool makes this even simpler with pre-built Docker images for each target.

Memory safety without garbage collection. No GC pauses, no runtime overhead. Rust catches memory bugs, data races, and null pointer dereferences at compile time. Your CLI will not segfault in production.

Rich ecosystem. The Rust CLI ecosystem is mature. Clap handles argument parsing, serde handles serialization, and crates like indicatif, owo-colors, and comfy-table handle terminal output. The code repositories guide covers more Rust tooling.

FeatureRustGoPython
Binary size (hello world)~300 KB (stripped)~2 MBN/A (needs interpreter)
Startup time<1 ms~5 ms~30 ms
Memory usageMinimal (no GC)~8 MB (GC overhead)~15 MB
Compile timeSlower (minutes)Fast (seconds)None
Cross-compilationcross tool / targetsGOOS/GOARCHPyInstaller (fragile)
Dependency managementCargo (excellent)Go modulespip/poetry
Error handlingResult/Option typesMultiple returnsExceptions

If compile time is your main concern, check out the Go CLI tutorial with Cobra. For everything else, Rust wins on binary size, runtime performance, and safety guarantees.

2. Project Setup

Start by creating a new Rust project with Cargo and adding the core dependencies.

cargo new fq --name fq
cd fq

Add these dependencies to Cargo.toml:

[package]
name = "fq"
version = "0.1.0"
edition = "2021"
description = "A fast file query tool"
license = "MIT"
repository = "https://github.com/yourorg/fq"

[dependencies]
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
anyhow = "1.0"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
owo-colors = "4.1"
comfy-table = "7.1"
indicatif = "0.17"
config = "0.14"
directories = "5.0"
glob = "0.3"

[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.1"
tempfile = "3.12"
trycmd = "0.15"

This gives you argument parsing (clap), error handling (anyhow, thiserror), serialization (serde), colored output (owo-colors), tables (comfy-table), progress bars (indicatif), configuration (config, directories), file matching (glob), and a full testing toolkit.

3. Clap v4 Derive API

Clap's derive API lets you define your CLI interface as Rust structs and enums. The compiler generates all parsing, validation, and help text from your type definitions.

use clap::Parser;

/// A fast file query tool
#[derive(Parser, Debug)]
#[command(name = "fq", version, about, long_about = None)]
struct Cli {
    /// Search pattern (regex)
    pattern: String,

    /// Files or directories to search
    #[arg(default_value = ".")]
    paths: Vec<String>,

    /// Output format
    #[arg(short, long, default_value = "text", value_parser = ["text", "json", "table"])]
    format: String,

    /// Show line numbers
    #[arg(short = 'n', long)]
    line_numbers: bool,

    /// Maximum search depth
    #[arg(short, long, default_value_t = 10)]
    depth: usize,

    /// Number of context lines before and after matches
    #[arg(short, long, default_value_t = 0)]
    context: usize,

    /// Enable verbose output
    #[arg(short, long, env = "FQ_VERBOSE")]
    verbose: bool,
}

fn main() {
    let cli = Cli::parse();
    println!("Searching for: {}", cli.pattern);
}

Key derive attributes:

  • #[command(...)] - sets app-level metadata (name, version, about)
  • #[arg(short, long)] - creates both -v and --verbose flags
  • #[arg(default_value = "text")] - provides a default when the flag is omitted
  • #[arg(value_parser = [...])] - restricts values to a fixed set
  • #[arg(env = "FQ_VERBOSE")] - reads from an environment variable as fallback

Running fq --help automatically generates formatted help text from your doc comments and attribute metadata. No manual help string maintenance required.

4. Subcommands and Arguments

Real CLI tools need subcommands. Clap models these as enums with the Subcommand derive macro. Each variant becomes a subcommand with its own arguments.

use clap::{Parser, Subcommand, Args};

#[derive(Parser, Debug)]
#[command(name = "fq", version, about)]
struct Cli {
    #[command(subcommand)]
    command: Commands,

    /// Config file path
    #[arg(long, global = true)]
    config: Option<String>,

    /// Output format
    #[arg(short, long, global = true, default_value = "text")]
    format: String,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Search files for a pattern
    Search(SearchArgs),
    /// Count matches across files
    Count(SearchArgs),
    /// List files matching a glob pattern
    List(ListArgs),
    /// Show or update configuration
    Config(ConfigArgs),
}

#[derive(Args, Debug)]
struct SearchArgs {
    /// Regex pattern to search for
    pattern: String,

    /// Paths to search
    #[arg(default_value = ".")]
    paths: Vec<String>,

    /// Show N context lines around matches
    #[arg(short, long, default_value_t = 0)]
    context: usize,

    /// Include hidden files
    #[arg(long)]
    hidden: bool,

    /// Maximum directory depth
    #[arg(short, long)]
    depth: Option<usize>,
}

#[derive(Args, Debug)]
struct ListArgs {
    /// Glob pattern (e.g., "**/*.rs")
    pattern: String,

    /// Root directory
    #[arg(default_value = ".")]
    path: String,

    /// Sort by: name, size, modified
    #[arg(short, long, default_value = "name")]
    sort: String,
}

#[derive(Args, Debug)]
struct ConfigArgs {
    /// Config key to get or set
    key: Option<String>,

    /// Value to set
    value: Option<String>,

    /// List all config values
    #[arg(long)]
    list: bool,
}

The global = true attribute on --config and --format makes them available to all subcommands. Shared argument groups use #[derive(Args)] so you can reuse SearchArgs across both search and count subcommands.

Clap also supports value enums for type-safe flag values:

use clap::ValueEnum;

#[derive(ValueEnum, Clone, Debug)]
enum OutputFormat {
    Text,
    Json,
    Table,
}

// Use in your args struct:
#[arg(short, long, default_value_t = OutputFormat::Text)]
format: OutputFormat,

5. Library and Binary Crate Structure

Production Rust CLIs separate logic into a library crate (src/lib.rs) and a thin binary crate (src/main.rs). This lets you test business logic independently, reuse it as a library, and keep main.rs focused on CLI wiring.

src/
  main.rs          # Binary entry point - CLI parsing only
  lib.rs           # Library root - re-exports modules
  cli.rs           # Clap structs and enums
  search.rs        # Core search logic
  output.rs        # Formatting (text, JSON, table)
  config.rs        # Configuration loading
  error.rs         # Custom error types
tests/
  cli.rs           # Integration tests (assert_cmd)
  snapshots/       # trycmd snapshot tests

The binary crate is minimal:

// src/main.rs
use anyhow::Result;
use clap::Parser;
use fq::cli::{Cli, Commands};
use fq::{search, count, list, config};

fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Search(args) => search::run(args, &cli.format)?,
        Commands::Count(args) => count::run(args, &cli.format)?,
        Commands::List(args) => list::run(args, &cli.format)?,
        Commands::Config(args) => config::run(args)?,
    }

    Ok(())
}

The library crate exposes modules:

// src/lib.rs
pub mod cli;
pub mod search;
pub mod count;
pub mod list;
pub mod config;
pub mod output;
pub mod error;

This pattern is used by ripgrep, bat, and most serious Rust CLI tools. It also enables cargo doc to generate documentation for your library API.

6. Error Handling

Rust's error handling ecosystem gives you three complementary crates. Use them together for clean, informative error reporting.

The error handling stack: thiserror for defining errors, anyhow for propagating them, and color-eyre for pretty-printing them during development.

thiserror - define typed errors for your library crate:

// src/error.rs
use thiserror::Error;

#[derive(Error, Debug)]
pub enum FqError {
    #[error("invalid regex pattern: {0}")]
    InvalidPattern(#[from] regex::Error),

    #[error("cannot read file: {path}")]
    FileRead {
        path: String,
        #[source]
        source: std::io::Error,
    },

    #[error("config key not found: {0}")]
    ConfigNotFound(String),

    #[error("unsupported output format: {0}")]
    UnsupportedFormat(String),

    #[error("max depth {depth} exceeded at {path}")]
    DepthExceeded { depth: usize, path: String },
}

anyhow - propagate errors with context in your binary crate:

use anyhow::{Context, Result};

fn read_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))?;

    let config: Config = toml::from_str(&content)
        .context("failed to parse config file")?;

    Ok(config)
}

color-eyre - pretty error reports during development:

// In main.rs, replace anyhow with color-eyre for development
use color_eyre::eyre::Result;

fn main() -> Result<()> {
    color_eyre::install()?;
    // ... rest of main
}

color-eyre prints colorized backtraces, span traces, and suggestion sections. Use it during development, then switch to anyhow for release builds if you prefer minimal output. Many tools keep color-eyre in production since the error reports help users file better bug reports.

Set proper exit codes to follow Unix conventions:

use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(found) => {
            if found { ExitCode::SUCCESS } else { ExitCode::from(1) }
        }
        Err(e) => {
            eprintln!("error: {e:#}");
            ExitCode::from(2)
        }
    }
}

7. Output Formatting

Good CLI tools adapt their output to the user's needs. Support plain text for piping, colored text for terminals, tables for human reading, and JSON for scripting.

owo-colors - fast, zero-allocation colored output:

use owo_colors::OwoColorize;

fn print_match(filename: &str, line_num: usize, line: &str, matched: &str) {
    println!(
        "{}:{}: {}",
        filename.purple(),
        line_num.to_string().green(),
        line.replace(matched, &matched.red().bold().to_string())
    );
}

owo-colors is the modern replacement for colored and termcolor. It uses zero heap allocations and supports the NO_COLOR environment variable standard automatically. ripgrep and other modern Rust CLIs have adopted this approach.

comfy-table - formatted tables with alignment and borders:

use comfy_table::{Table, ContentArrangement, presets::UTF8_FULL};

fn print_table(results: &[SearchResult]) {
    let mut table = Table::new();
    table
        .load_preset(UTF8_FULL)
        .set_content_arrangement(ContentArrangement::Dynamic)
        .set_header(vec!["File", "Line", "Match"]);

    for r in results {
        table.add_row(vec![
            &r.file,
            &r.line_num.to_string(),
            &r.text,
        ]);
    }

    println!("{table}");
}

indicatif - progress bars and spinners:

use indicatif::{ProgressBar, ProgressStyle, MultiProgress};

fn search_with_progress(paths: &[String]) -> Result<Vec<SearchResult>> {
    let multi = MultiProgress::new();
    let pb = multi.add(ProgressBar::new(paths.len() as u64));
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")?
            .progress_chars("=>-")
    );

    let mut results = Vec::new();
    for path in paths {
        pb.set_message(path.clone());
        results.extend(search_file(path)?);
        pb.inc(1);
    }

    pb.finish_with_message("done");
    Ok(results)
}

indicatif supports multi-progress bars (parallel tasks), ETA estimation, custom templates, and steady tick for spinners. Use ProgressBar::hidden() when output is piped to avoid corrupting machine-readable output.

JSON output - structured output for scripting:

use serde::Serialize;

#[derive(Serialize)]
struct SearchResult {
    file: String,
    line_num: usize,
    text: String,
    byte_offset: usize,
}

fn print_json(results: &[SearchResult]) -> Result<()> {
    let json = serde_json::to_string_pretty(results)?;
    println!("{json}");
    Ok(())
}

// Dispatch based on format flag
fn print_results(results: &[SearchResult], format: &OutputFormat) -> Result<()> {
    match format {
        OutputFormat::Text => print_text(results),
        OutputFormat::Json => print_json(results),
        OutputFormat::Table => { print_table(results); Ok(()) }
    }
}

Always detect whether stdout is a TTY. Disable colors and progress bars when output is piped:

use std::io::IsTerminal;

let use_color = std::io::stdout().is_terminal();
let pb = if use_color {
    ProgressBar::new(total)
} else {
    ProgressBar::hidden()
};

8. Configuration

The config crate merges configuration from multiple sources with clear precedence: CLI flags override environment variables, which override config files, which override defaults.

use config::{Config, File, Environment};
use directories::ProjectDirs;
use serde::Deserialize;

#[derive(Deserialize, Debug)]
pub struct AppConfig {
    pub default_format: String,
    pub max_depth: usize,
    pub show_hidden: bool,
    pub color: bool,
    pub ignore_patterns: Vec<String>,
}

impl AppConfig {
    pub fn load(cli_config_path: Option<&str>) -> anyhow::Result<Self> {
        let mut builder = Config::builder()
            .set_default("default_format", "text")?
            .set_default("max_depth", 10)?
            .set_default("show_hidden", false)?
            .set_default("color", true)?
            .set_default("ignore_patterns", Vec::<String>::new())?;

        // XDG config path: ~/.config/fq/config.toml
        if let Some(dirs) = ProjectDirs::from("com", "yourorg", "fq") {
            let xdg_config = dirs.config_dir().join("config.toml");
            builder = builder.add_source(
                File::from(xdg_config).required(false)
            );
        }

        // CLI-specified config file
        if let Some(path) = cli_config_path {
            builder = builder.add_source(File::with_name(path));
        }

        // Environment variables with FQ_ prefix
        builder = builder.add_source(
            Environment::with_prefix("FQ").separator("_")
        );

        let config: AppConfig = builder.build()?.try_deserialize()?;
        Ok(config)
    }
}

The directories crate handles XDG Base Directory paths cross-platform. On Linux it uses ~/.config/fq/, on macOS ~/Library/Application Support/com.yourorg.fq/, and on Windows %APPDATA%\yourorg\fq\.

Example config file at ~/.config/fq/config.toml:

default_format = "table"
max_depth = 20
show_hidden = false
color = true
ignore_patterns = ["node_modules", ".git", "target", "dist"]

Users can override any setting with environment variables: FQ_MAX_DEPTH=5 fq search "TODO".

9. Testing

Rust's testing ecosystem for CLIs is excellent. Use three layers: unit tests for logic, integration tests with assert_cmd for end-to-end behavior, and snapshot tests with trycmd for output stability.

Unit tests - test your library functions directly:

// src/search.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn finds_pattern_in_line() {
        let result = match_line("hello world", "world", false);
        assert!(result.is_some());
        assert_eq!(result.unwrap().text, "hello world");
    }

    #[test]
    fn case_insensitive_match() {
        let result = match_line("Hello World", "hello", true);
        assert!(result.is_some());
    }

    #[test]
    fn no_match_returns_none() {
        let result = match_line("hello world", "xyz", false);
        assert!(result.is_none());
    }
}

assert_cmd - run your compiled binary and check output:

// tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
use std::fs;

#[test]
fn search_finds_pattern_in_file() {
    let dir = TempDir::new().unwrap();
    let file = dir.path().join("test.txt");
    fs::write(&file, "hello world\nfoo bar\nhello rust").unwrap();

    Command::cargo_bin("fq")
        .unwrap()
        .args(["search", "hello", file.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("hello world"))
        .stdout(predicate::str::contains("hello rust"));
}

#[test]
fn search_returns_exit_1_on_no_match() {
    let dir = TempDir::new().unwrap();
    let file = dir.path().join("test.txt");
    fs::write(&file, "nothing here").unwrap();

    Command::cargo_bin("fq")
        .unwrap()
        .args(["search", "missing", file.to_str().unwrap()])
        .assert()
        .code(1);
}

#[test]
fn json_output_is_valid() {
    let dir = TempDir::new().unwrap();
    let file = dir.path().join("test.txt");
    fs::write(&file, "hello world").unwrap();

    let output = Command::cargo_bin("fq")
        .unwrap()
        .args(["search", "hello", file.to_str().unwrap(), "-f", "json"])
        .output()
        .unwrap();

    let parsed: serde_json::Value =
        serde_json::from_slice(&output.stdout).unwrap();
    assert!(parsed.is_array());
}

#[test]
fn help_flag_shows_usage() {
    Command::cargo_bin("fq")
        .unwrap()
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("A fast file query tool"));
}

#[test]
fn version_flag_shows_version() {
    Command::cargo_bin("fq")
        .unwrap()
        .arg("--version")
        .assert()
        .success()
        .stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}

trycmd - snapshot testing for CLI output. Create .toml or .md files that describe expected input/output pairs:

# tests/snapshots/help.toml
bin.name = "fq"
args = ["--help"]
status.code = 0
stdout = """
A fast file query tool

Usage: fq <COMMAND>

Commands:
  search  Search files for a pattern
  count   Count matches across files
  list    List files matching a glob pattern
  config  Show or update configuration
  help    Print this message or the help of the given subcommand(s)
...
"""
// tests/snapshots.rs
#[test]
fn cli_snapshots() {
    trycmd::TestCases::new().case("tests/snapshots/*.toml");
}

trycmd compares actual output against snapshots and shows diffs on failure. Run with TRYCMD=overwrite to update snapshots after intentional changes. This catches accidental help text regressions and output format changes.

10. Cross-Compilation

Ship your CLI to every platform. The cross tool uses Docker containers with pre-configured toolchains for each target.

# Install cross
cargo install cross

# Build for Linux (musl for static linking)
cross build --release --target x86_64-unknown-linux-musl

# Build for macOS (from Linux)
cross build --release --target x86_64-apple-darwin
cross build --release --target aarch64-apple-darwin

# Build for Windows
cross build --release --target x86_64-pc-windows-gnu

GitHub Actions matrix build - automate cross-platform releases:

# .github/workflows/release.yml
name: Release
on:
  push:
    tags: ["v*"]

permissions:
  contents: write

jobs:
  build:
    strategy:
      matrix:
        include:
          - target: x86_64-unknown-linux-musl
            os: ubuntu-latest
            name: fq-linux-amd64
          - target: aarch64-unknown-linux-musl
            os: ubuntu-latest
            name: fq-linux-arm64
          - target: x86_64-apple-darwin
            os: macos-latest
            name: fq-macos-amd64
          - target: aarch64-apple-darwin
            os: macos-latest
            name: fq-macos-arm64
          - target: x86_64-pc-windows-msvc
            os: windows-latest
            name: fq-windows-amd64.exe

    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Install cross (Linux only)
        if: runner.os == 'Linux'
        run: cargo install cross

      - name: Build (Linux via cross)
        if: runner.os == 'Linux'
        run: cross build --release --target ${{ matrix.target }}

      - name: Build (native)
        if: runner.os != 'Linux'
        run: cargo build --release --target ${{ matrix.target }}

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.name }}
          path: target/${{ matrix.target }}/release/fq*

  release:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: "**/*"
          generate_release_notes: true

Use x86_64-unknown-linux-musl for Linux targets. The musl libc produces fully static binaries that run on any Linux distribution without shared library dependencies.

11. Distribution

Get your CLI into users' hands through multiple channels.

cargo install - the simplest distribution method. Publish to crates.io and users install with one command:

# Publish to crates.io
cargo publish

# Users install with:
cargo install fq

Homebrew - create a tap for macOS and Linux users:

# Formula/fq.rb
class Fq < Formula
  desc "A fast file query tool"
  homepage "https://github.com/yourorg/fq"
  version "0.1.0"

  on_macos do
    on_arm do
      url "https://github.com/yourorg/fq/releases/download/v0.1.0/fq-macos-arm64.tar.gz"
      sha256 "REPLACE_WITH_ACTUAL_SHA256"
    end
    on_intel do
      url "https://github.com/yourorg/fq/releases/download/v0.1.0/fq-macos-amd64.tar.gz"
      sha256 "REPLACE_WITH_ACTUAL_SHA256"
    end
  end

  on_linux do
    url "https://github.com/yourorg/fq/releases/download/v0.1.0/fq-linux-amd64.tar.gz"
    sha256 "REPLACE_WITH_ACTUAL_SHA256"
  end

  def install
    bin.install "fq"
  end

  test do
    assert_match "fq", shell_output("#{bin}/fq --version")
  end
end
# Users install with:
brew tap yourorg/tap
brew install fq

cargo-dist - automates the entire release pipeline. It generates GitHub Actions workflows, builds for all platforms, creates installers (shell script, PowerShell, Homebrew, MSI), and publishes GitHub Releases:

# Install cargo-dist
cargo install cargo-dist

# Initialize in your project
cargo dist init

# This adds to Cargo.toml:
# [workspace.metadata.dist]
# cargo-dist-version = "0.22"
# ci = "github"
# installers = ["shell", "powershell", "homebrew"]
# targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-pc-windows-msvc"]

# Generate the CI workflow
cargo dist generate

# Tag and push to trigger a release
git tag v0.1.0 && git push origin v0.1.0

cargo-dist is the recommended approach for new projects. It handles cross-compilation, checksums, installers, and release notes automatically. The frameworks guide covers more Rust build tooling.

12. Real-World Patterns

The best way to learn Rust CLI patterns is to study tools that millions of developers use daily. Here are the key architectural decisions from five standout projects.

ripgrep - the gold standard for Rust CLIs. Key patterns:

  • Library crate (grep-*) separated from binary crate for reuse
  • Memory-mapped file I/O for maximum throughput
  • Parallel directory walking with ignore crate (respects .gitignore)
  • Streaming output - prints matches as found, never buffers entire results
  • Custom regex engine (grep-regex) optimized for line-oriented search

bat - a cat replacement with syntax highlighting:

  • Uses syntect for syntax highlighting with custom theme support
  • Detects terminal capabilities (color support, pager availability)
  • Pipes to less automatically when output exceeds terminal height
  • Git integration shows modified lines in the gutter

fd - a find replacement:

  • Smart defaults: ignores hidden files and .gitignore patterns by default
  • Parallel execution with --exec using rayon thread pool
  • Regex-first design with optional glob mode
  • Colored output that degrades gracefully when piped

delta - a git diff viewer:

  • Reads from stdin (pipe-friendly design)
  • Extensive configuration via ~/.gitconfig integration
  • Side-by-side diff mode with terminal width detection
  • Syntax highlighting within diffs using syntect

zoxide - a smarter cd command:

  • Minimal binary size (aggressive dependency pruning)
  • Shell integration via init subcommand that outputs shell functions
  • SQLite database for frecency-based directory ranking
  • Cross-shell support (bash, zsh, fish, PowerShell, Nushell)
ToolBinary SizeKey CratesPattern Worth Stealing
ripgrep~5 MBclap, ignore, grep-regexStreaming output, parallel walking
bat~6 MBclap, syntect, git2Pager detection, theme system
fd~3 MBclap, ignore, rayonSmart defaults, parallel exec
delta~8 MBclap, syntect, regexStdin pipe design, gitconfig integration
zoxide~1 MBclap, rusqlite, dirsShell init pattern, frecency algorithm

Common patterns across all five: Clap for argument parsing, library/binary crate split, NO_COLOR support, streaming output over buffering, and smart defaults that work without configuration.

13. Complete Tutorial - Building fq

Let's put everything together and build fq, a practical file query tool, from scratch. This combines every pattern covered above into a working project.

Step 1: Create the project and add dependencies.

cargo new fq && cd fq
# Add dependencies to Cargo.toml as shown in Section 2

Step 2: Define the CLI interface.

// src/cli.rs
use clap::{Parser, Subcommand, Args, ValueEnum};

#[derive(Parser, Debug)]
#[command(name = "fq", version, about = "A fast file query tool")]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,

    #[arg(short, long, global = true, default_value_t = OutputFormat::Text)]
    pub format: OutputFormat,

    #[arg(long, global = true)]
    pub config: Option<String>,

    #[arg(short, long, global = true)]
    pub verbose: bool,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Search files for a regex pattern
    Search(SearchArgs),
    /// Count matches across files
    Count(SearchArgs),
    /// List files matching a glob
    List(ListArgs),
}

#[derive(Args, Debug)]
pub struct SearchArgs {
    /// Regex pattern
    pub pattern: String,
    /// Paths to search
    #[arg(default_value = ".")]
    pub paths: Vec<String>,
    /// Context lines around matches
    #[arg(short, long, default_value_t = 0)]
    pub context: usize,
    /// Include hidden files
    #[arg(long)]
    pub hidden: bool,
    /// Max directory depth
    #[arg(short, long, default_value_t = 10)]
    pub depth: usize,
    /// Show line numbers
    #[arg(short = 'n', long)]
    pub line_numbers: bool,
}

#[derive(Args, Debug)]
pub struct ListArgs {
    /// Glob pattern
    pub pattern: String,
    #[arg(default_value = ".")]
    pub path: String,
}

#[derive(ValueEnum, Clone, Debug, Default)]
pub enum OutputFormat {
    #[default]
    Text,
    Json,
    Table,
}

Step 3: Define error types.

// src/error.rs
use thiserror::Error;

#[derive(Error, Debug)]
pub enum FqError {
    #[error("invalid regex: {0}")]
    InvalidPattern(#[from] regex::Error),

    #[error("cannot read {path}: {source}")]
    FileRead {
        path: String,
        source: std::io::Error,
    },

    #[error("no matches found")]
    NoMatches,
}

Step 4: Implement the search engine.

// src/search.rs
use crate::cli::SearchArgs;
use crate::error::FqError;
use anyhow::{Context, Result};
use regex::Regex;
use serde::Serialize;
use std::fs;
use std::path::Path;

#[derive(Debug, Serialize)]
pub struct Match {
    pub file: String,
    pub line_num: usize,
    pub text: String,
}

pub fn search(args: &SearchArgs) -> Result<Vec<Match>> {
    let re = Regex::new(&args.pattern)
        .map_err(FqError::InvalidPattern)?;

    let mut results = Vec::new();

    for path in &args.paths {
        walk_and_search(
            Path::new(path),
            &re,
            args.depth,
            args.hidden,
            0,
            &mut results,
        )?;
    }

    Ok(results)
}

fn walk_and_search(
    path: &Path,
    re: &Regex,
    max_depth: usize,
    show_hidden: bool,
    current_depth: usize,
    results: &mut Vec<Match>,
) -> Result<()> {
    if current_depth > max_depth {
        return Ok(());
    }

    if path.is_file() {
        search_file(path, re, results)?;
    } else if path.is_dir() {
        let entries = fs::read_dir(path)
            .with_context(|| format!("cannot read directory: {}", path.display()))?;

        for entry in entries.flatten() {
            let entry_path = entry.path();
            let name = entry.file_name();
            let name_str = name.to_string_lossy();

            // Skip hidden files unless --hidden is set
            if !show_hidden && name_str.starts_with('.') {
                continue;
            }

            walk_and_search(
                &entry_path,
                re,
                max_depth,
                show_hidden,
                current_depth + 1,
                results,
            )?;
        }
    }

    Ok(())
}

fn search_file(path: &Path, re: &Regex, results: &mut Vec<Match>) -> Result<()> {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => return Ok(()), // Skip binary/unreadable files
    };

    for (i, line) in content.lines().enumerate() {
        if re.is_match(line) {
            results.push(Match {
                file: path.display().to_string(),
                line_num: i + 1,
                text: line.to_string(),
            });
        }
    }

    Ok(())
}

Step 5: Implement output formatting.

// src/output.rs
use crate::cli::OutputFormat;
use crate::search::Match;
use anyhow::Result;
use comfy_table::{Table, ContentArrangement, presets::UTF8_FULL};
use owo_colors::OwoColorize;
use std::io::IsTerminal;

pub fn print_results(results: &[Match], format: &OutputFormat) -> Result<()> {
    match format {
        OutputFormat::Text => print_text(results),
        OutputFormat::Json => print_json(results),
        OutputFormat::Table => print_table(results),
    }
}

fn print_text(results: &[Match]) -> Result<()> {
    let use_color = std::io::stdout().is_terminal();

    for m in results {
        if use_color {
            println!(
                "{}:{}: {}",
                m.file.purple(),
                m.line_num.to_string().green(),
                m.text
            );
        } else {
            println!("{}:{}:{}", m.file, m.line_num, m.text);
        }
    }
    Ok(())
}

fn print_json(results: &[Match]) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(results)?);
    Ok(())
}

fn print_table(results: &[Match]) -> Result<()> {
    let mut table = Table::new();
    table
        .load_preset(UTF8_FULL)
        .set_content_arrangement(ContentArrangement::Dynamic)
        .set_header(vec!["File", "Line", "Text"]);

    for m in results {
        table.add_row(vec![&m.file, &m.line_num.to_string(), &m.text]);
    }

    println!("{table}");
    Ok(())
}

Step 6: Wire it all together in main.rs.

// src/main.rs
use anyhow::Result;
use clap::Parser;
use fq::cli::{Cli, Commands};
use fq::output::print_results;
use fq::search;
use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(true) => ExitCode::SUCCESS,
        Ok(false) => ExitCode::from(1),
        Err(e) => {
            eprintln!("error: {e:#}");
            ExitCode::from(2)
        }
    }
}

fn run() -> Result<bool> {
    let cli = Cli::parse();

    match &cli.command {
        Commands::Search(args) => {
            let results = search::search(args)?;
            if results.is_empty() {
                return Ok(false);
            }
            print_results(&results, &cli.format)?;
            Ok(true)
        }
        Commands::Count(args) => {
            let results = search::search(args)?;
            println!("{}", results.len());
            Ok(!results.is_empty())
        }
        Commands::List(args) => {
            for entry in glob::glob(&args.pattern)? {
                println!("{}", entry?.display());
            }
            Ok(true)
        }
    }
}

// src/lib.rs
pub mod cli;
pub mod error;
pub mod search;
pub mod output;

Step 7: Build and test.

# Build
cargo build --release

# Run it
./target/release/fq search "fn main" src/
./target/release/fq search "TODO" . --format json
./target/release/fq count "unwrap" src/
./target/release/fq list "**/*.rs"

# Run tests
cargo test

# Build for release with all optimizations
# Add to Cargo.toml:
# [profile.release]
# lto = true
# codegen-units = 1
# strip = true
# panic = "abort"
cargo build --release

The release profile settings above produce the smallest possible binary. lto = true enables link-time optimization across all crates. strip = true removes debug symbols. panic = "abort" removes the unwinding machinery. Together these can reduce binary size by 50-70%.

Next steps: Add parallel directory walking with rayon, .gitignore support with the ignore crate, and shell completions with clap_complete. Study the ripgrep source for advanced patterns.