Rust: Rust 实战:构建文件搜索工具(grep 精简版)

最后更新:2026-08-26

这是 Rust 教程的收官之战——用前 30 课学过的所有知识,构建一个可运行的命令行文件搜索工具(grep 精简版)。从需求到代码,从测试到运行,一步不落。

如果说前 30 课是"学招式",那这一课就是"打实战"。就像学完所有烹饪技巧后,真正做一桌菜给客人吃。你可能会发现火候还不够、刀工还不熟——但做完这道菜,你就真正从"学过 Rust"变成了"能用 Rust 写东西"。


1. 你将学到


2. 故事:日志大海捞针

(1) 痛苦:手动翻日志

Tom 负责维护一个电商平台的订单服务。某个周三的深夜,报警系统响了——大量订单失败,错误率飙升到 30%。

30 分钟后,Tom 终于找到了错误原因:数据库连接池耗尽。但宝贵的 30 分钟已经浪费在"找工具"上。

"如果我有一个自己写的搜索工具,几秒钟就能搞定……"

(2) 我们的方案

今天就写一个 Rust 命令行文件搜索工具(grep-lite),它:

BASH
# 基本用法
cargo run -- "ERROR" order-service.log

# 忽略大小写模式
IGNORE_CASE=1 cargo run -- "error" order-service.log

# 文件不存在的错误提示
cargo run -- "hello" nonexistent.txt
# Output: minigrep: error reading file: The system cannot find the file specified. (os error 2)

3. 项目需求

(1) 功能列表

# 功能 说明
1 命令行参数 接收两个参数:搜索词(query)和文件路径(file_path
2 文件读取 读取指定文件的所有内容
3 逐行搜索 逐行检查是否包含搜索词,打印匹配行(带行号)
4 大小写控制 通过环境变量 IGNORE_CASE 控制是否忽略大小写
5 错误处理 优雅处理文件不存在、无权限、参数不足等错误

(2) 项目模块与知识点对照

模块/函数 对应 Rust 知识点 来源课程
parse_args() 命令行参数 env::argsResult 29-标准库
search() 字符串搜索 contains、迭代器 enumerate 03-字符串、22-迭代器
run() 文件 I/O fs::read_to_string? 运算符 27-文件I/O、09-错误处理
main() 流程控制 matchprocess::exit 05-流程控制
IGNORE_CASE 环境变量 env::var 29-标准库
#[cfg(test)] 单元测试模块 12-测试
&str / String 所有权与借用 02-所有权、03-字符串
Vec<(usize, &str)> Vec 元组、泛型 14-Vec、19-泛型

(3) 命令行参数设计

参数位置 变量名 类型 必需 说明
args[0] 程序名 String 自动 minigrep
args[1] query &str 搜索关键词
args[2] file_path &str 目标文件路径
环境变量 IGNORE_CASE String 设为 1 时忽略大小写

(4) 项目架构

100%
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. 完整项目代码

▶ 示例:grep-lite 完整代码实现

(1) 项目结构

TEXT 📖 仅展示
minigrep/
├── Cargo.toml
└── src/
    └── main.rs      # 所有代码都在 main.rs 中(标准库版本)

(2) Cargo.toml

TOML
[package]
name = "minigrep"
version = "0.1.0"
edition = "2021"

# 本课程仅使用标准库,无需外部依赖
[dependencies]

(3) src/main.rs(完整代码)

RUST
// ============================================
// minigrep - 命令行文件搜索工具(grep 精简版)
// 功能:
//   1. 接收两个命令行参数:搜索词 + 文件路径
//   2. 读取文件内容,逐行搜索
//   3. 打印匹配行(带行号)
//   4. 通过环境变量 IGNORE_CASE 控制大小写
//   5. 优雅的错误处理
// ============================================

use std::env;
use std::fs;
use std::process;

/// 解析命令行参数,返回 (query, file_path)
///
/// 期望接收 2 个参数(不包括程序名):
///   参数 1: 搜索词 (query)
///   参数 2: 文件路径 (file_path)
///
/// 参数数量不足时返回一个错误描述。
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))
}

/// 在文件内容中逐行搜索匹配项
///
/// # Arguments
/// * `query` - 要搜索的关键词
/// * `contents` - 文件内容的字符串切片
/// * `case_sensitive` - 是否区分大小写
///
/// # Returns
/// 返回一个 Vec,包含所有匹配行的 (行号, 行内容)
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 {
            // 忽略大小写:将 query 和 line 都转为小写再比较
            let query_lower = query.to_lowercase();
            let line_lower = line.to_lowercase();
            line_lower.contains(&query_lower)
        };

        if matched {
            // 行号从 1 开始计数(更符合用户习惯)
            results.push((line_no + 1, line));
        }
    }

    results
}

