
Getting Started with Rust: Building a Robust Text Search Tool
Why build a text search tool?
A text search tool is a useful first Rust project because it combines several core skills without requiring a large codebase:
- parsing command-line arguments
- reading files safely and efficiently
- working with
Resultand error propagation - writing clear, testable functions
- producing useful terminal output
Unlike a toy example, this project mirrors real developer workflows. You might use it to search logs, inspect source trees, or quickly locate configuration values in a large repository.
What we are building
The tool will:
- accept a search pattern and one or more file paths
- print matching lines with line numbers
- support case-sensitive matching by default
- optionally perform case-insensitive searches
A sample invocation might look like this:
cargo run -- --ignore-case "error" app.logExpected output:
42: error connecting to database
87: ERROR retry limit reachedCreate the project
Start a new binary crate:
cargo new text_search
cd text_searchRust projects use Cargo.toml for dependencies and build metadata, and src/main.rs for the executable entry point.
For this tutorial, we will keep dependencies minimal and use only the standard library.
Design the command-line interface
A clean CLI makes the tool easier to use and easier to extend later. We will support this argument shape:
text_search [--ignore-case] <pattern> <file>For a first version, manual argument parsing is enough. It keeps the example focused on Rust fundamentals and avoids introducing a CLI framework too early.
Argument parsing strategy
We will:
- collect the process arguments
- detect an optional
--ignore-caseflag - read the pattern and file path
- validate the input before doing any file I/O
This approach keeps errors immediate and predictable.
Implement the search logic
The core of the tool is a function that checks whether a line matches a pattern. We will implement both case-sensitive and case-insensitive modes.
use std::env;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
fn main() {
if let Err(err) = run() {
eprintln!("error: {err}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::from_args(env::args())?;
search_file(&config)?;
Ok(())
}
struct Config {
pattern: String,
file_path: String,
ignore_case: bool,
}
impl Config {
fn from_args<I>(args: I) -> Result<Self, &'static str>
where
I: IntoIterator<Item = String>,
{
let mut args = args.into_iter();
let _program = args.next();
let mut ignore_case = false;
let mut remaining = Vec::new();
for arg in args {
if arg == "--ignore-case" {
ignore_case = true;
} else {
remaining.push(arg);
}
}
if remaining.len() != 2 {
return Err("usage: text_search [--ignore-case] <pattern> <file>");
}
Ok(Self {
pattern: remaining[0].clone(),
file_path: remaining[1].clone(),
ignore_case,
})
}
}
fn search_file(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
let file = File::open(&config.file_path)?;
let reader = BufReader::new(file);
for (line_number, line_result) in reader.lines().enumerate() {
let line = line_result?;
if matches_pattern(&line, &config.pattern, config.ignore_case) {
println!("{}: {}", line_number + 1, line);
}
}
Ok(())
}
fn matches_pattern(line: &str, pattern: &str, ignore_case: bool) -> bool {
if ignore_case {
line.to_lowercase().contains(&pattern.to_lowercase())
} else {
line.contains(pattern)
}
}Understand the code structure
The example is intentionally small, but each piece has a clear responsibility.
| Component | Responsibility | Why it matters |
|---|---|---|
main | Entry point and top-level error reporting | Keeps the user-facing behavior simple |
run | Orchestrates parsing and searching | Makes the program easier to test |
Config | Stores validated input | Separates input handling from business logic |
search_file | Reads the file and prints matches | Encapsulates I/O behavior |
matches_pattern | Determines whether a line matches | Easy to reuse and unit test |
This separation is one of Rust’s strengths. Small functions with explicit inputs and outputs are easier to reason about, especially when error handling is involved.
Run the tool
Create a sample file:
cat > sample.txt <<'EOF'
Rust is fast
Error handling is explicit
The compiler is helpful
EOFSearch for a pattern:
cargo run -- "Rust" sample.txtOutput:
1: Rust is fastTry case-insensitive matching:
cargo run -- --ignore-case "error" sample.txtOutput:
2: Error handling is explicitWhy BufReader is the right choice
When reading text files, BufReader is usually the correct default. It reduces the number of system calls by buffering input internally, which is especially useful for large files.
Using std::fs::read_to_string can be simpler, but it loads the entire file into memory. That is fine for small inputs, but a line-by-line approach is more scalable and more representative of production tooling.
When to choose each approach
| Approach | Best for | Trade-offs |
|---|---|---|
read_to_string | Small files, quick prototypes | Higher memory usage |
BufReader::lines() | Logs, source trees, large text files | Slightly more code |
| Memory-mapped I/O | Very large files, specialized tools | More complexity, platform concerns |
For a beginner-friendly Rust utility, BufReader::lines() is the best balance of simplicity and performance.
Error handling best practices
Rust encourages explicit error handling, and that is a major advantage in CLI tools. In this example, run() returns Result<(), Box<dyn std::error::Error>>, which allows different error types to propagate with ?.
This pattern is practical because:
- file open failures are reported automatically
- line-reading errors are not ignored
- the user sees a clear error message
mainremains small and readable
Improve the error message
The current version prints a generic error. For a production-quality tool, you can make errors more specific by matching on common failure cases.
For example:
fn main() {
if let Err(err) = run() {
eprintln!("text_search failed: {err}");
std::process::exit(1);
}
}You can also distinguish usage errors from I/O errors. A usage error should show a short help message, while an I/O error should explain which file could not be opened.
Add unit tests
A search tool is easy to test because the matching logic is pure. You can verify behavior without touching the file system.
Add tests at the bottom of src/main.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_case_sensitive_text() {
assert!(matches_pattern("Rust is great", "Rust", false));
assert!(!matches_pattern("Rust is great", "rust", false));
}
#[test]
fn matches_case_insensitive_text() {
assert!(matches_pattern("Rust is great", "rust", true));
assert!(matches_pattern("ERROR: failed", "error", true));
}
}Run the tests with:
cargo testTesting the pure matching function gives you confidence that the core logic works before you worry about file input or terminal output.
Practical improvements for real use
Once the basic version works, there are several useful enhancements you can add.
1. Highlight matches
You can make output easier to scan by coloring the matching portion. This is helpful in terminal workflows, but it should be optional because not all terminals handle ANSI colors the same way.
2. Search multiple files
Instead of accepting one file, accept a list of paths and print the file name before each match. This makes the tool much more useful in a repository.
Example output:
src/main.rs:12: fn main() {
README.md:4: Rust is fast and safe3. Add context lines
Many search tools print a few lines before and after each match. This is useful when the surrounding text matters, such as in logs or configuration files.
4. Support regular expressions
A literal substring search is simple and fast, but regular expressions are more flexible. If you add regex support later, keep the current literal search path as a fast default.
Common mistakes to avoid
A few issues are common when building this kind of tool:
- Loading huge files into memory when a streaming reader would be better
- Ignoring I/O errors from
lines() - Mixing parsing and searching logic in
main - Using
to_lowercase()on every line repeatedly in performance-sensitive code - Printing unstructured output that is hard to consume in scripts
A small amount of structure goes a long way. Even a simple utility benefits from predictable behavior and clean boundaries.
A note on performance
For most beginner projects, correctness and clarity matter more than micro-optimization. Still, it is worth understanding the cost of the current implementation.
The case-insensitive search uses to_lowercase() on both the line and the pattern for each comparison. That is fine for a tutorial, but it allocates new strings repeatedly. If you later need better performance, you can precompute the lowercase pattern once and use a more efficient comparison strategy.
The important lesson is that Rust lets you start with a straightforward implementation and refine it later without changing the overall design.
Next steps
This project is a solid introduction to Rust because it touches the language features you will use constantly in real applications:
- ownership and borrowing through string and file handling
- error propagation with
Result - iterators with
lines().enumerate() - modular design with small functions
- testing pure logic separately from I/O
From here, you can evolve the tool into a more complete utility by adding flags, better output formatting, and recursive directory traversal.
