简介
一个实用的命令行应用需要连接多个边界:类型化参数、领域数据、持久化存储、清晰的输出、可恢复的错误以及自动化检查。如果从空文件开始编写所有内容,大量输入工作会掩盖整体设计。
在本实验中,环境已经提供了完整的任务跟踪器骨架。你将先阅读模块结构,然后逐个完成小型边界:读取并保存存储数据、添加任务、列出任务、标记任务完成,以及生成经过测试的发布构建。整个过程中不需要粘贴完整的源文件。
阅读项目结构和命令接口
在本步骤中,你将熟悉已准备好的项目,并在编辑代码前了解各文件如何参与数据流转。
进入项目并列出源文件:
cd /home/labex/project/tasker
ls src
lib.rs main.rs model.rs store.rs
每个文件负责一项主要工作:
main.rs负责进程边界:解析参数、打印成功信息以及报告失败;lib.rs负责 CLI 和测试调用的任务操作;model.rs定义Task,并负责在任务与存储行之间进行转换;store.rs负责读取和写入任务集合。
这种拆分可以避免参数解析、领域操作和文件细节全部堆积在一个大型函数中。环境已经写好了模块声明和较长的模型解析器;你只需要完成标记出的操作边界。
查看 CLI 入口:
nano src/main.rs
#[command(subcommand)] 告诉 clap:下一个命令单词用于选择 Commands 枚举的一个变体。--file 选项标记为 global = true,因此用户可以将它放在子命令之前或之后。它的 PathBuf 值默认为 tasks.db。不要修改文件,按 Ctrl+X 退出。
显示生成的顶层帮助信息:
cargo run --quiet -- --help
帮助信息会列出 add、list 和 done。查看 add 的专用帮助信息:
cargo run --quiet -- add --help
必填参数 <TITLE> 来自 Add 变体中的 title: String 字段。接口已经存在;后续步骤会让每个命令对应的库操作正常工作。
完成存储边界
在本步骤中,你将完成连接任务值与本地文本文件的两个小型转换。
打开已准备好的存储模块:
nano src/store.rs
文件格式为每行一个任务,并包含三个由制表符分隔的字段:
id<TAB>status<TAB>title
model.rs 已经提供了 Task::encode 和 Task::decode。存储模块只需要对完整的任务集合应用这两个辅助方法。
将 load 操作中的 TODO 以及其下方两行替换为:
let tasks = contents
.lines()
.filter(|line| !line.is_empty())
.map(Task::decode)
.collect::<Result<Vec<_>, _>>()?;
Ok(tasks)
这个迭代器会将每个非空行转换为 Result<Task, String>。收集为 Result<Vec<_>, _> 后,如果遇到无效行就会在第一处停止;否则会返回所有已解码的任务。问号运算符会将该错误从 load 继续向上传播。
在 save 中,将其中的 TODO 以及最后三行替换为:
fs::write(path, contents)
.map_err(|error| format!("could not write {}: {error}", path.display()))
fs::write 会创建或替换存储文件。map_err 会加入失败路径,同时保留可恢复的 Result。
保存文件并退出 nano。只运行针对存储的测试:
cargo test store::tests::saves_and_loads_tasks
如果一个测试通过,就说明任务集合能够跨越文件边界,并以相等的 Rust 值重新读取回来。
添加并持久化新任务
在本步骤中,你将实现 add 子命令背后的库操作。
打开库入口文件:
nano src/lib.rs
将 add_task 中的 TODO 和占位函数体替换为:
let mut tasks = store::load(path)?;
let next_id = tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1;
let task = Task::new(next_id, title);
tasks.push(task.clone());
store::save(path, &tasks)?;
Ok(task)
该操作会先加载当前状态。对于空文件,max().unwrap_or(0) + 1 会生成 ID 1;对于已有任务的文件,它会生成比现有最大 ID 大 1 的 ID。任务会被克隆一次:一个拥有所有权的副本放入向量,返回的副本则供 CLI 说明刚刚添加的内容。
保存文件并退出 nano。向专用演示文件中添加两个任务:
cargo run --quiet -- --file add-demo.db add "Write release notes"
Added 1: Write release notes
cargo run --quiet -- --file add-demo.db add "Tag version"
Added 2: Tag version
第二个 ID 证明该命令先加载了第一条记录,然后才选择并保存下一个 ID。
格式化任务列表
在本步骤中,你将把存储的任务转换为稳定、易读的命令输出。
再次打开 src/lib.rs:
nano src/lib.rs
将 list_tasks 中的 TODO 和占位函数体替换为:
let tasks = store::load(path)?;
Ok(tasks
.iter()
.map(|task| {
let marker = if task.done { "x" } else { " " };
format!("[{marker}] {}: {}", task.id, task.title)
})
.collect())
标记符号提供了简洁的状态视图:[ ] 表示未完成,[x] 表示已完成。该函数返回用于显示的行,而不是直接打印它们,因此测试和其他调用方可以检查结果,而无需捕获终端输出。
保存文件并退出 nano。使用上一步创建的文件:
cargo run --quiet -- --file add-demo.db list
[ ] 1: Write release notes
[ ] 2: Tag version
库负责格式化任务行,而 main.rs 只负责打印返回的行。
将一个任务标记为已完成
在本步骤中,你将更新一个任务,同时保留存储集合中的其他任务。
打开库源文件:
nano src/lib.rs
将 complete_task 中的 TODO 和占位函数体替换为:
let mut tasks = store::load(path)?;
let task = tasks
.iter_mut()
.find(|task| task.id == id)
.ok_or_else(|| format!("task {id} was not found"))?;
task.done = true;
let completed = task.clone();
store::save(path, &tasks)?;
Ok(completed)
iter_mut() 会提供可变引用,因此可以直接修改匹配的记录。当 ID 不存在时,find 会返回 None;ok_or_else 会将这种缺失情况转换为函数提供的说明性错误。在保存之前需要克隆已完成的任务,因为可变借用属于即将保存的向量。
保存文件并退出 nano。在演示文件中完成任务 1:
cargo run --quiet -- --file add-demo.db done 1
Completed 1: Write release notes
再次列出存储的状态:
cargo run --quiet -- --file add-demo.db list
[x] 1: Write release notes
[ ] 2: Tag version
尝试一个不存在的 ID:
cargo run --quiet -- --file add-demo.db done 99
该命令预期会失败。错误消息会写入 stderr,进程也会以非零状态退出,因为 main.rs 已经在进程边界将库返回的 Err 转换为进程级错误。
测试并构建发布版本
在本步骤中,你将执行交付前的质量检查流程,并从已完成的项目生成发布可执行文件。
先格式化你在存储模块和库模块中所做的修改:
cargo fmt
确认格式化工具没有剩余修改:
cargo fmt -- --check
运行严格的 Clippy 检查:
cargo clippy -- -D warnings
运行完整测试套件:
cargo test
应该有两个测试通过:针对存储往返过程的测试,以及完整的添加—列出—完成库工作流测试。这些测试使用临时文件,因此会实际测试持久化过程,同时不依赖你的演示数据库。
构建经过优化的发布目标:
cargo build --release --locked
--release 会选择 Cargo 的优化发布配置,而不是编译速度更快的开发配置。--locked 要求使用 Cargo.lock 中已经记录的精确依赖关系图。直接运行生成的可执行文件:
./target/release/tasker --file release-demo.db add "Publish tasker"
Added 1: Publish tasker
./target/release/tasker --file release-demo.db list
[ ] 1: Publish tasker
直接使用该路径运行可以证明你执行的是已经构建好的程序,而不是让 Cargo 为你编译并启动程序。
总结
你没有重新输入整个架构,就完成了一个多命令 Rust CLI。完成后的项目使用 clap 解析类型化子命令,通过专用存储模块持久化任务模型,传播带上下文的错误,将进程输出保留在 main 中,通过针对性测试和端到端库测试,完成质量检查流程,并生成使用锁定依赖的发布可执行文件。


