CLI Tool
Build a file search CLI that finds files matching a pattern. Uses clap for argument parsing and anyhow for error handling.
Create the Project
cargo new filesearch
cd filesearch
cargo add clap -F derive
cargo add anyhow
Define Arguments with clap
clap with derive macros turns a struct into a full argument parser with help text, validation, and shell completions.
// src/main.rs
use clap::Parser;
/// Search for files matching a pattern
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
/// Pattern to search for in filenames
pattern: String,
/// Directory to search in
#[arg(short, long, default_value = ".")]
dir: String,
/// Search file contents instead of names
#[arg(short, long)]
contents: bool,
/// Maximum search depth
#[arg(long, default_value_t = 10)]
max_depth: usize,
}
fn main() {
let args = Args::parse();
println!("{:?}", args);
}
Test it:
cargo run -- "*.rs"
cargo run -- --help
cargo run -- -c "TODO" --dir src/
Walk the Directory Tree
Use std::fs to recursively search directories.
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
fn search_filenames(dir: &Path, pattern: &str, depth: usize, max_depth: usize) -> Result<Vec<String>> {
let mut matches = Vec::new();
if depth > max_depth {
return Ok(matches);
}
let entries = fs::read_dir(dir)
.with_context(|| format!("failed to read directory: {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if path.is_dir() {
// Skip hidden directories
if name.starts_with('.') {
continue;
}
let sub_matches = search_filenames(&path, pattern, depth + 1, max_depth)?;
matches.extend(sub_matches);
} else if name.contains(pattern) {
matches.push(path.display().to_string());
}
}
Ok(matches)
}
Search File Contents
Read files and look for pattern matches line by line.
fn search_contents(dir: &Path, pattern: &str, depth: usize, max_depth: usize) -> Result<Vec<Match>> {
let mut matches = Vec::new();
if depth > max_depth {
return Ok(matches);
}
let entries = fs::read_dir(dir)
.with_context(|| format!("failed to read directory: {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
if path.is_dir() {
if name.to_string_lossy().starts_with('.') {
continue;
}
let sub = search_contents(&path, pattern, depth + 1, max_depth)?;
matches.extend(sub);
} else if path.is_file() {
// Try to read as text, skip binary files
if let Ok(content) = fs::read_to_string(&path) {
for (line_num, line) in content.lines().enumerate() {
if line.contains(pattern) {
matches.push(Match {
path: path.display().to_string(),
line_number: line_num + 1,
line: line.to_string(),
});
}
}
}
}
}
Ok(matches)
}
#[derive(Debug)]
struct Match {
path: String,
line_number: usize,
line: String,
}
Put It All Together
use std::fs;
use std::path::Path;
use std::process;
use anyhow::{Context, Result};
use clap::Parser;
/// Search for files matching a pattern
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
/// Pattern to search for
pattern: String,
/// Directory to search in
#[arg(short, long, default_value = ".")]
dir: String,
/// Search file contents instead of names
#[arg(short, long)]
contents: bool,
/// Maximum search depth
#[arg(long, default_value_t = 10)]
max_depth: usize,
}
fn main() {
if let Err(e) = run() {
eprintln!("Error: {e:#}");
process::exit(1);
}
}
fn run() -> Result<()> {
let args = Args::parse();
let dir = Path::new(&args.dir);
if args.contents {
let matches = search_contents(dir, &args.pattern, 0, args.max_depth)?;
if matches.is_empty() {
println!("No matches found.");
}
for m in &matches {
println!("{}:{}: {}", m.path, m.line_number, m.line.trim());
}
println!("\n{} matches in {} files",
matches.len(),
matches.iter().map(|m| &m.path).collect::<std::collections::HashSet<_>>().len()
);
} else {
let matches = search_filenames(dir, &args.pattern, 0, args.max_depth)?;
if matches.is_empty() {
println!("No files matching '{}'", args.pattern);
}
for path in &matches {
println!("{path}");
}
println!("\n{} files found", matches.len());
}
Ok(())
}
// ... include search_filenames and search_contents from above
# // placeholder for the search functions
Tip: The
run()pattern keepsmain()clean. It returnsResult, so you can use?everywhere. Themain()function just handles printing the error and setting the exit code.
Build a Release Binary
cargo build --release
# Binary at: target/release/filesearch
# Test it
./target/release/filesearch "main" --dir src/ --contents
Gotcha: Debug builds are 10-100x slower than release builds. Always benchmark and distribute release builds.
Argument Types Reference
Common clap derive patterns:
#[derive(Parser)]
struct Args {
/// Required positional argument
input: String,
/// Optional positional argument
output: Option<String>,
/// Flag (true if present)
#[arg(short, long)]
verbose: bool,
/// Option with value
#[arg(short, long)]
format: Option<String>,
/// Option with default
#[arg(long, default_value = "json")]
output_format: String,
/// Multiple values
#[arg(short, long)]
exclude: Vec<String>,
/// Enum-based choices
#[arg(long, value_enum, default_value_t = Color::Auto)]
color: Color,
}
#[derive(Clone, Debug, clap::ValueEnum)]
enum Color {
Auto,
Always,
Never,
}
Next Steps
Once you have a working CLI:
- Add
coloredfor terminal colors - Add
indicatiffor progress bars - Add
rayonfor parallel search (par_iter()) - Publish to crates.io with
cargo publish
Next: Setup | Error Handling