Rust
第十八章:实战 —— 构建 CLI 工具
第十八章:实战 —— 构建 CLI 工具
本章目标
- 使用 Cargo 初始化一个 CLI 项目
- 掌握
clap库进行命令行参数解析(对比 Node.js 的 commander.js)- 熟练使用
std::fs进行文件读写操作- 使用
regexcrate 进行正则表达式匹配- 用
coloredcrate 实现彩色终端输出- 用
anyhow优雅地处理各种错误- 从零构建一个完整的 minigrep 项目(类似 grep 的文本搜索工具)
- 编写单元测试和集成测试
- 将项目发布为可执行二进制文件
预计学习时间:120 - 150 分钟
18.1 项目初始化
18.1.1 创建项目
# 创建一个新的二进制项目
cargo new minigrep
cd minigrep
# 项目结构
# minigrep/
# ├── Cargo.toml # 项目配置和依赖
# ├── src/
# │ └── main.rs # 入口文件
# └── tests/ # 集成测试目录(稍后创建)
18.1.2 添加依赖
# Cargo.toml
[package]
name = "minigrep"
version = "0.1.0"
edition = "2021"
description = "一个简单的文本搜索工具,类似 grep"
authors = ["你的名字"]
license = "MIT"
# 二进制入口(默认就是 src/main.rs,可以不写)
[[bin]]
name = "minigrep"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] } # 命令行参数解析
regex = "1" # 正则表达式
colored = "2" # 彩色输出
anyhow = "1" # 错误处理
[dev-dependencies]
assert_cmd = "2" # 测试命令行程序
predicates = "3" # 测试断言
tempfile = "3" # 临时文件(用于测试)
对比 JavaScript 项目:
Rust (Cargo.toml) │ JavaScript (package.json)
───────────────────────────────┼──────────────────────────────
[package] │ {
name = "minigrep" │ "name": "minigrep",
version = "0.1.0" │ "version": "0.1.0",
│ "bin": "index.js",
[dependencies] │ "dependencies": {
clap = "4" │ "commander": "^11.0",
regex = "1" │ "chalk": "^5.0"
colored = "2" │ },
│ "devDependencies": {
[dev-dependencies] │ "jest": "^29.0"
assert_cmd = "2" │ }
│ }
18.1.3 Cargo.toml 详解
# Cargo.toml 是 Rust 项目的核心配置文件,类似 package.json
[package]
name = "minigrep" # 包名(发布到 crates.io 时的名称)
version = "0.1.0" # 语义化版本号
edition = "2021" # Rust 版本(2015/2018/2021),影响语言特性
description = "一个文本搜索工具"
authors = ["DongDong <dong@example.com>"]
license = "MIT"
repository = "https://github.com/yourname/minigrep"
keywords = ["grep", "search", "cli"] # 最多 5 个关键词
categories = ["command-line-utilities"] # crates.io 分类
[dependencies]
# 依赖声明方式
clap = "4" # 简写:最新 4.x.x
clap = "=4.5.1" # 精确版本
clap = { version = "4", features = ["derive"] } # 启用特性
# my_lib = { path = "../my_lib" } # 本地路径依赖
# my_lib = { git = "https://..." } # Git 依赖
# 版本号语法(类似 npm 的 ^)
# "1.2.3" → >=1.2.3, <2.0.0 (默认行为,类似 npm 的 ^1.2.3)
# "=1.2.3" → 精确 1.2.3
# ">=1.2.0" → 大于等于 1.2.0
# "1.2.*" → 1.2.x 的任何版本
18.2 Clap:命令行参数解析
18.2.1 对比 JavaScript 的 Commander.js
// JavaScript - 使用 commander.js
import { program } from 'commander';
program
.name('minigrep')
.description('一个文本搜索工具')
.version('0.1.0')
.argument('<pattern>', '搜索模式')
.argument('<file>', '要搜索的文件')
.option('-i, --ignore-case', '忽略大小写')
.option('-n, --line-number', '显示行号')
.option('-c, --count', '只显示匹配行数')
.action((pattern, file, options) => {
console.log(`搜索 "${pattern}" in ${file}`);
});
program.parse();
// Rust - 使用 clap(derive 模式)
// clap 的 derive 模式利用 Rust 的 derive 宏,直接从结构体定义生成参数解析器
// 这比 JavaScript 的链式调用更简洁、更类型安全!
use clap::Parser;
/// minigrep - 一个简单的文本搜索工具
#[derive(Parser, Debug)]
#[command(name = "minigrep")]
#[command(version = "0.1.0")]
#[command(about = "一个简单的文本搜索工具,类似 grep")]
struct Args {
/// 要搜索的模式(支持正则表达式)
pattern: String,
/// 要搜索的文件路径
file: String,
/// 忽略大小写
#[arg(short = 'i', long = "ignore-case")]
ignore_case: bool,
/// 显示行号
#[arg(short = 'n', long = "line-number")]
line_number: bool,
/// 只显示匹配的行数
#[arg(short = 'c', long = "count")]
count: bool,
/// 使用正则表达式模式
#[arg(short = 'r', long = "regex")]
use_regex: bool,
/// 反向匹配(显示不匹配的行)
#[arg(short = 'v', long = "invert-match")]
invert: bool,
/// 搜索后显示的上下文行数
#[arg(short = 'C', long = "context", default_value = "0")]
context: usize,
}
fn main() {
let args = Args::parse();
println!("{:?}", args);
// 就这么简单!clap 自动:
// 1. 解析命令行参数
// 2. 验证必填参数
// 3. 生成帮助信息(--help)
// 4. 生成版本信息(--version)
// 5. 处理错误(参数不对时自动显示用法说明)
}
18.2.2 运行测试
# 查看帮助
$ cargo run -- --help
minigrep - 一个简单的文本搜索工具,类似 grep
Usage: minigrep [OPTIONS] <PATTERN> <FILE>
Arguments:
<PATTERN> 要搜索的模式(支持正则表达式)
<FILE> 要搜索的文件路径
Options:
-i, --ignore-case 忽略大小写
-n, --line-number 显示行号
-c, --count 只显示匹配的行数
-r, --regex 使用正则表达式模式
-v, --invert-match 反向匹配(显示不匹配的行)
-C, --context <CONTEXT> 搜索后显示的上下文行数 [default: 0]
-h, --help Print help
-V, --version Print version
# 缺少参数时自动报错
$ cargo run
error: the following required arguments were not provided:
<PATTERN>
<FILE>
Usage: minigrep <PATTERN> <FILE>
18.2.3 Clap 的更多功能
use clap::{Parser, Subcommand, ValueEnum};
// === 子命令(类似 git add / git commit) ===
#[derive(Parser, Debug)]
#[command(name = "mytool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// 搜索文件内容
Search {
/// 搜索模式
pattern: String,
/// 文件路径
file: String,
},
/// 统计文件信息
Stats {
/// 文件路径
file: String,
/// 输出格式
#[arg(long, value_enum, default_value = "text")]
format: OutputFormat,
},
}
// === 枚举参数 ===
#[derive(Debug, Clone, ValueEnum)]
enum OutputFormat {
Text,
Json,
Csv,
}
// 使用方式:
// mytool search "pattern" file.txt
// mytool stats file.txt --format json
// === 环境变量作为默认值 ===
#[derive(Parser, Debug)]
struct EnvArgs {
/// 搜索模式
pattern: String,
/// 文件路径
file: String,
/// 忽略大小写(也可以通过环境变量 IGNORE_CASE=1 设置)
#[arg(short, long, env = "IGNORE_CASE")]
ignore_case: bool,
}
18.3 文件读写:std::fs
18.3.1 对比 JavaScript 的 fs 模块
// JavaScript (Node.js)
import fs from 'fs';
import { readFile, writeFile } from 'fs/promises';
// 同步读取
const content = fs.readFileSync('file.txt', 'utf-8');
// 异步读取
const content2 = await readFile('file.txt', 'utf-8');
// 写入文件
fs.writeFileSync('output.txt', 'Hello, World!');
await writeFile('output.txt', 'Hello, World!');
use std::fs;
use std::io::{self, BufRead, Write, BufWriter};
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// === 读取整个文件为字符串 ===
let content = fs::read_to_string("file.txt")?;
println!("文件内容:{}", content);
// === 读取文件为字节 ===
let bytes = fs::read("image.png")?;
println!("文件大小:{} 字节", bytes.len());
// === 写入文件(覆盖) ===
fs::write("output.txt", "你好,世界!")?;
// === 追加写入 ===
use std::fs::OpenOptions;
let mut file = OpenOptions::new()
.append(true)
.create(true) // 如果文件不存在则创建
.open("log.txt")?;
writeln!(file, "新的一行日志")?;
// === 逐行读取(适合大文件) ===
let file = fs::File::open("large_file.txt")?;
let reader = io::BufReader::new(file);
for (index, line) in reader.lines().enumerate() {
let line = line?; // 每行可能有 IO 错误
println!("第 {} 行:{}", index + 1, line);
}
// === 带缓冲的写入(适合大量写操作) ===
let file = fs::File::create("big_output.txt")?;
let mut writer = BufWriter::new(file);
for i in 0..10000 {
writeln!(writer, "第 {} 行", i)?;
}
writer.flush()?; // 确保所有数据写入磁盘
Ok(())
}
18.3.2 路径操作
use std::path::{Path, PathBuf};
fn main() {
// === 创建路径 ===
let path = Path::new("/home/user/documents/file.txt");
// 获取各部分
println!("文件名:{:?}", path.file_name()); // Some("file.txt")
println!("扩展名:{:?}", path.extension()); // Some("txt")
println!("父目录:{:?}", path.parent()); // Some("/home/user/documents")
println!("文件干名:{:?}", path.file_stem()); // Some("file")
// === 路径拼接 ===
let mut path = PathBuf::from("/home/user");
path.push("documents");
path.push("file.txt");
println!("{}", path.display()); // /home/user/documents/file.txt
// 也可以用 join
let path = Path::new("/home/user").join("documents").join("file.txt");
println!("{}", path.display());
// === 检查路径 ===
println!("存在?{}", path.exists());
println!("是文件?{}", path.is_file());
println!("是目录?{}", path.is_dir());
println!("是绝对路径?{}", path.is_absolute());
// 对比 JavaScript (Node.js path 模块):
// path.basename('/home/user/file.txt') → 'file.txt'
// path.extname('/home/user/file.txt') → '.txt'
// path.dirname('/home/user/file.txt') → '/home/user'
// path.join('/home', 'user', 'file.txt') → '/home/user/file.txt'
}
18.3.3 目录操作
use std::fs;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// === 创建目录 ===
fs::create_dir("new_folder")?; // 单层目录
fs::create_dir_all("a/b/c/d")?; // 递归创建(类似 mkdir -p)
// === 列出目录内容 ===
for entry in fs::read_dir(".")? {
let entry = entry?;
let path = entry.path();
let file_type = if path.is_dir() { "📁" } else { "📄" };
println!("{} {}", file_type, path.display());
}
// === 递归遍历目录 ===
fn walk_dir(dir: &std::path::Path, depth: usize) -> std::io::Result<()> {
let indent = " ".repeat(depth);
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
println!("{}📁 {}/", indent, path.file_name().unwrap().to_string_lossy());
walk_dir(&path, depth + 1)?;
} else {
println!("{}📄 {}", indent, path.file_name().unwrap().to_string_lossy());
}
}
Ok(())
}
walk_dir(std::path::Path::new("."), 0)?;
// === 复制、移动、删除 ===
fs::copy("source.txt", "destination.txt")?; // 复制文件
fs::rename("old_name.txt", "new_name.txt")?; // 移动/重命名
fs::remove_file("unwanted.txt")?; // 删除文件
fs::remove_dir("empty_folder")?; // 删除空目录
fs::remove_dir_all("folder_with_contents")?; // 递归删除
// === 文件元数据 ===
let metadata = fs::metadata("file.txt")?;
println!("大小:{} 字节", metadata.len());
println!("是否只读:{}", metadata.permissions().readonly());
println!("修改时间:{:?}", metadata.modified()?);
Ok(())
}
18.4 正则表达式:regex crate
18.4.1 基本用法
use regex::Regex;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// === 创建正则表达式 ===
let re = Regex::new(r"hello\s+(\w+)")?;
// r"..." 是原始字符串,不需要转义反斜杠
// 对比 JavaScript:/hello\s+(\w+)/
// === 检查是否匹配 ===
let text = "hello world";
println!("匹配?{}", re.is_match(text)); // true
// 对比 JavaScript:
// /hello\s+(\w+)/.test("hello world") // true
// === 查找第一个匹配 ===
if let Some(caps) = re.captures(text) {
println!("完整匹配:{}", &caps[0]); // "hello world"
println!("捕获组 1:{}", &caps[1]); // "world"
}
// 对比 JavaScript:
// const match = "hello world".match(/hello\s+(\w+)/);
// match[0] // "hello world"
// match[1] // "world"
// === 查找所有匹配 ===
let text = "hello alice, hello bob, hello charlie";
for caps in re.captures_iter(text) {
println!("找到:{}", &caps[1]);
}
// 输出:
// 找到:alice
// 找到:bob
// 找到:charlie
// 对比 JavaScript:
// const matches = [...text.matchAll(/hello\s+(\w+)/g)];
// === 替换 ===
let result = re.replace_all(text, "hi $1");
println!("{}", result); // "hi alice, hi bob, hi charlie"
// 对比 JavaScript:
// text.replace(/hello\s+(\w+)/g, "hi $1")
Ok(())
}
18.4.2 常用正则模式
use regex::Regex;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// === 邮箱匹配 ===
let email_re = Regex::new(r"[\w.+-]+@[\w-]+\.[\w.]+$")?;
println!("{}", email_re.is_match("user@example.com")); // true
// === IP 地址匹配 ===
let ip_re = Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")?;
let text = "服务器地址是 192.168.1.100,端口 8080";
if let Some(m) = ip_re.find(text) {
println!("找到 IP:{}", m.as_str()); // 192.168.1.100
}
// === 不区分大小写 ===
let re = Regex::new(r"(?i)hello")?; // (?i) 标志
println!("{}", re.is_match("HELLO")); // true
println!("{}", re.is_match("Hello")); // true
// 对比 JavaScript:/hello/i
// === 多行模式 ===
let re = Regex::new(r"(?m)^\d+")?; // (?m) 多行模式,^ 匹配每行开头
let text = "123 abc\n456 def\n789 ghi";
let numbers: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect();
println!("{:?}", numbers); // ["123", "456", "789"]
// === 命名捕获组 ===
let re = Regex::new(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})")?;
if let Some(caps) = re.captures("今天是 2024-01-15") {
println!("年:{}", &caps["year"]); // 2024
println!("月:{}", &caps["month"]); // 01
println!("日:{}", &caps["day"]); // 15
}
// 对比 JavaScript:
// /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
// match.groups.year
Ok(())
}
18.4.3 性能优化:编译一次,使用多次
use regex::Regex;
use std::sync::LazyLock;
// 在全局作用域预编译正则(避免每次调用都重新编译)
// LazyLock 在第一次访问时初始化,之后复用
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[\w.+-]+@[\w-]+\.[\w.]+").unwrap()
});
static URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"https?://[\w\-._~:/?#\[\]@!$&'()*+,;=]+").unwrap()
});
fn validate_email(email: &str) -> bool {
EMAIL_RE.is_match(email)
}
fn extract_urls(text: &str) -> Vec<&str> {
URL_RE.find_iter(text).map(|m| m.as_str()).collect()
}
fn main() {
println!("{}", validate_email("user@example.com")); // true
println!("{}", validate_email("not-an-email")); // false
let text = "访问 https://rust-lang.org 和 https://crates.io 获取更多信息";
let urls = extract_urls(text);
println!("{:?}", urls);
// ["https://rust-lang.org", "https://crates.io"]
}
18.5 彩色输出:colored crate
18.5.1 基本用法
use colored::*;
fn main() {
// === 前景色 ===
println!("{}", "红色文字".red());
println!("{}", "绿色文字".green());
println!("{}", "蓝色文字".blue());
println!("{}", "黄色文字".yellow());
println!("{}", "紫色文字".purple());
println!("{}", "青色文字".cyan());
println!("{}", "白色文字".white());
// === 背景色 ===
println!("{}", "红色背景".on_red());
println!("{}", "绿色背景的白色文字".white().on_green());
// === 样式 ===
println!("{}", "粗体".bold());
println!("{}", "斜体".italic());
println!("{}", "下划线".underline());
println!("{}", "删除线".strikethrough());
println!("{}", "暗淡".dimmed());
// === 组合样式 ===
println!("{}", "粗体红色带下划线".red().bold().underline());
println!("{}", "错误信息".white().on_red().bold());
println!("{}", "成功信息".green().bold());
println!("{}", "警告信息".yellow().bold());
// 对比 JavaScript (chalk):
// import chalk from 'chalk';
// console.log(chalk.red('红色文字'));
// console.log(chalk.bold.red.underline('粗体红色带下划线'));
// Rust 的 colored 用法几乎一样直观!
}
18.5.2 实际应用:格式化搜索结果
use colored::*;
/// 高亮显示搜索结果中的匹配部分
fn highlight_match(line: &str, pattern: &str) -> String {
// 简单版本:直接替换
line.replace(pattern, &pattern.red().bold().to_string())
}
/// 格式化输出搜索结果
fn print_result(filename: &str, line_number: usize, line: &str, pattern: &str) {
let highlighted = highlight_match(line, pattern);
println!(
"{}:{}:{}",
filename.purple(),
line_number.to_string().green(),
highlighted
);
}
fn main() {
// 模拟搜索结果
print_result("src/main.rs", 42, "fn main() {", "main");
print_result("src/main.rs", 55, " println!(\"Hello from main\");", "main");
print_result("src/lib.rs", 10, "pub fn main_logic() {", "main");
// 输出效果(终端中会有颜色):
// src/main.rs:42:fn main() {
// src/main.rs:55: println!("Hello from main");
// src/lib.rs:10:pub fn main_logic() {
// 其中文件名是紫色,行号是绿色,"main" 是红色粗体
}
18.6 错误处理实战:anyhow
18.6.1 为什么需要 anyhow?
// 不用 anyhow 时,处理多种错误类型很痛苦:
use std::fs;
use std::num::ParseIntError;
// 你需要定义自己的错误类型
#[derive(Debug)]
enum MyError {
IoError(std::io::Error),
ParseError(ParseIntError),
RegexError(regex::Error),
CustomError(String),
}
// 然后为每种错误实现 From
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self {
MyError::IoError(e)
}
}
impl From<ParseIntError> for MyError {
fn from(e: ParseIntError) -> Self {
MyError::ParseError(e)
}
}
// ... 每种错误都要写一遍,太繁琐了!
// 使用 anyhow 后:
use anyhow::{Result, Context, bail, anyhow};
fn read_config() -> Result<Config> {
// ? 操作符可以自动转换任何实现了 std::error::Error 的类型
let content = fs::read_to_string("config.toml")
.context("无法读取配置文件")?; // 添加上下文信息
let port: u16 = content.trim().parse()
.context("配置文件中的端口号格式错误")?;
if port == 0 {
bail!("端口号不能为 0"); // 快速返回错误
}
Ok(Config { port })
}
struct Config {
port: u16,
}
18.6.2 anyhow 的核心功能
use anyhow::{Result, Context, bail, anyhow, ensure};
// === Result 类型别名 ===
// anyhow::Result<T> 等价于 Result<T, anyhow::Error>
// anyhow::Error 可以包装任何 std::error::Error
fn process_file(path: &str) -> Result<Vec<String>> {
// === context() —— 添加错误上下文 ===
let content = std::fs::read_to_string(path)
.with_context(|| format!("读取文件 '{}' 失败", path))?;
// 错误信息:读取文件 'config.txt' 失败
// Caused by: No such file or directory (os error 2)
// === bail! —— 快速返回错误 ===
if content.is_empty() {
bail!("文件 '{}' 是空的", path);
}
// === ensure! —— 断言式检查 ===
ensure!(content.len() < 1_000_000, "文件太大:{} 字节", content.len());
// === anyhow! —— 创建错误值 ===
let lines: Vec<String> = content.lines().map(String::from).collect();
if lines.is_empty() {
return Err(anyhow!("文件没有有效行"));
}
Ok(lines)
}
fn main() {
match process_file("test.txt") {
Ok(lines) => {
println!("读取了 {} 行", lines.len());
}
Err(e) => {
// 打印完整的错误链
eprintln!("错误:{}", e);
// 打印每一层的错误原因
for cause in e.chain() {
eprintln!(" 原因:{}", cause);
}
}
}
}
18.6.3 对比 JavaScript 的错误处理
JavaScript │ Rust (anyhow)
────────────────────────────────────┼───────────────────────────────
try { │ fn do_stuff() -> Result<()> {
const data = fs.readFileSync(f); │ let data = fs::read(f)
const parsed = JSON.parse(data); │ .context("读取失败")?;
// ... │ let parsed = parse(data)
} catch (e) { │ .context("解析失败")?;
console.error(e.message); │ Ok(())
console.error(e.stack); │ }
} │
│ // 调用处
│ match do_stuff() {
│ Ok(()) => println!("成功"),
│ Err(e) => eprintln!("{:#}", e),
│ }
18.7 完整 minigrep 项目
现在,让我们把所有知识组合起来,构建完整的 minigrep!
18.7.1 项目结构
minigrep/
├── Cargo.toml
├── src/
│ ├── main.rs # 入口:解析参数,调用库函数
│ ├── lib.rs # 核心逻辑
│ ├── search.rs # 搜索引擎
│ └── output.rs # 输出格式化
└── tests/
└── integration.rs # 集成测试
18.7.2 src/main.rs —— 程序入口
// src/main.rs
// 程序入口:解析参数并调用核心逻辑
use anyhow::Result;
use clap::Parser;
mod search;
mod output;
/// minigrep - 一个简单但功能完整的文本搜索工具
#[derive(Parser, Debug)]
#[command(name = "minigrep", version, about)]
pub struct Args {
/// 要搜索的模式
pub pattern: String,
/// 要搜索的文件路径(可以指定多个)
pub files: Vec<String>,
/// 忽略大小写
#[arg(short = 'i', long)]
pub ignore_case: bool,
/// 显示行号
#[arg(short = 'n', long = "line-number")]
pub line_number: bool,
/// 只显示匹配的行数
#[arg(short = 'c', long)]
pub count: bool,
/// 使用正则表达式
#[arg(short = 'r', long)]
pub regex: bool,
/// 反向匹配
#[arg(short = 'v', long = "invert-match")]
pub invert: bool,
/// 上下文行数
#[arg(short = 'C', long = "context", default_value = "0")]
pub context: usize,
/// 禁用彩色输出
#[arg(long = "no-color")]
pub no_color: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
// 如果没有指定文件,提示用法
if args.files.is_empty() {
eprintln!("错误:请指定至少一个文件");
std::process::exit(1);
}
// 禁用彩色输出
if args.no_color {
colored::control::set_override(false);
}
// 构建搜索配置
let config = search::SearchConfig {
pattern: args.pattern.clone(),
ignore_case: args.ignore_case,
use_regex: args.regex,
invert: args.invert,
context: args.context,
};
// 创建搜索引擎
let engine = search::SearchEngine::new(&config)?;
let mut total_matches = 0;
let multiple_files = args.files.len() > 1;
// 搜索每个文件
for file_path in &args.files {
match engine.search_file(file_path) {
Ok(results) => {
if args.count {
// 只显示计数
if multiple_files {
println!("{}:{}", file_path, results.len());
} else {
println!("{}", results.len());
}
} else {
// 显示匹配的行
let filename = if multiple_files { Some(file_path.as_str()) } else { None };
output::print_results(&results, filename, args.line_number);
}
total_matches += results.len();
}
Err(e) => {
eprintln!("minigrep: {}: {}", file_path, e);
}
}
}
// 如果没有任何匹配,返回退出码 1(与 grep 行为一致)
if total_matches == 0 {
std::process::exit(1);
}
Ok(())
}
18.7.3 src/search.rs —— 搜索引擎
// src/search.rs
// 核心搜索逻辑
use anyhow::{Result, Context};
use regex::Regex;
use std::fs;
/// 搜索配置
pub struct SearchConfig {
pub pattern: String,
pub ignore_case: bool,
pub use_regex: bool,
pub invert: bool,
pub context: usize,
}
/// 匹配结果
#[derive(Debug, Clone)]
pub struct MatchResult {
/// 行号(从 1 开始)
pub line_number: usize,
/// 行内容
pub line: String,
/// 匹配的范围(起始位置, 结束位置)
pub matches: Vec<(usize, usize)>,
/// 是否是上下文行(不是直接匹配的行)
pub is_context: bool,
}
/// 搜索引擎
pub struct SearchEngine {
regex: Regex,
invert: bool,
context: usize,
}
impl SearchEngine {
/// 创建新的搜索引擎
pub fn new(config: &SearchConfig) -> Result<Self> {
let pattern = if config.use_regex {
config.pattern.clone()
} else {
// 如果不是正则模式,转义特殊字符
regex::escape(&config.pattern)
};
// 构建正则表达式
let pattern = if config.ignore_case {
format!("(?i){}", pattern)
} else {
pattern
};
let regex = Regex::new(&pattern)
.with_context(|| format!("无效的搜索模式:'{}'", config.pattern))?;
Ok(SearchEngine {
regex,
invert: config.invert,
context: config.context,
})
}
/// 搜索文件
pub fn search_file(&self, path: &str) -> Result<Vec<MatchResult>> {
let content = fs::read_to_string(path)
.with_context(|| format!("无法读取文件:'{}'", path))?;
Ok(self.search(&content))
}
/// 搜索文本内容
pub fn search(&self, content: &str) -> Vec<MatchResult> {
let lines: Vec<&str> = content.lines().collect();
let mut results = Vec::new();
let mut matched_lines: Vec<bool> = vec![false; lines.len()];
// 第一遍:找出所有匹配的行
for (i, line) in lines.iter().enumerate() {
let is_match = self.regex.is_match(line);
let should_include = if self.invert { !is_match } else { is_match };
if should_include {
matched_lines[i] = true;
}
}
// 第二遍:添加上下文行
let mut context_lines: Vec<bool> = vec![false; lines.len()];
if self.context > 0 {
for (i, &matched) in matched_lines.iter().enumerate() {
if matched {
let start = i.saturating_sub(self.context);
let end = (i + self.context + 1).min(lines.len());
for j in start..end {
if !matched_lines[j] {
context_lines[j] = true;
}
}
}
}
}
// 第三遍:构建结果
for (i, line) in lines.iter().enumerate() {
if matched_lines[i] {
let matches = if !self.invert {
self.regex
.find_iter(line)
.map(|m| (m.start(), m.end()))
.collect()
} else {
vec![]
};
results.push(MatchResult {
line_number: i + 1,
line: line.to_string(),
matches,
is_context: false,
});
} else if context_lines[i] {
results.push(MatchResult {
line_number: i + 1,
line: line.to_string(),
matches: vec![],
is_context: true,
});
}
}
results
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_engine(pattern: &str, ignore_case: bool, invert: bool) -> SearchEngine {
let config = SearchConfig {
pattern: pattern.to_string(),
ignore_case,
use_regex: false,
invert,
context: 0,
};
SearchEngine::new(&config).unwrap()
}
#[test]
fn test_basic_search() {
let engine = make_engine("hello", false, false);
let results = engine.search("hello world\ngoodbye world\nhello rust");
assert_eq!(results.len(), 2);
assert_eq!(results[0].line, "hello world");
assert_eq!(results[1].line, "hello rust");
}
#[test]
fn test_case_insensitive() {
let engine = make_engine("hello", true, false);
let results = engine.search("Hello World\nHELLO RUST\ngoodbye");
assert_eq!(results.len(), 2);
}
#[test]
fn test_invert_match() {
let engine = make_engine("hello", false, true);
let results = engine.search("hello world\ngoodbye world\nhello rust");
assert_eq!(results.len(), 1);
assert_eq!(results[0].line, "goodbye world");
}
#[test]
fn test_no_match() {
let engine = make_engine("xyz", false, false);
let results = engine.search("hello world\ngoodbye world");
assert_eq!(results.len(), 0);
}
#[test]
fn test_line_numbers() {
let engine = make_engine("world", false, false);
let results = engine.search("hello\nworld\nfoo\nworld");
assert_eq!(results[0].line_number, 2);
assert_eq!(results[1].line_number, 4);
}
#[test]
fn test_match_positions() {
let engine = make_engine("world", false, false);
let results = engine.search("hello world");
assert_eq!(results[0].matches, vec![(6, 11)]);
}
#[test]
fn test_context_lines() {
let config = SearchConfig {
pattern: "target".to_string(),
ignore_case: false,
use_regex: false,
invert: false,
context: 1,
};
let engine = SearchEngine::new(&config).unwrap();
let results = engine.search("line 1\nline 2\ntarget line\nline 4\nline 5");
// 应该有 3 行:line 2(上下文)、target line(匹配)、line 4(上下文)
assert_eq!(results.len(), 3);
assert!(results[0].is_context);
assert!(!results[1].is_context);
assert!(results[2].is_context);
}
#[test]
fn test_regex_search() {
let config = SearchConfig {
pattern: r"\d+".to_string(),
ignore_case: false,
use_regex: true,
invert: false,
context: 0,
};
let engine = SearchEngine::new(&config).unwrap();
let results = engine.search("hello\n123 world\nfoo 456");
assert_eq!(results.len(), 2);
}
#[test]
fn test_empty_content() {
let engine = make_engine("hello", false, false);
let results = engine.search("");
assert_eq!(results.len(), 0);
}
#[test]
fn test_special_regex_chars_escaped() {
// 非正则模式下,特殊字符应该被转义
let engine = make_engine("hello.world", false, false);
let results = engine.search("hello.world\nhelloXworld");
assert_eq!(results.len(), 1); // 只匹配 "hello.world",不匹配 "helloXworld"
}
}
18.7.4 src/output.rs —— 输出格式化
// src/output.rs
// 输出格式化与彩色高亮
use colored::*;
use crate::search::MatchResult;
/// 打印搜索结果
pub fn print_results(results: &[MatchResult], filename: Option<&str>, show_line_numbers: bool) {
let mut prev_line_number = 0;
for result in results {
// 如果行号不连续,打印分隔符
if prev_line_number > 0 && result.line_number > prev_line_number + 1 {
println!("{}", "--".dimmed());
}
prev_line_number = result.line_number;
// 构建输出行
let mut parts: Vec<String> = Vec::new();
// 文件名(紫色)
if let Some(name) = filename {
parts.push(format!("{}", name.purple()));
}
// 行号(绿色)
if show_line_numbers {
let separator = if result.is_context { "-" } else { ":" };
parts.push(format!(
"{}{}",
result.line_number.to_string().green(),
separator.dimmed()
));
}
// 行内容(高亮匹配部分)
if result.is_context {
parts.push(result.line.dimmed().to_string());
} else {
parts.push(highlight_line(&result.line, &result.matches));
}
// 用分隔符连接并输出
if filename.is_some() && show_line_numbers {
let name = filename.unwrap();
let sep = if result.is_context { "-" } else { ":" };
print!("{}{}", name.purple(), sep.dimmed());
print!("{}{}", result.line_number.to_string().green(), sep.dimmed());
if result.is_context {
println!("{}", result.line.dimmed());
} else {
println!("{}", highlight_line(&result.line, &result.matches));
}
} else if filename.is_some() {
let name = filename.unwrap();
let sep = if result.is_context { "-" } else { ":" };
print!("{}{}", name.purple(), sep.dimmed());
if result.is_context {
println!("{}", result.line.dimmed());
} else {
println!("{}", highlight_line(&result.line, &result.matches));
}
} else if show_line_numbers {
let sep = if result.is_context { "-" } else { ":" };
print!("{}{}", result.line_number.to_string().green(), sep.dimmed());
if result.is_context {
println!("{}", result.line.dimmed());
} else {
println!("{}", highlight_line(&result.line, &result.matches));
}
} else if result.is_context {
println!("{}", result.line.dimmed());
} else {
println!("{}", highlight_line(&result.line, &result.matches));
}
}
}
/// 高亮行中的匹配部分
fn highlight_line(line: &str, matches: &[(usize, usize)]) -> String {
if matches.is_empty() {
return line.to_string();
}
let mut result = String::new();
let mut last_end = 0;
for &(start, end) in matches {
// 添加匹配前的普通文本
if start > last_end {
result.push_str(&line[last_end..start]);
}
// 添加高亮的匹配文本
result.push_str(&line[start..end].red().bold().to_string());
last_end = end;
}
// 添加最后一个匹配后的文本
if last_end < line.len() {
result.push_str(&line[last_end..]);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_highlight_no_matches() {
let result = highlight_line("hello world", &[]);
assert_eq!(result, "hello world");
}
#[test]
fn test_highlight_single_match() {
// 注意:彩色输出包含 ANSI 转义序列,所以不能简单比较字符串
let result = highlight_line("hello world", &[(6, 11)]);
assert!(result.contains("world"));
assert_ne!(result, "hello world"); // 应该包含颜色代码
}
}
18.7.5 src/lib.rs —— 库入口
// src/lib.rs
// 将模块导出,方便集成测试使用
pub mod search;
pub mod output;
18.8 测试
18.8.1 单元测试(已包含在各模块中)
# 运行所有测试
cargo test
# 运行特定模块的测试
cargo test search::tests
# 运行特定测试
cargo test test_basic_search
# 显示 println! 输出
cargo test -- --nocapture
# 只运行名字包含 "case" 的测试
cargo test case
18.8.2 集成测试
// tests/integration.rs
// 集成测试:测试整个程序的行为
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
/// 创建临时测试文件
fn setup_test_file(content: &str) -> (TempDir, String) {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("test.txt");
fs::write(&file_path, content).unwrap();
(dir, file_path.to_string_lossy().to_string())
}
#[test]
fn test_basic_search() {
let (_dir, path) = setup_test_file("hello world\ngoodbye world\nhello rust");
Command::cargo_bin("minigrep")
.unwrap()
.args(&["hello", &path])
.assert()
.success()
.stdout(predicate::str::contains("hello world"))
.stdout(predicate::str::contains("hello rust"));
}
#[test]
fn test_no_match_exits_with_1() {
let (_dir, path) = setup_test_file("hello world");
Command::cargo_bin("minigrep")
.unwrap()
.args(&["xyz", &path])
.assert()
.code(1);
}
#[test]
fn test_case_insensitive() {
let (_dir, path) = setup_test_file("Hello World\nhello rust\nGoodbye");
Command::cargo_bin("minigrep")
.unwrap()
.args(&["-i", "hello", &path])
.assert()
.success()
.stdout(predicate::str::contains("Hello World"))
.stdout(predicate::str::contains("hello rust"));
}
#[test]
fn test_count_mode() {
let (_dir, path) = setup_test_file("hello a\nhello b\nhello c\ngoodbye");
Command::cargo_bin("minigrep")
.unwrap()
.args(&["-c", "hello", &path])
.assert()
.success()
.stdout(predicate::str::contains("3"));
}
#[test]
fn test_line_numbers() {
let (_dir, path) = setup_test_file("aaa\nbbb\nccc\nbbb\neee");
Command::cargo_bin("minigrep")
.unwrap()
.args(&["-n", "bbb", &path, "--no-color"])
.assert()
.success()
.stdout(predicate::str::contains("2"))
.stdout(predicate::str::contains("4"));
}
#[test]
fn test_invert_match() {
let (_dir, path) = setup_test_file("hello\nworld\nhello");
Command::cargo_bin("minigrep")
.unwrap()
.args(&["-v", "hello", &path])
.assert()
.success()
.stdout(predicate::str::contains("world"));
}
#[test]
fn test_file_not_found() {
Command::cargo_bin("minigrep")
.unwrap()
.args(&["hello", "/nonexistent/file.txt"])
.assert()
.failure()
.stderr(predicate::str::contains("无法读取文件"));
}
#[test]
fn test_no_args_shows_error() {
Command::cargo_bin("minigrep")
.unwrap()
.assert()
.failure();
}
#[test]
fn test_help_flag() {
Command::cargo_bin("minigrep")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("minigrep"));
}
#[test]
fn test_regex_mode() {
let (_dir, path) = setup_test_file("foo123\nbar456\nbaz");
Command::cargo_bin("minigrep")
.unwrap()
.args(&["-r", r"\d+", &path])
.assert()
.success()
.stdout(predicate::str::contains("foo123"))
.stdout(predicate::str::contains("bar456"));
}
#[test]
fn test_multiple_files() {
let dir = TempDir::new().unwrap();
let file1 = dir.path().join("a.txt");
let file2 = dir.path().join("b.txt");
fs::write(&file1, "hello from a").unwrap();
fs::write(&file2, "hello from b").unwrap();
Command::cargo_bin("minigrep")
.unwrap()
.args(&[
"hello",
&file1.to_string_lossy(),
&file2.to_string_lossy(),
])
.assert()
.success()
.stdout(predicate::str::contains("hello from a"))
.stdout(predicate::str::contains("hello from b"));
}
18.8.3 对比 JavaScript 的测试
Rust 测试 │ JavaScript 测试 (Jest)
───────────────────────────────────────┼────────────────────────────────
#[test] │ test('描述', () => {
fn test_name() { │ expect(result).toBe(42);
assert_eq!(result, 42); │ });
} │
│
#[test] │ test('async', async () => {
#[should_panic] │ await expect(fn).rejects
fn test_panic() { │ .toThrow('error');
panic!("error"); │ });
} │
│
cargo test │ npx jest
cargo test -- --nocapture │ npx jest --verbose
cargo test test_name │ npx jest -t 'test_name'
18.9 发布为二进制
18.9.1 构建 Release 版本
# Debug 构建(默认,编译快但运行慢)
cargo build
# 输出:target/debug/minigrep
# Release 构建(编译慢但运行快,有优化)
cargo build --release
# 输出:target/release/minigrep
# 查看二进制大小
ls -lh target/release/minigrep
# 通常只有几 MB!
# 进一步减小体积
# 在 Cargo.toml 中添加:
# [profile.release]
# opt-level = "z" # 优化体积而不是速度
# lto = true # 链接时优化
# strip = true # 移除调试符号
# codegen-units = 1 # 单代码生成单元(编译慢但优化好)
# panic = "abort" # panic 时直接中止(减少二进制大小)
18.9.2 安装到系统
# 安装到 ~/.cargo/bin/(如果在 PATH 中就可以全局使用)
cargo install --path .
# 现在可以直接使用
minigrep "hello" file.txt
# 卸载
cargo uninstall minigrep
18.9.3 交叉编译
# 安装目标平台的工具链
rustup target add x86_64-unknown-linux-musl # Linux 静态链接
rustup target add x86_64-apple-darwin # macOS
rustup target add x86_64-pc-windows-msvc # Windows
# 交叉编译
cargo build --release --target x86_64-unknown-linux-musl
# 输出:target/x86_64-unknown-linux-musl/release/minigrep
# 静态链接的二进制可以在任何 Linux 上运行,不需要安装依赖!
# 对比 JavaScript:
# JavaScript 需要 Node.js 运行时(~100MB)
# 或者用 pkg/nexe 打包(~50MB 以上)
# Rust 的二进制:几 MB,零依赖!
18.9.4 发布到 crates.io
# 1. 注册账号:https://crates.io/
# 2. 登录
cargo login <your-api-token>
# 3. 检查包是否可以发布
cargo publish --dry-run
# 4. 发布!
cargo publish
# 别人安装你的工具:
# cargo install minigrep
# 对比 npm:
# npm login
# npm publish
# npm install -g minigrep
18.10 完整的 Cargo.toml(最终版)
[package]
name = "minigrep"
version = "0.1.0"
edition = "2021"
description = "一个简单但功能完整的文本搜索工具,类似 grep"
authors = ["Your Name <you@example.com>"]
license = "MIT"
repository = "https://github.com/yourname/minigrep"
keywords = ["grep", "search", "cli", "text"]
categories = ["command-line-utilities"]
[[bin]]
name = "minigrep"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
regex = "1"
colored = "2"
anyhow = "1"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
[profile.release]
opt-level = "z"
lto = true
strip = true
codegen-units = 1
panic = "abort"
18.11 本章总结
┌──────────────────────────────────────────────────────────────────┐
│ CLI 工具开发全流程 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 1. 项目初始化 │
│ cargo new minigrep │
│ 编辑 Cargo.toml 添加依赖 │
│ │
│ 2. 参数解析 (clap) │
│ #[derive(Parser)] 自动生成解析器 │
│ 类型安全、自动生成帮助信息 │
│ │
│ 3. 核心逻辑 │
│ 文件读写 (std::fs) │
│ 正则表达式 (regex) │
│ 彩色输出 (colored) │
│ │
│ 4. 错误处理 (anyhow) │
│ Result + ? + context() │
│ 用户友好的错误信息 │
│ │
│ 5. 测试 │
│ 单元测试:#[test] │
│ 集成测试:assert_cmd + predicates │
│ │
│ 6. 发布 │
│ cargo build --release │
│ cargo install --path . │
│ cargo publish │
│ │
│ 对比 JavaScript: │
│ ┌──────────────┬───────────────────┬─────────────────────┐ │
│ │ 功能 │ Rust │ JavaScript │ │
│ ├──────────────┼───────────────────┼─────────────────────┤ │
│ │ 参数解析 │ clap │ commander.js │ │
│ │ 彩色输出 │ colored │ chalk │ │
│ │ 正则表达式 │ regex │ 内置 RegExp │ │
│ │ 错误处理 │ anyhow │ try/catch │ │
│ │ 测试 │ cargo test │ jest │ │
│ │ 打包 │ cargo build │ pkg / nexe │ │
│ │ 二进制大小 │ ~2-5 MB │ ~50-100 MB │ │
│ │ 运行时依赖 │ 无 │ Node.js │ │
│ └──────────────┴───────────────────┴─────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
📝 下一章预告: 在第十九章中,我们将用 Axum 框架构建一个完整的 REST API,包括路由、中间件、数据库集成和部署!如果你用过 Express.js,你会发现 Axum 既熟悉又令人惊喜。