Build Rust CLI Tools with Clap in 2026
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.
| Feature | Rust | Go | Python |
|---|---|---|---|
| Binary size (hello world) | ~300 KB (stripped) | ~2 MB | N/A (needs interpreter) |
| Startup time | <1 ms | ~5 ms | ~30 ms |
| Memory usage | Minimal (no GC) | ~8 MB (GC overhead) | ~15 MB |
| Compile time | Slower (minutes) | Fast (seconds) | None |
| Cross-compilation | cross tool / targets | GOOS/GOARCH | PyInstaller (fragile) |
| Dependency management | Cargo (excellent) | Go modules | pip/poetry |
| Error handling | Result/Option types | Multiple returns | Exceptions |
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-vand--verboseflags#[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.
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
ignorecrate (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
syntectfor syntax highlighting with custom theme support - Detects terminal capabilities (color support, pager availability)
- Pipes to
lessautomatically 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
--execusingrayonthread 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
~/.gitconfigintegration - 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
initsubcommand that outputs shell functions - SQLite database for frecency-based directory ranking
- Cross-shell support (bash, zsh, fish, PowerShell, Nushell)
| Tool | Binary Size | Key Crates | Pattern Worth Stealing |
|---|---|---|---|
| ripgrep | ~5 MB | clap, ignore, grep-regex | Streaming output, parallel walking |
| bat | ~6 MB | clap, syntect, git2 | Pager detection, theme system |
| fd | ~3 MB | clap, ignore, rayon | Smart defaults, parallel exec |
| delta | ~8 MB | clap, syntect, regex | Stdin pipe design, gitconfig integration |
| zoxide | ~1 MB | clap, rusqlite, dirs | Shell 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%.
clap_complete. Study the ripgrep source for advanced patterns.