/// 核心运行逻辑:解析参数 → 读取文件 → 搜索 → 打印结果
///
/// 将 IO 和错误处理集中在此函数中,main 只负责调用并处理最终错误。
fn run(args: &[String]) -> Result<(), String> {
    // 步骤 1: 解析命令行参数
    let (query, file_path) = parse_args(args)?;

    // 步骤 2: 读取环境变量 IGNORE_CASE
    // 如果 IGNORE_CASE 被设置为任意非空值,则忽略大小写
    let case_sensitive = match env::var("IGNORE_CASE") {
        Ok(val) if !val.is_empty() => false, // 忽略大小写
        _ => true,                           // 默认区分大小写
    };

    // 步骤 3: 读取文件内容
    let contents = fs::read_to_string(file_path)
        .map_err(|e| format!("minigrep: error reading file: {}", e))?;

    // 步骤 4: 执行搜索
    let matches = search(query, &contents, case_sensitive);

    // 步骤 5: 打印结果
    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(())
}

/// 程序入口
///
/// 收集命令行参数 → 调用 run → 处理错误
fn main() {
    let args: Vec<String> = env::args().collect();

    // 调用 run,如果返回错误则打印并退出
    if let Err(err_msg) = run(&args) {
        eprintln!("{}", err_msg);
        process::exit(1);
    }
}

// ============================================
// 单元测试
// ============================================

#[cfg(test)]
mod tests {
    use super::*;

    /// 测试 parse_args:正常情况
    #[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");
    }

    /// 测试 parse_args:参数不足
    #[test]
    fn test_parse_args_missing() {
        let args = vec!["program".to_string()];
        let result = parse_args(&args);
        assert!(result.is_err());
    }

    /// 测试 search:区分大小写
    #[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."));
    }

    /// 测试 search:忽略大小写
    #[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);

        // 忽略大小写时,所有 4 行都应该匹配(都包含 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!"));
    }

    /// 测试 search:无匹配
    #[test]
    fn test_search_no_match() {
        let contents = "\
apple
banana
cherry";
        let results = search("durian", contents, true);
        assert!(results.is_empty());
    }

    /// 测试 search:空内容
    #[test]
    fn test_search_empty_contents() {
        let contents = "";
        let results = search("hello", contents, true);
        assert!(results.is_empty());
    }

    /// 测试 search:空查询
    #[test]
    fn test_search_empty_query() {
        let contents = "line one\nline two\nline three";
        let results = search("", contents, true);
        // 空字符串在任何行中都匹配(contains("") 始终为 true)
        assert_eq!(results.len(), 3);
    }

    /// 集成测试:模拟 run 函数(使用临时文件)
    #[test]
    fn test_run_with_temp_file() {
        use std::io::Write;

        // 创建临时文件
        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();

        // 构造参数
        let args = vec![
            "minigrep".to_string(),
            "hello".to_string(),
            temp_path.clone(),
        ];

        // 设置环境变量:区分大小写
        // 注意:这个测试依赖 tempfile crate,如果不想引入外部 crate 可以跳过
        // 这里仅作为参考,实际运行时用标准库也可以模拟
        let result = run(&args);
        assert!(result.is_ok());
    }
}

// ============================================
// 说明:
// 1. 以上代码可直接复制到 src/main.rs 中编译运行
// 2. 单元测试中的 tempfile 集成测试是可选演示,
//    实际只需要 cargo test 即可运行其余所有单元测试
// 3. 运行方式:
//    cargo run -- "search_term" "file_path"
//    IGNORE_CASE=1 cargo run -- "search_term" "file_path"
// ============================================

▶ 示例:编译与运行

BASH
# 1. 创建项目
cargo new minigrep
cd minigrep

# 2. 将上述代码复制到 src/main.rs

# 3. 编译
cargo build

# 4. 创建测试文件
echo -e "Hello World\nrust programming\nHELLO everyone\nGoodbye Rust" > test.txt

# 5. 运行(区分大小写)
cargo run -- "rust" test.txt

# 6. 运行(忽略大小写)
IGNORE_CASE=1 cargo run -- "rust" test.txt

# 7. 运行测试
cargo test

▶ 示例:运行示例

示例 1:基本搜索(区分大小写)

创建测试文件 test.txt

TEXT 📖 仅展示
Hello World
Rust is awesome
rust is fast
I love Rust
RUST is powerful
Goodbye

命令:

BASH
cargo run -- "Rust" test.txt

输出:

TEXT 📖 仅展示
minigrep: found 2 match(es) for 'Rust' (case-sensitive)
---
   1: Hello World
   3: I love Rust

示例 2:忽略大小写

命令:

BASH
IGNORE_CASE=1 cargo run -- "rust" test.txt

输出:

