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 keeps main() clean. It returns Result, so you can use ? everywhere. The main() 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:

  1. Add colored for terminal colors
  2. Add indicatif for progress bars
  3. Add rayon for parallel search (par_iter())
  4. Publish to crates.io with cargo publish

Next: Setup | Error Handling