DEV Community

Young Gao
Young Gao

Posted on

Building a CLI Tool in Rust: From Zero to Published on crates.io

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
Enter fullscreen mode Exit fullscreen mode

This gives you:

rsearch/
  Cargo.toml
  src/
    main.rs
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
    })
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • WalkBuilder handles .gitignore parsing automatically
  • We filter by extension if the user passed --ext
  • Hidden files are skipped by default unless --hidden is 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: &regex::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)
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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"));
}

Enter fullscreen mode Exit fullscreen mode

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

  1. Create account on crates.io (GitHub login)
  2. cargo login your-api-token
  3. cargo publish --dry-run
  4. cargo 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)