Building a CLI Tool in Rust: From Zero to Published on crates.io
Why Build a CLI Tool in Rust?
Rust is an excellent language for CLI tools. It compiles to a single static binary, starts instantly, and gives you memory safety without a garbage collector.
Many beloved tools like ripgrep, fd, bat, and exa are written in Rust.
In this tutorial, we will build rsearch -- a fast file content searcher with colored output. By the end, you will have a tool published on crates.io that others can install with cargo install.
Step 1: Project Setup
cargo new rsearch
cd rsearch
This gives you:
rsearch/
Cargo.toml
src/
main.rs
Step 2: Dependencies in Cargo.toml
Open Cargo.toml and add the crates we need:
[package]
name = "rsearch"
version = "0.1.0"
edition = "2021"
description = "A fast file content searcher with colored output"
license = "MIT"
[dependencies]
clap = { version = "4", features = ["derive"] }
regex = "1"
colored = "2"
anyhow = "1"
ignore = "0.4"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
Here is what each crate does:
| Crate | Purpose |
|---|---|
clap |
Argument parsing with derive macros |
regex |
Regular expression matching |
colored |
Terminal color output |
anyhow |
Ergonomic error handling |
ignore |
Respects .gitignore while walking directories |
The [profile.release] section enables aggressive binary size optimization -- we will revisit this at the end.
Step 3: Argument Parsing with Clap Derive API
Clap derive API lets you define your CLI interface as a struct. Replace the contents of src/main.rs:
use anyhow::{Context, Result};
use clap::Parser;
use colored::Colorize;
use ignore::WalkBuilder;
use regex::Regex;
use std::fs;
use std::path::PathBuf;
/// rsearch - A fast file content searcher
#[derive(Parser, Debug)]
#[command(name = "rsearch", version, about)]
struct Args {
/// The regex pattern to search for
pattern: String,
/// Directory to search in
#[arg(default_value = ".")]
path: PathBuf,
/// File extension filter
#[arg(short, long)]
ext: Option<String>,
/// Show line numbers
#[arg(short = 'n', long, default_value_t = true)]
line_numbers: bool,
/// Maximum search depth
#[arg(short, long)]
depth: Option<usize>,
/// Include hidden files
#[arg(long)]
hidden: bool,
/// Case-insensitive search
#[arg(short = 'i', long)]
ignore_case: bool,
}
Every field becomes a CLI flag. Clap auto-generates --help and --version for free.
Running rsearch --help will output:
rsearch - A fast file content searcher
Usage: rsearch [OPTIONS] <PATTERN> [PATH]
Arguments:
<PATTERN> The regex pattern to search for
[PATH] Directory to search in [default: .]
Options:
-e, --ext <EXT> File extension filter
-n, --line-numbers Show line numbers
-d, --depth <DEPTH> Maximum search depth
--hidden Include hidden files
-i, --ignore-case Case-insensitive search
-h, --help Print help
-V, --version Print version
Step 4: File Walking with the ignore Crate
The ignore crate (from the ripgrep family) walks directories while automatically respecting .gitignore rules.
fn walk_files(args: &Args) -> impl Iterator<Item = PathBuf> + '_ {
let mut builder = WalkBuilder::new(&args.path);
builder.hidden(!args.hidden);
if let Some(depth) = args.depth {
builder.max_depth(Some(depth));
}
let ext_filter = args.ext.clone();
builder.build().filter_map(move |entry| {
let entry = entry.ok()?;
let path = entry.path();
if !path.is_file() {
return None;
}
if let Some(ref ext) = ext_filter {
if path.extension()?.to_str()? != ext.as_str() {
return None;
}
}
Some(path.to_path_buf())
})
}
Key points:
-
WalkBuilderhandles.gitignoreparsing automatically - We filter by extension if the user passed
--ext - Hidden files are skipped by default unless
--hiddenis set - The iterator is lazy -- files are yielded on demand
Step 5: Regex Search with Colored Output
Now the core search logic. For each file, we read its contents, search line by line, and print matches with color highlighting:
fn search_file(
path: &PathBuf,
re: &Regex,
show_line_nums: bool,
) -> Result<Vec<String>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let mut matches = Vec::new();
for (line_num, line) in content.lines().enumerate() {
if re.is_match(line) {
let highlighted = re.replace_all(
line,
|caps: ®ex::Captures| {
caps[0].red().bold().to_string()
},
);
let formatted = if show_line_nums {
format!(
"{}:{} {}",
path.display().to_string().green(),
(line_num + 1).to_string().yellow(),
highlighted
)
} else {
format!(
"{}: {}",
path.display().to_string().green(),
highlighted
)
};
matches.push(formatted);
}
}
Ok(matches)
}
The output looks like: src/main.rs:42 fn search_file(...) where the filename is green, the line number is yellow, and the matched text is bold red.
Step 6
test
Main fn uses anyhow Result for error handling.
Step 7: Unit Tests
Add a tests module at the bottom of main.rs:
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_search_finds_match() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "hello world").unwrap();
writeln!(file, "foo bar").unwrap();
writeln!(file, "hello rust").unwrap();
let re = Regex::new("hello").unwrap();
let path = file.path().to_path_buf();
let results = search_file(&path, &re, false).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_search_no_match() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "nothing here").unwrap();
let re = Regex::new("xyz").unwrap();
let results = search_file(&file.path().to_path_buf(), &re, false).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_case_insensitive() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "Hello World").unwrap();
let re = Regex::new("(?i)hello").unwrap();
let results = search_file(&file.path().to_path_buf(), &re, false).unwrap();
assert_eq!(results.len(), 1);
}
}
Run them with cargo test. Each test creates a temp file and verifies search behavior.
Step 8: Integration Tests with assert_cmd
Create tests/integration.rs for end-to-end testing:
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_search_in_directory() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.txt"), "hello world
foo bar
hello rust
").unwrap();
Command::cargo_bin("rsearch").unwrap()
.args(&["hello", dir.path().to_str().unwrap()])
.assert().success()
.stdout(predicate::str::contains("hello"));
}
assert_cmd tests the real compiled binary.
Step 9: Publishing to crates.io
Binary Size Optimization
| Option | Effect |
|---|---|
| opt-level z | Optimize for size |
| lto true | Link-Time Optimization |
| codegen-units 1 | Better optimization |
| strip true | Remove debug symbols |
| panic abort | Smaller binary |
A typical Rust CLI drops from 4MB to 1.5MB with these settings.
Publishing Steps
- Create account on crates.io (GitHub login)
cargo login your-api-tokencargo publish --dry-runcargo publish
Now anyone can install: cargo install rsearch
Wrapping Up
We built a real CLI tool in Rust from scratch:
- Clap derive gave us a polished CLI interface
- ignore handles .gitignore rules automatically
- regex + colored produce scannable colored output
- anyhow made error handling clean
- assert_cmd let us write integration tests
- Release profile cut binary size significantly
The resulting binary starts in under 5ms and can be installed with a single command. Happy hacking!
If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.
Top comments (0)