构建任务跟踪器 CLI

RustBeginner
立即练习

简介

一个实用的命令行应用需要连接多个边界:类型化参数、领域数据、持久化存储、清晰的输出、可恢复的错误以及自动化检查。如果从空文件开始编写所有内容,大量输入工作会掩盖整体设计。

在本实验中,环境已经提供了完整的任务跟踪器骨架。你将先阅读模块结构,然后逐个完成小型边界:读取并保存存储数据、添加任务、列出任务、标记任务完成,以及生成经过测试的发布构建。整个过程中不需要粘贴完整的源文件。

阅读项目结构和命令接口

在本步骤中,你将熟悉已准备好的项目,并在编辑代码前了解各文件如何参与数据流转。

进入项目并列出源文件:

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

帮助信息会列出 addlistdone。查看 add 的专用帮助信息:

cargo run --quiet -- add --help

必填参数 <TITLE> 来自 Add 变体中的 title: String 字段。接口已经存在;后续步骤会让每个命令对应的库操作正常工作。

完成存储边界

在本步骤中,你将完成连接任务值与本地文本文件的两个小型转换。

打开已准备好的存储模块:

nano src/store.rs

文件格式为每行一个任务,并包含三个由制表符分隔的字段:

id<TAB>status<TAB>title

model.rs 已经提供了 Task::encodeTask::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 会返回 Noneok_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 中,通过针对性测试和端到端库测试,完成质量检查流程,并生成使用锁定依赖的发布可执行文件。