TEXT 📖 仅展示
minigrep: found 4 match(es) for 'rust' (case-insensitive)
---
   1: Hello World
   2: Rust is awesome
   3: rust is fast
   4: I love Rust

示例 3:文件不存在

命令:

BASH
cargo run -- "hello" nonexistent.txt

输出:

TEXT 📖 仅展示
minigrep: error reading file: The system cannot find the file specified. (os error 2)

示例 4:无匹配

命令:

BASH
cargo run -- "python" test.txt

输出:

TEXT 📖 仅展示
minigrep: no matches found for 'python'

示例 5:参数不足

命令:

BASH
cargo run -- "hello"

输出:

TEXT 📖 仅展示
usage: minigrep <query> <file_path>

(4) 测试输出

BASH
$ 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

7 个单元测试全部通过!涵盖了正常搜索(区分/忽略大小写)、无匹配、空内容、空查询、参数解析等边界情况。这就是 Rust 测试系统 #[cfg(test)]#[test] 的威力——测试代码与产品代码放在同一个文件中,用条件编译确保测试代码不会进入生产构建。


5. 代码讲解

(1) parse_args 函数

RUST
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))
}

(2) search 函数

RUST
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
}

(3) run 函数

RUST
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))?;
    // ...
}

(4) main 函数

RUST
fn main() {
    let args: Vec<String> = env::args().collect();
    if let Err(err_msg) = run(&args) {
        eprintln!("{}", err_msg);
        process::exit(1);
    }
}

▶ 示例:扩展功能——统计搜索结果行号与上下文

RUST
// ============================================
// 扩展 grep-lite:显示行号和上下文行
// ============================================

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!("用法: {} <查询> <文件> [--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!("读取文件 '{}' 失败: {}", filename, err);
        process::exit(1);
    });

    println!("=== 搜索 '{}' (忽略大小写: {}) ===\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!("\n共 {} 行匹配", results.len());
    }
}

扩展版增加了行号显示(enumerate)和上下文行(--context N 参数)。search_surroundingHashSet 去重避免匹配行相邻时重复输出。>>> 标记匹配行,普通行缩进显示。


▶ 示例:扩展功能——多文件搜索与递归目录

RUST
// ============================================
// 扩展 grep-lite:支持多文件和目录递归
// ============================================

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!("用法: {} <查询> <路径...> [--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!("搜索 '{}' 在 {} 个文件中 (扩展: .{})\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=== 统计 ===");
    println!("扫描文件: {}, 有匹配: {}, 总匹配行: {}", all_files.len(), files_with_matches, total_matches);
}

多文件搜索支持:find_files 递归遍历目录,按扩展名过滤;search_in_file 在单个文件中搜索;输出格式为 --- 文件名 --- 分隔各文件结果;最后的统计汇总扫描和匹配情况。


❓ 常见问题

Q 为什么用 env::args() 而不是 std::env::args_os()
A args() 返回 Args 迭代器,产生 String 类型,适合大多数场景。
Q fs::read_to_string()BufReader 逐行读取有什么区别?
A fs::read_to_string() 一次性将整个文件读入内存,适合中小文件。
Q 为什么 search 函数需要生命周期参数 'a
A 因为返回的 Vec<(usize, &str)> 中的 &str 引用的是 contents 参数中的数据。
Q 环境变量 IGNORE_CASE 的取值规则是怎样的?
A 只要 IGNORE_CASE 被设置为任意非空值,就忽略大小写。
Q 为什么用 process::exit(1) 而不是直接 panic?
A process::exit(1) 以指定的退出码终止程序,适合 CLI 工具——父进程(如 Shell 脚本)可以根据退出码判断执行成功与否。

📖 小节


📝 作业

  1. 难度 ⭐:为搜索功能添加"匹配行数统计":在 search 函数的基础上,新增一个 count_matches 函数,它只返回匹配行数而不返回具体行内容。例如 count_matches("Rust", contents, true) 返回 3。在 main 中添加一个 --count 标志(通过第三个参数判断),如果为 --count 则只打印匹配行数。

  2. 难度 ⭐⭐:为工具添加"上下文行"功能:修改 search 函数,使其同时返回匹配行前后的若干行(类似 grep -C)。新增一个参数 context_lines: usize,表示在匹配行前后各显示多少行。例如 context_lines=1 时,匹配行会带上它的前一行和后一行。注意要处理边界情况(文件开头/结尾的行没有上下文)。

  3. 难度 ⭐⭐⭐:将项目扩展为"多文件搜索工具":修改程序使其支持在多个文件中搜索。新的命令行格式为 minigrep <query> <file1> <file2> ...(文件名数量可变)。输出格式改为在每个匹配行前加上文件名,如 file1.txt:5: Hello World。提示:使用循环处理多个文件,用 $()* 模式虽然不是宏但思想类似——"一次处理逻辑,应用多个输入"。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