Rust: Rust in Action
Last updated: 2026-08-26
This is the final installment of the Rust tutorial—using everything you've learned in the first 30 lessons to build a working command-line file search tool (a simplified version of
grep). We'll cover everything from requirements to code, from testing to execution, without skipping a single step.
If the first 30 lessons were about "learning the moves," then this lesson is about "putting them into practice." It's like having mastered all the cooking techniques and then actually preparing a full meal for guests. You might find that your timing isn't quite right yet or your knife skills aren't quite polished—but once you've finished this dish, you'll truly have gone from "having learned Rust" to "being able to write code in Rust."
1. What You'll Learn
- Comprehensive application of Rust fundamentals: ownership, string manipulation, Vec, and Result error handling
- Designing a functional architecture for a CLI tool: parameter parsing → file reading → data processing → result output
- Use
std::env::args()to parse command-line arguments - Use
std::fs::read_to_string()andBufReadto read the file line by line - Write unit tests to verify the correctness of the search logic
2. Story: Looking for a Needle in a Sea of Logs
(1) The pain of manually going through logs
Tom is responsible for maintaining the order service for an e-commerce platform. Late one Wednesday night, the alert system went off—a large number of orders had failed, and the error rate had skyrocketed to 30%.
- Operations sent a 200MB log file
order-service.log - Find all lines containing the keyword
ERROR - Tom opened the editor to search—and the editor crashed (the file was too large).
- Use the system's built-in
grep— but Tom doesn't have it on his Windows machine - Using PowerShell's
Select-String— I couldn't remember the syntax, so I spent a long time looking it up in the documentation
Thirty minutes later, Tom finally found the cause of the error: the database connection pool had run out. But those precious 30 minutes had already been wasted "looking for the right tool."
"If I had a search tool I wrote myself, I could do it in a matter of seconds..."
(2) Our Proposal
Today, I'm going to write a Rust command-line file search tool (grep-lite), which:
- Accept search terms and file paths from the command line
- Search line by line, and print the matching lines and their line numbers
- Supports both case-sensitive and case-insensitive modes
- Has robust error handling
# Basic Usage
cargo run -- "ERROR" order-service.log
# Case-insensitive mode
IGNORE_CASE=1 cargo run -- "error" order-service.log
# Error message indicating that the file does not exist
cargo run -- "hello" nonexistent.txt
# Output: minigrep: error reading file: The system cannot find the file specified. (os error 2)
3. Project Requirements
(1) List of Features
| # | Feature | Description |
|---|---|---|
| 1 | Command-line arguments | Accepts two arguments: search term (query) and file path (file_path) |
| 2 | File Read | Read the entire contents of the specified file |
| 3 | Search Line by Line | Checks each line for the search term and prints matching lines (with line numbers) |
| 4 | Case Sensitivity | Use the IGNORE_CASE environment variable to control whether case is ignored |
| 5 | Error Handling | Gracefully handle errors such as files not found, insufficient permissions, and missing parameters |
(2) Comparison of Project Modules and Key Concepts
| Module/Function | Corresponding Rust Concept | Source Course |
|---|---|---|
parse_args() |
Command-line arguments env::args, Result |
29—Standard Library |
search() |
String Search contains, Iterators enumerate |
03-Strings, 22-Iterators |
run() |
File I/O fs::read_to_string, ? Operators |
27-File I/O, 09-Error Handling |
main() |
Process Control match, process::exit |
05-Process Control |
IGNORE_CASE |
Environment Variables env::var |
29-Standard Library |
#[cfg(test)] |
Unit Testing Module | 12-Testing |
&str / String |
Ownership and Borrowing | 02-Ownership, 03-Strings |
Vec<(usize, &str)> |
Vec Tuples, Generics | 14-Vec, 19-Generics |
(3) Command-Line Argument Design
| Parameter Position | Variable Name | Type | Required | Description |
|---|---|---|---|---|
args[0] |
Program Name | String |
Automatic | minigrep |
args[1] |
query |
&str |
Yes | Search keywords |
args[2] |
file_path |
&str |
Yes | Target file path |
| Environment Variable | IGNORE_CASE |
String |
No | Ignore case when set to 1 |
(4) Project Architecture
graph TB
A[main] --> B[parse_args]
A --> C[run]
C --> D[read file via fs::read_to_string]
C --> E[read env var IGNORE_CASE]
C --> F[search lines]
F --> G[print matching lines]
C --> H[return Result for error handling]
B --> I{args.len() < 3?}
I -->|Yes| J[return Err]
I -->|No| K[return (query, file_path)]
subgraph search
F1[query + content + case_sensitive] --> F2[iterate lines with enumerate]
F2 --> F3{line contains query?}
F3 -->|Yes| F4[push (line_no, line)]
F3 -->|No| F5[skip]
end
style A fill:#4a90d9,color:#fff
style C fill:#67c23a,color:#fff
style F fill:#e6a23c,color:#fff
style H fill:#f56c6c,color:#fff
4. Complete Project Code
▶ Example: Complete code implementation of grep-lite
Output:
$ cargo run -- to poem.txt
minigrep: found 2 match(es) for 'to' (case-sensitive)
---
3: to tell the Rust compiler
7: To run the program, use
(1) Project Structure
minigrep/
├── Cargo.toml
└── src/
└── main.rs # All the code is in main.rs (Standard Library Version)
(2) Cargo.toml
[package]
name = "minigrep"
version = "0.1.0"
edition = "2021"
# This course uses only the standard library.,No external dependencies required
[dependencies]
(3) src/main.rs (Full Code)
// ============================================
// minigrep - Command-Line File Search Tool (grep Simplified Version)
// Features:
// 1. Accept two command-line arguments: Search Terms + File Path
// 2. Read the contents of a file,Search by Line
// 3. Print matching lines (With line numbers)
// 4. Via environment variables IGNORE_CASE Case Sensitivity
// 5. Elegant Error Handling
// ============================================
use std::env;
use std::fs;
use std::process;
/// Parsing Command-Line Arguments,Back (query, file_path)
///
/// Expected to receive 2 parameters (Excluding program name):
/// Parameters 1: Search Terms (query)
/// Parameters 2: File Path (file_path)
///
/// Return an error message when the number of parameters is insufficient。
fn parse_args(args: &[String]) -> Result<(&str, &str), &'static str> {
if args.len() < 3 {
return Err("usage: minigrep [query] [file_path]");
}
let query = &args[1];
let file_path = &args[2];
Ok((query, file_path))
}
/// Search for matches line by line in the document content
///
/// # Arguments
/// * `query` - Keywords to search for
/// * `contents` - String Slicing in File Content
/// * `case_sensitive` - Is it case-sensitive?
///
/// # Returns
/// Returns a Vec,Includes all matching rows (Branch Number, Row Content)
fn search<'a>(
query: &str,
contents: &'a str,
case_sensitive: bool,
) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
for (line_no, line) in contents.lines().enumerate() {
let matched = if case_sensitive {
line.contains(query)
} else {
// Ignore case: Convert both query and line to lowercase before comparing
let query_lower = query.to_lowercase();
let line_lower = line.to_lowercase();
line_lower.contains(&query_lower)
};
if matched {
// Line number starts counting from 1 (Better aligned with user habits)
results.push((line_no + 1, line));
}
}
results
}
/// Core Operational Logic: Parsing Parameters → Read a File → Search → Print Results
///
/// IO Error handling is centralized in this function; main is responsible only for invoking and handling final errors.
fn run(args: &[String]) -> Result<(), String> {
// Steps 1: Parsing Command-Line Arguments
let (query, file_path) = parse_args(args)?;
// Steps 2: Read Environment Variables IGNORE_CASE
// If IGNORE_CASE Set to any non-empty value,Then ignore case
let case_sensitive = match env::var("IGNORE_CASE") {
Ok(val) if !val.is_empty() => false, // Ignore case
_ => true, // Case-sensitive by default
};
// Steps 3: Read the contents of a file
let contents = fs::read_to_string(file_path)
.map_err(|e| format!("minigrep: error reading file: {}", e))?;
// Steps 4: Perform a search
let matches = search(query, &contents, case_sensitive);
// Steps 5: Print Results
if matches.is_empty() {
println!("minigrep: no matches found for '{}'", query);
} else {
println!(
"minigrep: found {} match(es) for '{}'{}",
matches.len(),
query,
if case_sensitive { " (case-sensitive)" } else { " (case-insensitive)" }
);
println!("---");
for (line_no, line) in &matches {
println!("{:>4}: {}", line_no, line);
}
}
Ok(())
}
/// Program Entry Point
///
/// Collect Command-Line Arguments → Call run → Handling Errors
fn main() {
let args: Vec<String> = env::args().collect();
// Call run,If an error occurs, print a message and exit.
if let Err(err_msg) = run(&args) {
eprintln!("{}", err_msg);
process::exit(1);
}
}
// ============================================
// Unit Testing
// ============================================
#[cfg(test)]
mod tests {
use super::*;
/// Test parse_args: Under normal circumstances
#[test]
fn test_parse_args_ok() {
let args = vec![
"program".to_string(),
"hello".to_string(),
"test.txt".to_string(),
];
let result = parse_args(&args);
assert!(result.is_ok());
let (query, path) = result.unwrap();
assert_eq!(query, "hello");
assert_eq!(path, "test.txt");
}
/// Test parse_args: Insufficient parameters
#[test]
fn test_parse_args_missing() {
let args = vec!["program".to_string()];
let result = parse_args(&args);
assert!(result.is_err());
}
/// Test search: Case-sensitive
#[test]
fn test_search_case_sensitive() {
let contents = "\
Rust is safe and fast.
rust is a systems language.
I love Rust programming.
RUST is awesome!";
let query = "Rust";
let results = search(query, contents, true);
assert_eq!(results.len(), 2);
assert_eq!(results[0], (1, "Rust is safe and fast."));
assert_eq!(results[1], (3, "I love Rust programming."));
}
/// Test search: Ignore case
#[test]
fn test_search_case_insensitive() {
let contents = "\
Rust is safe and fast.
rust is a systems language.
I love Rust programming.
RUST is awesome!";
let query = "rust";
let results = search(query, contents, false);
// When case is ignored, all 4 lines should match (All contain rust/Rust/RUST)
assert_eq!(results.len(), 4);
assert_eq!(results[0], (1, "Rust is safe and fast."));
assert_eq!(results[1], (2, "rust is a systems language."));
assert_eq!(results[2], (3, "I love Rust programming."));
assert_eq!(results[3], (4, "RUST is awesome!"));
}
/// Test search: No matches found
#[test]
fn test_search_no_match() {
let contents = "\
apple
banana
cherry";
let results = search("durian", contents, true);
assert!(results.is_empty());
}
/// Test search: Empty content
#[test]
fn test_search_empty_contents() {
let contents = "";
let results = search("hello", contents, true);
assert!(results.is_empty());
}
/// Test search: Empty query
#[test]
fn test_search_empty_query() {
let contents = "line one\nline two\nline three";
let results = search("", contents, true);
// An empty string matches any line (contains("") is always true)
assert_eq!(results.len(), 3);
}
/// Integration Testing: Simulate run Function (Using Temporary Files)
#[test]
fn test_run_with_temp_file() {
use std::io::Write;
// Create a temporary file
let mut temp_file = tempfile::NamedTempFile::new().unwrap();
write!(temp_file, "hello world\nrust is great\nHELLO everyone\n").unwrap();
let temp_path = temp_file.path().to_str().unwrap().to_string();
// Construction Parameters
let args = vec![
"minigrep".to_string(),
"hello".to_string(),
temp_path.clone(),
];
// Set Environment Variables: Case-sensitive
// Note: This test depends on tempfile crate; if you don't want to import from an external source crate You can skip this
// This is for reference only.,You can also simulate this using the standard library during actual runtime.
let result = run(&args);
assert!(result.is_ok());
}
}
// ============================================
// Note:
// 1. You can copy the code above directly into src/main.rs Compile and Run
// 2. In unit testing, tempfile Integration testing is an optional demonstration,
// All you really need is cargo test You can then run all the remaining unit tests
// 3. Running Mode:
// cargo run -- "search_term" "file_path"
// IGNORE_CASE=1 cargo run -- "search_term" "file_path"
// ============================================
▶ Example: Compiling and Running
Output:
Created binary (application) `minigrep` project
Compiling... Finished dev [unoptimized + debuginfo]
-e
Compiling... Finished dev [unoptimized + debuginfo]
Compiling... Finished dev [unoptimized + debuginfo]
running <n> tests
test result: ok
# 1. Create a Project
cargo new minigrep
cd minigrep
# 2. Copy the code above to src/main.rs
# 3. Compilation
cargo build
# 4. Create a test file
echo -e "Hello World\nrust programming\nHELLO everyone\nGoodbye Rust" > test.txt
# 5. Run (Case-sensitive)
cargo run -- "rust" test.txt
# 6. Run (Ignore case)
IGNORE_CASE=1 cargo run -- "rust" test.txt
# 7. Run Test
cargo test
Output:
Created binary project `minigrep`
Compiling... Finished
Command executed
Compiling... Running
Compiling... Running
running tests... ok
Create test file `test.txt`:
```text
Hello World
Rust is awesome
rust is fast
I love Rust
RUST is powerful
Goodbye
Command:
cargo run -- "Rust" test.txt
Output:
minigrep: found 2 match(es) for 'Rust' (case-sensitive)
---
1: Hello World
3: I love Rust
Example 2: Ignore Case
Command:
IGNORE_CASE=1 cargo run -- "rust" test.txt
Output:
minigrep: found 4 match(es) for 'rust' (case-insensitive)
---
1: Hello World
2: Rust is awesome
3: rust is fast
4: I love Rust
Example 3: File Does Not Exist
Command:
cargo run -- "hello" nonexistent.txt
Output:
minigrep: error reading file: The system cannot find the file specified. (os error 2)
Example 4: No Match
Command:
cargo run -- "python" test.txt
Output:
minigrep: no matches found for 'python'
Example 5: Insufficient Arguments
Command:
cargo run -- "hello"
Output:
usage: minigrep [query] [file_path]
(4) Test Output
$ cargo test
Compiling minigrep v0.1.0
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.23s
Running unittests src/main.rs
running 7 tests
test tests::test_parse_args_ok ... ok
test tests::test_parse_args_missing ... ok
test tests::test_search_case_sensitive ... ok
test tests::test_search_case_insensitive ... ok
test tests::test_search_no_match ... ok
test tests::test_search_empty_contents ... ok
test tests::test_search_empty_query ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
All 7 unit tests passed! They cover edge cases such as normal searches (case-sensitive and case-insensitive), no matches, empty content, empty queries, and parameter parsing. This is the power of the Rust testing system
#[cfg(test)]and#[test]—the test code is placed in the same file as the production code, and conditional compilation ensures that the test code is not included in production builds.
5. Code Explanation
(1) parse_args Function
fn parse_args(args: &[String]) -> Result<(&str, &str), &'static str> {
if args.len() < 3 {
return Err("usage: minigrep [query] [file_path]");
}
let query = &args[1];
let file_path = &args[2];
Ok((query, file_path))
}
- Received the
&[String]slice (do not own it; merely borrowing it) - Check the number of parameters; if there are not enough, return
Err(&'static str) - Returns
Ok((&str, &str))on success—two string slices - Use the
Resulttype for unified error handling
(2) search Function
fn search<'a>(
query: &str,
contents: &'a str,
case_sensitive: bool,
) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
for (line_no, line) in contents.lines().enumerate() {
let matched = if case_sensitive {
line.contains(query)
} else {
let query_lower = query.to_lowercase();
let line_lower = line.to_lowercase();
line_lower.contains(&query_lower)
};
if matched {
results.push((line_no + 1, line));
}
}
results
}
- Using the lifecycle annotation
'aindicates that the returned&strhas the same lifecycle as thecontentsparameter. contents.lines()Returns an iterator for each line (excluding newline characters).enumerate()Add an index to each line (starting from 0; when outputting, add 1 to start from 1)- When ignoring case, convert both
queryandlineto lowercase before comparing them
(3) run Function
fn run(args: &[String]) -> Result<(), String> {
let (query, file_path) = parse_args(args)?;
// ...
let case_sensitive = match env::var("IGNORE_CASE") {
Ok(val) if !val.is_empty() => false,
_ => true,
};
let contents = fs::read_to_string(file_path)
.map_err(|e| format!("minigrep: error reading file: {}", e))?;
// ...
}
?Operator: Ifparse_argsreturnsErr, the error is automatically propagatedenv::var("IGNORE_CASE"): Read environment variablesfs::read_to_string: Read the file intoStringin a single operation.map_err(): Convert error types from the standard library into user-friendly strings
(4) main Function
fn main() {
let args: Vec<String> = env::args().collect();
if let Err(err_msg) = run(&args) {
eprintln!("{}", err_msg);
process::exit(1);
}
}
env::args().collect(): Collect command-line arguments toVec<String>if let Err(...): Processes the return result ofrunusing pattern matchingeprintln!: Error messages are output to stderr (standard error) instead of stdoutprocess::exit(1): Terminate the program with a non-zero exit code (indicating an abnormal termination)
▶ Example: Advanced Features—Counting Line Numbers and Context in Search Results
Output:
Usage: [args[0]] [Search] [Documents] [--context N] [--ignore-case]
Read a File '[filename]' Failure: [err]
=== Search '[query]' (Ignore case: [ignore_case]) ===
[prefix] [line_num]: [line]
[line_num]: [line]
// ============================================
// Expand grep-lite:Show line numbers and surrounding lines
// ============================================
use std::env;
use std::fs;
use std::process;
fn search_with_context<'a>(query: &str, contents: &'a str, context: usize, ignore_case: bool) -> Vec<(usize, &'a str)> {
let pattern = if ignore_case { query.to_lowercase() } else { query.to_string() };
contents.lines()
.enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&pattern)
})
.map(|(i, line)| (i + 1, line))
.collect()
}
fn search_surrounding<'a>(query: &str, contents: &'a str, context: usize, ignore_case: bool) -> Vec<(usize, &'a str, bool)> {
let matches: Vec<usize> = contents.lines().enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&if ignore_case { query.to_lowercase() } else { query.to_string() })
})
.map(|(i, _)| i)
.collect();
let mut result = Vec::new();
let mut printed = std::collections::HashSet::new();
for &match_idx in &matches {
let start = match_idx.saturating_sub(context);
let end = (match_idx + context + 1).min(contents.lines().count());
for i in start..end {
if printed.insert(i) {
let is_match = i == match_idx;
result.push((i + 1, contents.lines().nth(i).unwrap_or(""), is_match));
}
}
}
result
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: {} [Search] [Documents] [--context N] [--ignore-case]", args[0]);
process::exit(1);
}
let query = &args[1];
let filename = &args[2];
let context = args.iter().position(|a| a == "--context")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let ignore_case = args.iter().any(|a| a == "--ignore-case") ||
env::var("IGNORE_CASE").is_ok();
let contents = fs::read_to_string(filename).unwrap_or_else(|err| {
eprintln!("Read a File '{}' Failure: {}", filename, err);
process::exit(1);
});
println!("=== Search '{}' (Ignore case: {}) ===\n", query, ignore_case);
if context > 0 {
let results = search_surrounding(query, &contents, context, ignore_case);
for (line_num, line, is_match) in &results {
let prefix = if *is_match { ">>>" } else { " " };
println!("{} {}: {}", prefix, line_num, line);
}
} else {
let results = search_with_context(query, &contents, context, ignore_case);
for (line_num, line) in &results {
println!("{}: {}", line_num, line);
}
println!("\nTotal {} matching lines", results.len());
}
}
Output:
Usage: [args[0]] [Search] [Path...] [--ignore-case] [--ext rs]
Search '[query]' in [all_files.len()] file(s) (Extension: .[extension])
--- [filename] ---
[line_num]: [line]
=== Statistics ===
Scanned Documents: [all_files.len()], Matches found: 0, Total Matching Rows: 0
// ============================================
// Expand grep-lite:Supports multiple files and recursive directories
// ============================================
use std::env;
use std::fs;
use std::path::Path;
use std::process;
fn search_in_file(query: &str, filename: &str, ignore_case: bool) -> Vec<(usize, String)> {
let contents = match fs::read_to_string(filename) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let pattern = if ignore_case { query.to_lowercase() } else { query.to_string() };
contents.lines()
.enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&pattern)
})
.map(|(i, line)| (i + 1, line.to_string()))
.collect()
}
fn find_files(path: &Path, extension: &str) -> Vec<String> {
let mut files = Vec::new();
if path.is_file() {
files.push(path.to_string_lossy().to_string());
} else if path.is_dir() {
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
let sub_path = entry.path();
if sub_path.is_dir() {
files.extend(find_files(&sub_path, extension));
} else if sub_path.extension().map(|e| e == extension).unwrap_or(false) {
files.push(sub_path.to_string_lossy().to_string());
}
}
}
}
files
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: {} [Search] [Path...] [--ignore-case] [--ext rs]", args[0]);
process::exit(1);
}
let query = &args[1];
let ignore_case = args.iter().any(|a| a == "--ignore-case");
let extension = args.iter().position(|a| a == "--ext")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or("rs");
let mut all_files = Vec::new();
for path_str in args[2..].iter().filter(|a| !a.starts_with('-')) {
let path = Path::new(path_str);
all_files.extend(find_files(path, extension));
}
println!("Search '{}' in {} file(s) (Extension: .{})\n", query, all_files.len(), extension);
let mut total_matches = 0;
let mut files_with_matches = 0;
for filename in &all_files {
let results = search_in_file(query, filename, ignore_case);
if !results.is_empty() {
println!("--- {} ---", filename);
for (line_num, line) in &results {
println!(" {}: {}", line_num, line);
}
total_matches += results.len();
files_with_matches += 1;
}
}
println!("\n=== Statistics ===");
println!("Scanned Documents: {}, Matches found: {}, Total Matching Rows: {}", all_files.len(), files_with_matches, total_matches);
}
Output:
Search 'fn' in 3 file(s) (Extension: .rs)
--- src/main.rs ---
5: fn parse_args(args: &[String]) -> Result<(&str, &str), &'static str> {
27: fn search<'a>(
55: fn run(args: &[String]) -> Result<(), String> {
90: fn main() {
=== Statistics ===
Scanned Documents: 3, Matches found: 1, Total Matching Rows: 4
Multi-file search support:
find_filesrecursively traverses directories and filters by file extension;search_in_filesearches within a single file; the output format uses--- filename ---to separate results for each file; and finally, provides a summary of the scan and match results.
❓ FAQ
env::args() instead of std::env::args_os()?args() returns an Args iterator, producing the String type, which is suitable for most scenarios. args_os() returns OsString, which can handle non-UTF-8 parameters, but is more complex to use. For the requirements of this project, args() is sufficient.fs::read_to_string() and BufReader?fs::read_to_string() reads the entire file into memory at once, which is suitable for small and medium-sized files. BufReader reads line by line and is suitable for large files (such as log files several hundred MB in size), with lower memory usage. This lesson uses read_to_string to keep the code concise; if you're processing extremely large files, you can use the BufReader + lines() iterators to process them line by line.search function require a lifetime parameter 'a?&str in the returned Vec<(usize, &str)> refers to the data in the contents parameter. The lifetime annotation 'a tells the compiler that the returned reference has the same lifetime as the contents parameter. This allows the compiler to ensure that the contents data is still valid when the search results are used, preventing dangling references.IGNORE_CASE?IGNORE_CASE is set to any non-empty value, case is ignored. Setting IGNORE_CASE=1, IGNORE_CASE=true, or IGNORE_CASE=yes has the same effect. If the environment variable is not set (i.e., env::var returns Err) or is set to an empty string, case sensitivity applies.process::exit(1) instead of just calling panic?process::exit(1) terminates the program with a specified exit code, which is suitable for CLI tools—the parent process (such as a shell script) can determine whether the execution was successful based on the exit code. panic prints a stack trace, which is not user-friendly for end users. The standard practice for CLI tools is to return exit code 0 for success and a non-zero exit code for an error.📖 Summary
- Project Architecture: The CLI tool adopts a
main → run → searchthree-tier architecture, wheremainserves only as an entry point and handles error handling,runis responsible for orchestration logic, andsearchconsists of pure functions. - Command-line arguments: Use
std::env::args()to collect arguments, customize theparse_argsfunction to validate the number of arguments, and useResultfor unified error handling - File Reading:
std::fs::read_to_string()reads small files in a single pass;BufReader+lines()is suitable for processing large files line by line - Environment Variable Control:
std::env::var("IGNORE_CASE")reads environment variables and usesmatchpattern matching to determine whether they are set - Error Handling: Use
Result<(), String>as the return type;?propagates errors;eprintln!outputs to stderr;process::exit(1)sets the exit code - Unit Testing: Write tests using
#[cfg(test)]and#[test]to cover three scenarios—normal, boundary, and error—and run them with a single click usingcargo test
📝 Exercises
-
Difficulty ⭐: Add a "match count" feature to the search function: Based on the
searchfunction, add a newcount_matchesfunction that returns only the number of matching lines without returning the specific line content. For example,count_matches("Rust", contents, true)returns3. Add a--countflag tomain(determined by the third parameter); if set to--count, print only the number of matching lines. -
Difficulty ⭐⭐: Add a "context lines" feature to the tool: Modify the
searchfunction so that it returns a certain number of lines before and after the matched line (similar togrep -C). Add a new parameter,context_lines: usize, to specify how many lines to display before and after the matched line. For example, withcontext_lines=1, the matched line will be accompanied by the line immediately before and after it. Be sure to handle edge cases (lines at the beginning or end of the file have no context). -
Difficulty ⭐⭐⭐: Expand the project into a "multi-file search tool": Modify the program to support searching across multiple files. The new command-line format is
minigrep <query> <file1> <file2> ...(where the number of filenames is variable). Change the output format so that each matching line is prefixed with the filename, such asfile1.txt:5: Hello World. Hint: Use a loop to process multiple files. Although the$()*mode is not a macro, the concept is similar—"process the logic once and apply it to multiple inputs."