学习一门语言,不一定要从读完一本教材开始。一个范围清楚、可以逐步扩展的小项目,也能把语法、标准库和工程习惯串起来。作为一名Rust新手,本文记录了借助AI从一个简单的文本处理需求出发,用 Rust 写一个命令行工具 textkit。我们先统计文本,再增加搜索、过滤和替换;随着功能变多,再把代码拆成模块、统一错误处理,并用 clap 管理命令行参数。每一步都围绕一个可运行的结果展开,完整代码在Github 上可看到。
先确定要做什么
textkit 读取 UTF-8 文本文件,提供四项功能:
1
2
3
4
textkit stats <file>
textkit search <file> <keyword>
textkit filter <file> <keyword> [--mode <contains|exclude|starts-with>]
textkit replace <file> <pattern> <replacement>
本文采用“先实现需求,遇到问题再研究语法和源码”的学习方式。这样学到的 String、借用、迭代器和错误处理,都能立刻在一个具体问题里找到位置。
开始前请安装 Rust 工具链。用 Cargo 创建项目:
1
2
cargo new textkit
cd textkit
从文件统计开始
先统计行数、单词数、字符数和字节数。文件读取可能失败,因此要显式处理 Result:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std ::{ env , fs , process };
fn main () {
let Some ( path ) = env ::args (). nth ( 1 ) else {
eprintln! ( "Usage: textkit <file>" );
process ::exit ( 1 );
};
let content = fs ::read_to_string ( & path ). unwrap_or_else ( | error | {
eprintln! ( "Failed to read {path} : {error} " );
process ::exit ( 1 );
});
println! ( "File: {path} " );
println! ( "Lines: {} " , content . lines (). count ());
println! ( "Words: {} " , content . split_whitespace (). count ());
println! ( "Characters: {} " , content . chars (). count ());
println! ( "Bytes: {} " , content . len ());
}
运行程序时,-- 后面的参数会传给程序本身:
1
cargo run -- .\ Cargo . toml
这里有几个值得留意的细节:
String 拥有字符串数据;&str 是对字符串内容的借用。
content.lines()、split_whitespace() 和 chars() 都返回迭代器,可以直接继续调用 count()。
chars().count() 统计 Unicode 标量值,不是用户感知的字素簇;len() 统计 UTF-8 字节数。中文字符通常占多个字节。
lines() 不会把末尾换行符额外算作一行。统计口径应当明确,后续测试也应按这个约定编写。
例如,文件内容是 Rust is fast.\n你好 时,统计结果是:
1
2
3
4
Lines: 2
Words: 4
Characters: 16
Bytes: 20
split_whitespace() 按空白分隔单词,因此 Rust、is、fast. 和 你好 共四个词。Rust is fast. 有 13 个 Unicode 标量值,你好 有 2 个,合计 15?还要注意中间的换行符也属于内容,所以总字符数是 16;UTF-8 字节数则是 13 个 ASCII 字节、1 个换行字节和 6 个中文字符字节,共 20。
测试输入和输出前,最好自己先按所选口径算一遍。这样测试不只是“代码给出一个数字”,也能确认我们对字符、换行和单词的定义是一致的。
可以再检查两类错误:
1
2
3
4
5
# 缺少文件参数
cargo run
# 文件不存在
cargo run -- .\ missing . txt
前者应该显示用法,后者应该显示读取错误,并以非零状态结束。process::exit(1) 中的 1 是给 shell 和其他调用程序看的失败状态;成功运行通常以 0 结束。
String、借用与错误值
读取整个文件后,content 是拥有数据的 String。当函数只需要查看文本时,通常接收 &str,而不是拿走或复制整个字符串:
1
2
3
4
let name : String = String ::from ( "textkit" );
let borrowed : & str = & name ;
println! ( " {borrowed} " );
println! ( " {name} " );
borrowed 只是借用 name 的内容,因此打印借用值之后仍可继续使用 name。这是后面搜索和过滤函数使用 &str 的基础。
fs::read_to_string 返回 Result<String, std::io::Error>,因为磁盘读取不是必然成功的操作。Ok(content) 表示读取成功,Err(error) 带有失败原因。上面的 unwrap_or_else 在成功时取出内容,在失败时打印错误并退出。小程序可以这样处理;随着函数增多,我们会把错误向上传递,避免每处都直接结束进程。
此时的程序把参数解析、文件读取和统计逻辑都放在 main 中。功能少时这样足够直接;加入新命令后,我们再把职责拆开。
把搜索逻辑变成可复用函数
搜索指定关键字,返回匹配行的行号和内容。新建 src/lib.rs,把算法放进库函数:
1
2
3
4
5
6
7
8
9
10
11
12
pub fn search_lines < 'a > ( content : & 'a str , keyword : & str ) -> Vec < ( usize , & 'a str ) > {
if keyword . is_empty () {
return Vec ::new ();
}
content
. lines ()
. enumerate ()
. filter ( | ( _ , line ) | line . contains ( keyword ))
. map ( | ( index , line ) | ( index + 1 , line ))
. collect ()
}
enumerate() 给每一行附上从零开始的索引,输出给用户时再加一,转换为常见的行号。filter 保留匹配行,map 整理结果,collect 收集到 Vec。
返回类型中的生命周期 'a 表明,结果里的行切片借用自 content,不会复制每一行;因此结果也不能比原始文本活得更久。空关键字在这里约定为“不匹配任何行”,避免空字符串匹配每一行这一容易意外的行为。搜索区分大小写。
生命周期标注看起来特殊,但这里表达的关系很直接:函数返回的行切片来自 content,而不是来自 keyword。因此,调用方必须保证原文还存在,才能继续使用搜索结果。生命周期参数不是运行时计时器,也不会改变字符串内容;它只是让编译器检查引用是否可能悬空。
为什么返回 Vec<(usize, &str)>,而不是 Vec<String>?搜索只读取原文,并不修改匹配行。返回切片可以避免为每个结果重新分配字符串。Vec 本身需要存储结果项,但每个 &str 只是指向原文中的一段内容。
将搜索接入命令行
先添加一个 search 分支。此阶段仍然用标准库手动解析参数,目标是看清命令行参数如何到达业务函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use std ::{ env , fs , process };
use textkit ::search_lines ;
fn main () {
let args : Vec < String > = env ::args (). collect ();
if args . get ( 1 ). map ( String ::as_str ) == Some ( "search" ) {
if args . len () != 4 {
eprintln! ( "Usage: textkit search <file> <keyword>" );
process ::exit ( 1 );
}
let content = read_file ( & args [ 2 ]);
let results = search_lines ( & content , & args [ 3 ]);
for ( line_number , line ) in & results {
println! ( " {line_number} : {line} " );
}
println! ( "Found {} matching lines." , results . len ());
} else if args . len () == 2 {
run_stats ( & args [ 1 ]);
} else {
eprintln! ( "Usage: textkit <file> | search <file> <keyword>" );
process ::exit ( 1 );
}
}
fn read_file ( path : & str ) -> String {
fs ::read_to_string ( path ). unwrap_or_else ( | error | {
eprintln! ( "Failed to read {path} : {error} " );
process ::exit ( 1 );
})
}
fn run_stats ( path : & str ) {
let content = read_file ( path );
println! ( "File: {path} " );
println! ( "Lines: {} " , content . lines (). count ());
println! ( "Words: {} " , content . split_whitespace (). count ());
println! ( "Characters: {} " , content . chars (). count ());
println! ( "Bytes: {} " , content . len ());
}
假设 sample.txt 内容如下:
1
2
3
4
Rust is fast.
Python is readable.
I am learning Rust.
Go is also interesting.
运行:
1
cargo run -- search .\ sample . txt Rust
输出:
1
2
3
1: Rust is fast.
3: I am learning Rust.
Found 2 matching lines.
env::args() 返回整个参数列表,其中第 0 项通常是程序路径,args[1] 才是用户输入的第一个参数。cargo run -- 的 -- 是 Cargo 的分隔符,不会传给 textkit;后面的 search 才是 textkit 的第一个参数。
use textkit::search_lines; 能工作,是因为同一 Cargo package 中的 src/lib.rs 会被构建为库,二进制目标可以直接依赖它。随着命令增加,这种“参数检查、文件读取、打印、算法都写在一个文件”的方式会显得拥挤;现在先把搜索逻辑单独测试,再继续扩展。
先为函数写单元测试,验证命中、未命中和空关键字:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[cfg(test)]
mod tests {
use super ::search_lines ;
#[test]
fn finds_matching_lines () {
let results = search_lines ( "Rust is fast. \n Go is fun. \n I like Rust." , "Rust" );
assert_eq! ( results , vec! [( 1 , "Rust is fast." ), ( 3 , "I like Rust." )]);
}
#[test]
fn empty_keyword_matches_nothing () {
assert! ( search_lines ( "Hello \n World" , "" ). is_empty ());
}
}
如果项目中还没有 lib.rs,Cargo 会在创建它之后自动识别库目标。测试代码通常放在实现文件底部的 #[cfg(test)] 模块中。#[cfg(test)] 表示这部分只在运行测试时编译;#[test] 标记一个测试函数。
还可以补上“没有任何匹配项”的测试:
1
2
3
4
5
#[test]
fn returns_empty_when_no_match () {
let results = search_lines ( "Hello \n World" , "Rust" );
assert! ( results . is_empty ());
}
运行 cargo test。单元测试直接验证函数行为,反馈快,也能把边界约定固定下来。必要时可以用 cargo test finds_matching_lines 只运行某一个测试。
再手动检查几种命令行边界:
1
2
3
4
5
6
7
8
# 没有匹配行
cargo run -- search .\ sample . txt Java
# 文件不存在
cargo run -- search .\ missing . txt Rust
# 参数不完整
cargo run -- search .\ sample . txt
没有匹配项是一次成功的搜索,只是结果为空;文件不存在和参数不完整则是错误。把“空结果”和“执行失败”区分开,命令行使用起来会更可预期。
在搜索之上增加行过滤
过滤和搜索共享遍历文本行的方式,区别在于匹配规则。用枚举表达模式,比在业务代码中散落字符串判断更清楚:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterMode {
Contains ,
Exclude ,
StartsWith ,
}
pub fn filter_lines < 'a > (
content : & 'a str ,
keyword : & str ,
mode : FilterMode ,
) -> Vec < ( usize , & 'a str ) > {
if keyword . is_empty () {
return Vec ::new ();
}
content
. lines ()
. enumerate ()
. filter ( | ( _ , line ) | match mode {
FilterMode ::Contains => line . contains ( keyword ),
FilterMode ::Exclude => ! line . contains ( keyword ),
FilterMode ::StartsWith => line . starts_with ( keyword ),
})
. map ( | ( index , line ) | ( index + 1 , line ))
. collect ()
}
现在可以保留包含关键字的行、排除包含关键字的行,或只保留以关键字开头的行。每一种模式都应有测试,尤其要覆盖空关键字和无匹配结果。这里仍沿用空关键字不返回结果的约定。
例如,输入仍是 sample.txt:
1
2
3
4
5
6
7
8
# 默认保留包含 Rust 的行
cargo run -- filter .\ sample . txt Rust
# 排除包含 Rust 的行
cargo run -- filter .\ sample . txt Rust - -exclude
# 只保留以 Rust 开头的行
cargo run -- filter .\ sample . txt Rust - -starts-with
对前面那份示例文件,排除模式只留下不含 Rust 的行:
1
2
3
2: Python is readable.
4: Go is also interesting.
Filtered 2 lines.
StartsWith 检查的是行首,不会自动忽略前导空格;Contains 和 Exclude 也都是区分大小写的。这些规则看似细小,却会直接影响用户对结果的理解,所以最好在帮助信息或文档里明确,并用测试固定下来。
在使用 clap 之前,选项可以先按位置参数处理。对应的 main 分支需要检查参数数量,并把 --exclude、--starts-with 映射为枚举:
1
2
3
4
5
6
let mode = match args . get ( 4 ). map ( String ::as_str ) {
None => FilterMode ::Contains ,
Some ( "--exclude" ) => FilterMode ::Exclude ,
Some ( "--starts-with" ) => FilterMode ::StartsWith ,
Some ( _ ) => return usage_error (),
};
这段逻辑假设已经有统一的用法错误出口,例如:
1
2
3
4
fn usage_error () -> ! {
eprintln! ( "Usage: textkit filter <file> <keyword> [--exclude|--starts-with]" );
std ::process ::exit ( 1 );
}
返回类型 !(never type)表示这个函数不会把控制权正常交还给调用方:它总会退出进程。因此可以在需要返回 () 的分支里调用它。等使用 clap 后,参数数量和未知选项的错误就交由解析器处理,不再需要这段手动检查代码。
这里的 FilterMode 是业务类型。调用方传入枚举后,filter_lines 不必知道 CLI 上具体写的是哪个字符串。
可将测试放在 filter.rs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#[cfg(test)]
mod tests {
use super ::{ filter_lines , FilterMode };
const CONTENT : & str =
"Rust is fast. \n Python is readable. \n Rust is safe. \n Go is interesting." ;
#[test]
fn keeps_lines_containing_keyword () {
assert_eq! (
filter_lines ( CONTENT , "Rust" , FilterMode ::Contains ),
vec! [( 1 , "Rust is fast." ), ( 3 , "Rust is safe." )]
);
}
#[test]
fn excludes_lines_containing_keyword () {
assert_eq! (
filter_lines ( CONTENT , "Rust" , FilterMode ::Exclude ),
vec! [( 2 , "Python is readable." ), ( 4 , "Go is interesting." )]
);
}
#[test]
fn keeps_lines_starting_with_keyword () {
assert_eq! (
filter_lines ( CONTENT , "Rust" , FilterMode ::StartsWith ),
vec! [( 1 , "Rust is fast." ), ( 3 , "Rust is safe." )]
);
}
#[test]
fn empty_keyword_returns_no_lines () {
assert! ( filter_lines ( CONTENT , "" , FilterMode ::Contains ). is_empty ());
}
}
看懂 filter、map 和闭包
过滤逻辑的核心是这段迭代器链:
1
2
3
4
5
6
7
8
9
10
content
. lines ()
. enumerate ()
. filter ( | ( _ , line ) | match mode {
FilterMode ::Contains => line . contains ( keyword ),
FilterMode ::Exclude => ! line . contains ( keyword ),
FilterMode ::StartsWith => line . starts_with ( keyword ),
})
. map ( | ( index , line ) | ( index + 1 , line ))
. collect ()
可以把它拆成几个步骤理解:
lines() 依次产生每一行的 &str。
enumerate() 把每行变成 (索引, 行内容),索引从 0 开始。
filter(...) 接收一个闭包,对每项返回 true 或 false,决定保留还是丢弃。
map(...) 把保留项转换成最终形式,将索引加一作为行号。
collect() 消费迭代器并收集结果。此处函数返回类型告诉编译器要收集成 Vec。
闭包 |(_, line)| ... 是匿名函数参数。_ 表示该位置的索引暂时不用;后面的 line 是当前行。迭代器适合这种“逐项变换”的流程,若刚开始不习惯,也可以先写 for 循环,再逐步替换为迭代器写法。
将过滤命令接到程序
在手动解析参数的阶段,过滤命令通常是 textkit filter <file> <keyword> [option]。解析完成后,读取文件、调用 filter_lines,再逐行打印结果。注意读取文件前应先检查参数数量和模式,避免访问不存在的 args[index]:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if args . get ( 1 ). map ( String ::as_str ) == Some ( "filter" ) {
if args . len () != 4 && args . len () != 5 {
return usage_error ();
}
let mode = match args . get ( 4 ). map ( String ::as_str ) {
None => FilterMode ::Contains ,
Some ( "--exclude" ) => FilterMode ::Exclude ,
Some ( "--starts-with" ) => FilterMode ::StartsWith ,
Some ( _ ) => return usage_error (),
};
let content = read_file ( & args [ 2 ]);
let results = filter_lines ( & content , & args [ 3 ], mode );
for ( line_number , line ) in & results {
println! ( " {line_number} : {line} " );
}
println! ( "Filtered {} lines." , results . len ());
}
先只实现最常用的包含模式,再添加排除和前缀模式,有助于把问题分成小块。最后运行 cargo test,并手动回归搜索命令,确认增加分支没有破坏已有行为。
实现文本替换
替换需要产生新字符串,因此返回值不再借用输入,而是拥有替换后的内容。同时返回替换次数,方便命令行给出结果摘要:
1
2
3
4
5
6
7
8
9
pub fn replace_text ( content : & str , pattern : & str , replacement : & str ) -> ( String , usize ) {
if pattern . is_empty () {
return ( content . to_owned (), 0 );
}
let count = content . match_indices ( pattern ). count ();
let result = content . replace ( pattern , replacement );
( result , count )
}
空模式在这里约定为不执行替换。match_indices 与 replace 都按不重叠匹配处理:例如在 "aaaa" 中查找 "aa",匹配两次,替换结果也对应两处。测试时别只检查结果字符串,也要检查次数、未命中、空模式和 Unicode 文本。
替换函数返回 (String, usize):第一个值是新的文本,第二个值是发生替换的次数。content 仍然是借用传入的;生成的 result 则拥有新的字符串。因此调用后原文仍然可用:
1
2
3
4
5
6
let original = String ::from ( "Rust is fast." );
let ( result , count ) = replace_text ( & original , "Rust" , "Go" );
println! ( " {original} " ); // Rust is fast.
println! ( " {result} " ); // Go is fast.
println! ( " {count} " ); // 1
replace 会将所有匹配项替换,而不是只替换第一次。查找模式按字面文本处理,并非正则表达式。区分清楚“字面替换”和“正则替换”,可以避免用户误以为 .、* 等字符有特殊含义。
建议为替换逻辑至少写以下测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#[cfg(test)]
mod tests {
use super ::replace_text ;
#[test]
fn replaces_every_match_and_counts_them () {
assert_eq! (
replace_text ( "Rust is fast. Rust is fun." , "Rust" , "Go" ),
( "Go is fast. Go is fun." . to_owned (), 2 )
);
}
#[test]
fn returns_original_when_pattern_is_absent () {
assert_eq! (
replace_text ( "Hello World" , "Rust" , "Go" ),
( "Hello World" . to_owned (), 0 )
);
}
#[test]
fn empty_pattern_does_not_change_text () {
assert_eq! ( replace_text ( "Hello" , "" , "Rust" ), ( "Hello" . to_owned (), 0 ));
}
#[test]
fn supports_unicode_text () {
assert_eq! (
replace_text ( "你好,Rust!Rust 很棒。" , "Rust" , "Go" ),
( "你好,Go!Go 很棒。" . to_owned (), 2 )
);
}
}
match_indices() 会迭代不重叠的匹配位置和切片,count() 得到匹配次数;replace() 用同样的不重叠规则构造结果。比如 "aaaa" 中的 "aa" 会匹配两次而不是三次,计数与替换结果保持一致。
命令行接入时,参数顺序为 replace <file> <pattern> <replacement>。读取文件后调用函数,将新文本写到标准输出,并报告替换次数:
1
2
3
4
let content = read_file ( file_path );
let ( result , count ) = replace_text ( & content , pattern , replacement );
print! ( " {result} " );
println! ( " \n Replacements: {count} " );
此例只把结果打印出来,并不会覆盖输入文件。若要原地修改文件,应另行设计备份、临时文件和写入失败时的处理策略,避免把演示性命令误认为安全的文件编辑器。
以包含两次 Rust 的文件为例,命令会输出替换后的完整文本,随后给出次数:
1
2
Go is fast. Go is safe.
Replacements: 2
如果 pattern 没有出现,原文本保持不变,次数为 0。这仍然是一次成功的命令,因为搜索和替换都已正确执行,只是没有找到目标。
可以这样手动验收:
1
2
3
4
5
6
7
8
# 没有匹配项
cargo run -- replace .\ sample . txt Java Go
# 替换成中文文本
cargo run -- replace .\ sample . txt Rust Rust语言
# 参数不完整,应显示用法或 CLI 错误
cargo run -- replace .\ sample . txt Rust
到这里,统计、搜索、过滤和替换已经各自有了独立算法。接下来要解决的是代码组织和命令行接口。
按职责组织项目
把实现拆分到不同模块后,项目结构可以是:
1
2
3
4
5
6
7
8
9
10
11
12
13
textkit/
├── Cargo.toml
├── src/
│ ├── main.rs
│ ├── lib.rs
│ ├── cli.rs
│ ├── error.rs
│ ├── stats.rs
│ ├── search.rs
│ ├── filter.rs
│ └── replace.rs
└── tests/
└── cli.rs
stats.rs、search.rs、filter.rs 和 replace.rs 各自实现一个功能;lib.rs 声明模块并导出公共 API;cli.rs 定义命令行输入;main.rs 负责把输入交给业务逻辑;集成测试从程序外部验证命令和输出。
这不是 Rust 强制要求的目录结构,而是按职责拆分的一种选择。初期只有几行统计代码时,放在 main.rs 更容易上手;当一个文件需要同时处理多个命令、错误和打印时,再拆分能让每个模块更容易阅读和单独测试。模块也不必一开始就拆得很细,按功能边界逐渐整理即可。
可以把当前的 src/lib.rs 整理为一个模块入口:
1
2
3
4
5
6
7
8
9
10
11
pub mod error ;
pub mod filter ;
pub mod replace ;
pub mod search ;
pub mod stats ;
pub use error ::TextkitError ;
pub use filter ::{ filter_lines , FilterMode };
pub use replace ::replace_text ;
pub use search ::search_lines ;
pub use stats ::{ count_text , FileStats };
pub mod stats; 让模块可从库外访问;pub fn count_text 让函数可从模块外访问。pub use 将常用类型和函数从 crate 根部重新导出,调用方可以写 use textkit::count_text;,而不必写 use textkit::stats::count_text;。导出哪些 API 是库的设计选择,不需要把所有内部细节都公开。
把统计结果表示为结构体
原型里直接打印四个值很方便;现在统计逻辑由库提供,应该让它返回可复用的数据。新建 src/stats.rs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileStats {
pub lines : usize ,
pub words : usize ,
pub characters : usize ,
pub bytes : usize ,
}
pub fn count_text ( content : & str ) -> FileStats {
FileStats {
lines : content . lines (). count (),
words : content . split_whitespace (). count (),
characters : content . chars (). count (),
bytes : content . len (),
}
}
FileStats 把相关结果放在一起,调用方能通过 stats.words 等字段访问。usize 适合表示集合长度和计数。derive 自动为这个结构体实现常见 trait:Debug 方便调试,Clone/Copy 使这个只包含计数的值易于复制,PartialEq/Eq 让测试可以比较整个结构体。
之后入口层只负责格式化输出:
1
2
3
4
5
let stats = count_text ( & content );
println! ( "Lines: {} " , stats . lines );
println! ( "Words: {} " , stats . words );
println! ( "Characters: {} " , stats . characters );
println! ( "Bytes: {} " , stats . bytes );
这样统计逻辑和显示格式各自独立。以后 GUI 或其他程序也可以调用 count_text,而不必解析命令行输出。
为统计函数添加测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[cfg(test)]
mod tests {
use super ::{ count_text , FileStats };
#[test]
fn counts_ascii_and_unicode_text () {
assert_eq! (
count_text ( "Rust is fast. \n 你好" ),
FileStats { lines : 2 , words : 4 , characters : 16 , bytes : 20 }
);
}
#[test]
fn empty_text_has_zero_counts () {
assert_eq! (
count_text ( "" ),
FileStats { lines : 0 , words : 0 , characters : 0 , bytes : 0 }
);
}
}
当一个函数返回的值变多时,使用具名字段通常比返回 (usize, usize, usize, usize) 更容易理解。调用方不需要记住元组第 3 项代表什么。
文件读取和参数校验会失败。业务函数不应在错误发生时直接结束整个进程;让它们返回 Result,在程序入口统一报告错误,会更容易测试和扩展。可以定义项目错误类型,并通过 From 让 ? 自动转换 I/O 错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std ::{ error ::Error , fmt , io };
#[derive(Debug)]
pub enum TextkitError {
Io ( io ::Error ),
InvalidArguments ( String ),
}
impl fmt ::Display for TextkitError {
fn fmt ( & self , f : & mut fmt ::Formatter < '_ > ) -> fmt ::Result {
match self {
Self ::Io ( error ) => write! ( f , "I/O error: {error}" ),
Self ::InvalidArguments ( message ) => write! ( f , "Invalid arguments: {message}" ),
}
}
}
impl Error for TextkitError {}
impl From < io ::Error > for TextkitError {
fn from ( error : io ::Error ) -> Self {
Self ::Io ( error )
}
}
例如,返回 Result<String, TextkitError> 的读取函数可以写成:
1
2
3
fn read_file ( path : & std ::path ::Path ) -> Result < String , TextkitError > {
Ok ( std ::fs ::read_to_string ( path ) ? )
}
读取失败时,? 会提前返回,并通过 From<io::Error> 转换错误类型。最终由 main 统一打印错误和设置非零退出码。
这里的错误类型有两种情况:Io 表示文件读取等系统操作失败;InvalidArguments 表示调用方式不符合程序约定。实现 Display 后,可以用 {error} 生成用户可读文本;实现 Error 后,TextkitError 也可以作为标准错误类型使用。From<io::Error> 是 ? 完成自动转换的关键。
在入口统一处理错误,可以采用下面的模式:
1
2
3
4
5
6
7
8
9
10
11
fn main () {
if let Err ( error ) = run () {
eprintln! ( "Error: {error} " );
std ::process ::exit ( 1 );
}
}
fn run () -> Result < (), TextkitError > {
// 解析命令、读取文件、调用业务函数
Ok (())
}
run() 用 Result<(), TextkitError> 表示可能失败但没有返回业务数据。函数内部用 ? 逐层返回错误,最外层 main 决定如何展示错误以及何时退出。这样核心函数不需要知道进程退出策略,也更容易从测试中调用。
用 clap 定义命令行接口
手动读取 env::args() 有助于理解参数,但子命令和选项变多后,校验、帮助信息和错误提示都需要自己维护。引入 clap 的 derive 功能:
1
cargo add clap - -features derive
在 cli.rs 中用结构体和枚举描述命令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
use clap ::{ Args , Parser , Subcommand , ValueEnum };
use std ::path ::PathBuf ;
#[derive(Debug, Parser)]
#[command(name = "textkit" , version, about = "A lightweight text processing toolkit" )]
pub struct Cli {
#[command(subcommand)]
pub command : Commands ,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
/// Count lines, words, characters, and bytes.
Stats ( FileArgs ),
/// Search for lines containing a keyword.
Search ( SearchArgs ),
/// Filter lines by a keyword.
Filter ( FilterArgs ),
/// Replace all occurrences of a pattern.
Replace ( ReplaceArgs ),
}
#[derive(Debug, Args)]
pub struct FileArgs {
pub file : PathBuf ,
}
#[derive(Debug, Args)]
pub struct SearchArgs {
pub file : PathBuf ,
pub keyword : String ,
}
#[derive(Debug, Args)]
pub struct FilterArgs {
pub file : PathBuf ,
pub keyword : String ,
#[arg(long, value_enum, default_value_t = CliFilterMode::Contains)]
pub mode : CliFilterMode ,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CliFilterMode {
Contains ,
Exclude ,
StartsWith ,
}
#[derive(Debug, Args)]
pub struct ReplaceArgs {
pub file : PathBuf ,
pub pattern : String ,
pub replacement : String ,
}
Parser 负责解析入口参数,Subcommand 定义子命令,Args 组织参数,ValueEnum 将枚举映射为命令行可用值。StartsWith 默认对应 starts-with。过滤模式的参数与业务层的 FilterMode 分开定义,避免算法模块依赖 clap。
FileArgs 是一个可复用的参数组:统计命令只需要文件路径,其他命令则各有自己的参数结构。PathBuf 拥有一个路径值,适合保存在解析结果中;调用文件 API 时再借用成 &Path。路径不是普通文本,使用路径类型可以让标准库按当前操作系统的路径规则处理它。
用 derive 宏生成帮助和参数校验
#[derive(Parser)] 会根据结构体字段生成解析器。比如 SearchArgs 有两个必填字段,因此调用时必须提供文件和关键字;少传参数、给枚举传入未知值时,clap 会返回错误并显示用法。--help 和 --version 也会自动生成:
1
2
3
cargo run -- - -help
cargo run -- filter - -help
cargo run -- filter .\ sample . txt Rust - -mode unknown
过滤参数的声明:
1
2
#[arg(long, value_enum, default_value_t = CliFilterMode::Contains)]
pub mode : CliFilterMode ,
表示这是一个长选项 --mode,其值从 CliFilterMode 中选择;没有提供时使用 Contains。枚举变体的 CLI 拼法默认为 kebab-case,所以 StartsWith 对应 starts-with。完整用法如下:
1
textkit filter <FILE> <KEYWORD> --mode <contains|exclude|starts-with>
因为 --mode 有默认值,过滤时也可以省略它:
1
2
3
cargo run -- filter .\ sample . txt Rust
cargo run -- filter .\ sample . txt Rust - -mode exclude
cargo run -- filter .\ sample . txt Rust - -mode starts-with
于是过滤命令可以这样调用:
1
2
3
cargo run -- filter .\ sample . txt Rust
cargo run -- filter .\ sample . txt Rust - -mode exclude
cargo run -- filter .\ sample . txt Rust - -mode starts-with
在 src/lib.rs 注册 pub mod cli; 后,main.rs 可以通过 Cli::parse() 获取 Commands,再将每个变体交给相应函数。下面给出一个完整的调度示例,展示参数解析、路径读取、业务函数调用和错误出口如何连接:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
use std ::{ fs , path ::Path , process };
use clap ::Parser ;
use textkit ::cli ::{ Cli , CliFilterMode , Commands };
use textkit ::{
count_text , filter_lines , replace_text , search_lines , FilterMode , TextkitError ,
};
fn main () {
if let Err ( error ) = run ( Cli ::parse ()) {
eprintln! ( "Error: {error} " );
process ::exit ( 1 );
}
}
fn run ( cli : Cli ) -> Result < (), TextkitError > {
match cli . command {
Commands ::Stats ( args ) => {
let content = read_file ( & args . file ) ? ;
let stats = count_text ( & content );
println! ( "File: {} " , args . file . display ());
println! ( "Lines: {} " , stats . lines );
println! ( "Words: {} " , stats . words );
println! ( "Characters: {} " , stats . characters );
println! ( "Bytes: {} " , stats . bytes );
}
Commands ::Search ( args ) => {
let content = read_file ( & args . file ) ? ;
let results = search_lines ( & content , & args . keyword );
for ( line_number , line ) in & results {
println! ( " {line_number} : {line} " );
}
println! ( "Found {} matching lines." , results . len ());
}
Commands ::Filter ( args ) => {
let mode = match args . mode {
CliFilterMode ::Contains => FilterMode ::Contains ,
CliFilterMode ::Exclude => FilterMode ::Exclude ,
CliFilterMode ::StartsWith => FilterMode ::StartsWith ,
};
let content = read_file ( & args . file ) ? ;
let results = filter_lines ( & content , & args . keyword , mode );
for ( line_number , line ) in & results {
println! ( " {line_number} : {line} " );
}
println! ( "Filtered {} lines." , results . len ());
}
Commands ::Replace ( args ) => {
let content = read_file ( & args . file ) ? ;
let ( result , count ) =
replace_text ( & content , & args . pattern , & args . replacement );
print! ( " {result} " );
println! ( " \n Replacements: {count} " );
}
}
Ok (())
}
fn read_file ( path : & Path ) -> Result < String , TextkitError > {
Ok ( fs ::read_to_string ( path ) ? )
}
这里的 match 覆盖了 Commands 枚举的所有变体。若以后添加新命令而忘了在这里处理,编译器会提示匹配不完整。? 则让文件读取错误直接返回到 main 的统一错误处理处。
阅读 run 时可以沿着一次调用跟下来:Cli::parse() 把参数转为 Commands::Search(args);match 进入搜索分支;read_file 返回文本或错误;search_lines 返回匹配项;循环负责打印。算法函数没有直接读文件或打印,这使它可以单独测试,也能在别的界面复用。
过滤 CLI 的 CliFilterMode 到业务层 FilterMode 的转换看起来有些重复,但它划清了依赖边界:filter.rs 不需要引用 clap。以后即使 CLI 参数库变了,过滤算法也不必跟着修改。
在本教程前面的阶段,CLI 使用的是 textkit <file>,而 clap 阶段采用明确的 textkit stats <file> 子命令。这个调整让四项功能拥有一致的入口,也让 --help 更容易列出所有操作。完成迁移后,统计命令用法变为:
1
cargo run -- stats .\ sample . txt
参数定义、命令分发和文本算法各司其职;文件路径也使用 PathBuf/Path,而不是把路径当作普通文本拼接处理。
用两层测试验证程序
单元测试验证算法本身;集成测试则启动真正的可执行程序,检查参数、输出和退出状态。Cargo 会在集成测试构建时提供二进制路径:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std ::process ::Command ;
#[test]
fn help_lists_all_commands () {
let output = Command ::new ( env! ( "CARGO_BIN_EXE_textkit" ))
. arg ( "--help" )
. output ()
. expect ( "failed to run textkit" );
assert! ( output . status . success ());
let stdout = String ::from_utf8 ( output . stdout ). unwrap ();
for name in [ "stats" , "search" , "filter" , "replace" ] {
assert! ( stdout . contains ( name ));
}
}
这个测试不只是在函数内验证帮助文本,而是像用户一样启动 textkit --help。因此它能同时覆盖 clap 参数定义、程序入口和最终输出。类似地,搜索算法的单元测试通过了,并不代表文件路径一定传对、命令分支一定选对,所以两种测试解决的问题不同。
还应覆盖正常搜索、统计 Unicode 文本、替换次数、缺失文件和非法过滤模式。测试文件放在系统临时目录,或使用 tempfile 等测试依赖创建;避免测试依赖某个本地固定路径。断言应检查用户能观察到的行为,例如标准输出、标准错误和退出码。
隔离 CLI 测试文件
若每个测试都手动拼接临时文件名,需要处理唯一性和清理。可以使用 tempfile 管理生命周期,并用 assert_cmd、predicates 更简洁地启动程序和检查结果:
1
cargo add - -dev assert_cmd predicates tempfile
在 tests/cli.rs 中可以定义两个辅助函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
use assert_cmd ::Command ;
use std ::fs ;
use tempfile ::NamedTempFile ;
fn create_test_file ( content : & str ) -> NamedTempFile {
let file = NamedTempFile ::new (). expect ( "failed to create temporary file" );
fs ::write ( file . path (), content ). expect ( "failed to write temporary file" );
file
}
fn textkit () -> Command {
Command ::cargo_bin ( "textkit" ). expect ( "textkit binary should be built for integration tests" )
}
NamedTempFile 在离开作用域后会清理临时文件。Command::cargo_bin("textkit") 会找到 Cargo 为集成测试构建的程序。下面用这些辅助函数覆盖主要命令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#[test]
fn help_lists_all_subcommands () {
textkit ()
. arg ( "--help" )
. assert ()
. success ()
. stdout ( predicates ::str ::contains ( "stats" ))
. stdout ( predicates ::str ::contains ( "search" ))
. stdout ( predicates ::str ::contains ( "filter" ))
. stdout ( predicates ::str ::contains ( "replace" ));
}
#[test]
fn stats_counts_unicode_characters_and_bytes () {
let file = create_test_file ( "Rust is fast. \n 你好" );
textkit ()
. args ([ "stats" , file . path (). to_str (). unwrap ()])
. assert ()
. success ()
. stdout ( predicates ::str ::contains ( "Lines: 2" ))
. stdout ( predicates ::str ::contains ( "Words: 4" ))
. stdout ( predicates ::str ::contains ( "Characters: 16" ))
. stdout ( predicates ::str ::contains ( "Bytes: 20" ));
}
#[test]
fn search_prints_matching_lines_and_count () {
let file = create_test_file ( "Rust is fast. \n Python is readable. \n I like Rust. \n " );
textkit ()
. args ([ "search" , file . path (). to_str (). unwrap (), "Rust" ])
. assert ()
. success ()
. stdout ( predicates ::str ::contains ( "1: Rust is fast." ))
. stdout ( predicates ::str ::contains ( "3: I like Rust." ))
. stdout ( predicates ::str ::contains ( "Found 2 matching lines." ));
}
#[test]
fn replace_prints_result_and_number_of_replacements () {
let file = create_test_file ( "Rust is fast. Rust is safe." );
textkit ()
. args ([ "replace" , file . path (). to_str (). unwrap (), "Rust" , "Go" ])
. assert ()
. success ()
. stdout ( predicates ::str ::contains ( "Go is fast. Go is safe." ))
. stdout ( predicates ::str ::contains ( "Replacements: 2" ));
}
还要检查失败路径。测试故意传入不存在的路径,并检查退出状态和错误输出;非法枚举值则由 clap 拒绝:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#[test]
fn missing_file_returns_error () {
let directory = tempfile ::tempdir (). unwrap ();
let missing = directory . path (). join ( "missing.txt" );
textkit ()
. args ([ "stats" , missing . to_str (). unwrap ()])
. assert ()
. failure ()
. stderr ( predicates ::str ::contains ( "I/O error" ));
}
#[test]
fn unknown_filter_mode_is_rejected () {
let file = create_test_file ( "Rust \n Go" );
textkit ()
. args ([
"filter" ,
file . path (). to_str (). unwrap (),
"Rust" ,
"--mode" ,
"unknown" ,
])
. assert ()
. failure ();
}
#[test]
fn missing_required_arguments_are_rejected () {
textkit (). arg ( "search" ). assert (). failure ();
}
success() 和 failure() 检查进程退出码;stdout(...)、stderr(...) 检查用户实际看到的内容。只断言关键片段通常比要求整段输出完全一致更稳健,之后调整说明文字时也不必改动无关测试。
如果暂时不想增加测试依赖,标准库的 std::process::Command 也足够:用 .output() 捕获输出,再检查 output.status.success() 和转换为字符串的 stdout/stderr。第三方库只是让这类断言更清楚,并不是写集成测试的前提。
按层次运行检查
日常开发可以先运行最窄范围的检查,再跑全量检查:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 只运行库中的单元测试
cargo test - -lib
# 只运行 CLI 集成测试
cargo test - -test cli
# 按测试名称筛选
cargo test unknown_filter_mode_is_rejected
# 运行全部检查
cargo fmt - -check
cargo test
cargo clippy - -all-targets -- -D warnings
cargo build
cargo fmt --check 检查格式但不修改文件;cargo clippy 提示常见问题;-D warnings 将警告视为错误,便于在提交前处理。开发时也可以用 cargo fmt 自动格式化。测试失败时先阅读实际值和预期值,确认边界定义和实现是否一致,不要为了让测试变绿而直接删除断言。
完成后手动浏览 CLI 帮助并回归四个命令:
1
2
3
4
5
cargo run -- - -help
cargo run -- stats .\ sample . txt
cargo run -- search .\ sample . txt Rust
cargo run -- filter .\ sample . txt Rust - -mode exclude
cargo run -- replace .\ sample . txt Rust Go
总结
textkit 从一个文件统计脚本,逐渐变成结构清晰、可测试的命令行程序。过程中接触到的 Rust 基础概念都服务于实际需求:借用避免复制文本行,迭代器组合处理文本,Result 和 ? 传递错误,模块边界组织代码,测试固定行为,clap 负责可靠地解析用户输入。这类项目的价值不在于功能有多复杂,而在于每次只增加一项能力,同时让代码和验证方式跟着需求一起成长。把需求拆成小步骤,先得到可运行的版本,再逐步改善结构,是一条踏实的 Rust 入门路径。