真实项目:命令行待办应用
你会学到什么
- 把前面学的内容综合到一个真实小项目里:建模、错误、集合、序列化、CLI、文件 IO。
- 用 lib + bin 的结构,让核心逻辑可测、入口轻薄。
- 用 JSON 文件做持久化,跨多次运行保存状态。
最小示例
todo add "学习 Rust"
todo done 1
todo list
运行代码
cd examples
cargo run -p rt_42_todo_cli -- add "学习 Rust"
cargo run -p rt_42_todo_cli -- list
cargo run -p rt_42_todo_cli -- done 1
cargo test -p rt_42_todo_cli
代码讲解
核心逻辑放在 lib.rs 的 TodoList,完全不涉及命令行或文件,因此可以纯逻辑单测:
pub fn add(&mut self, title: &str) -> Result<u32, TodoError> {
let title = title.trim();
if title.is_empty() {
return Err(TodoError::EmptyTitle);
}
// ... 分配 id 并入列
}
错误用 thiserror 定义(NotFound、EmptyTitle),持久化用 serde 把整个清单序列化成 JSON。
main.rs 只做三件事:用 clap 解析子命令、加载/保存文件、把命令转发给 TodoList。main 返回 anyhow::Result<()>,于是 ? 能统一处理 TodoError、IO 错误和 JSON 错误:
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let mut list = load(&cli.file);
match cli.command {
Command::Add { title } => { let id = list.add(&title)?; /* ... */ }
Command::Done { id } => { list.complete(id)?; }
Command::List => { /* 打印 */ }
}
fs::write(&cli.file, list.to_json()?)?;
Ok(())
}
这正是 Rust 项目的典型分层:纯逻辑库 + 薄入口,可测性和可维护性都更好。
常见错误
把文件 IO 和业务逻辑混在一起,导致逻辑无法在不碰磁盘的情况下测试。把 IO 留在 main、逻辑留在库,是值得坚持的习惯。
练习
- 加一个
remove <id>子命令(库里已有remove)。 - 给
list加一个--pending选项,只显示未完成任务。 - 把存储格式从 JSON 换成你在 SQLite 那章学的数据库。
小结
这个小项目把建模、错误处理、序列化、CLI 和文件 IO 串了起来,核心是“纯逻辑库 + 薄入口”的分层。你已经走完了从 Hello World 到真实项目的完整路线!
下一步
恭喜完成路线!接下来可以深入标准库源码、阅读优秀 crate、或挑一个真实需求从零做起。持续写、持续读,是掌握 Rust 的最好方式。
完整示例代码
下面是 examples/42_todo_cli/ 的完整源码。无需 clone 仓库,直接在页面上阅读、复制、对照运行。
examples/42_todo_cli/src/main.rs
//! 待办应用的命令行入口:clap 解析 + JSON 文件持久化。
use clap::{Parser, Subcommand};
use rt_42_todo_cli::TodoList;
use std::fs;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "todo", about = "一个最小的命令行待办应用")]
struct Cli {
/// 数据文件路径。
#[arg(long, default_value = "todo.json")]
file: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// 新增任务。
Add { title: String },
/// 标记完成。
Done { id: u32 },
/// 列出全部任务。
List,
}
/// 从文件加载;文件不存在或损坏时返回空清单。
fn load(path: &PathBuf) -> TodoList {
fs::read_to_string(path)
.ok()
.and_then(|text| TodoList::from_json(&text).ok())
.unwrap_or_default()
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let mut list = load(&cli.file);
match cli.command {
Command::Add { title } => {
let id = list.add(&title)?;
println!("已添加 #{id}: {title}");
}
Command::Done { id } => {
list.complete(id)?;
println!("已完成 #{id}");
}
Command::List => {
if list.tasks().is_empty() {
println!("(空)");
}
for task in list.tasks() {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] #{} {}", task.id, task.title);
}
println!("未完成: {}", list.pending());
}
}
fs::write(&cli.file, list.to_json()?)?;
Ok(())
} examples/42_todo_cli/src/lib.rs
//! 待办清单核心逻辑:综合结构体、枚举、集合、错误处理与序列化。
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// 操作待办清单时可能出现的错误。
#[derive(Debug, Error, PartialEq)]
pub enum TodoError {
#[error("找不到任务 #{0}")]
NotFound(u32),
#[error("标题不能为空")]
EmptyTitle,
}
/// 单条任务。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Task {
pub id: u32,
pub title: String,
pub done: bool,
}
/// 待办清单:持有任务集合并分配自增 id。
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct TodoList {
tasks: Vec<Task>,
next_id: u32,
}
impl TodoList {
pub fn new() -> Self {
Self {
tasks: Vec::new(),
next_id: 1,
}
}
/// 新增任务,返回分配的 id;空标题报错。
pub fn add(&mut self, title: &str) -> Result<u32, TodoError> {
let title = title.trim();
if title.is_empty() {
return Err(TodoError::EmptyTitle);
}
let id = self.next_id;
self.next_id += 1;
self.tasks.push(Task {
id,
title: title.to_string(),
done: false,
});
Ok(id)
}
/// 把指定任务标记为完成。
pub fn complete(&mut self, id: u32) -> Result<(), TodoError> {
let task = self
.tasks
.iter_mut()
.find(|task| task.id == id)
.ok_or(TodoError::NotFound(id))?;
task.done = true;
Ok(())
}
/// 删除指定任务。
pub fn remove(&mut self, id: u32) -> Result<(), TodoError> {
let before = self.tasks.len();
self.tasks.retain(|task| task.id != id);
if self.tasks.len() == before {
Err(TodoError::NotFound(id))
} else {
Ok(())
}
}
/// 未完成任务数量。
pub fn pending(&self) -> usize {
self.tasks.iter().filter(|task| !task.done).count()
}
pub fn tasks(&self) -> &[Task] {
&self.tasks
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
pub fn from_json(text: &str) -> serde_json::Result<Self> {
serde_json::from_str(text)
}
}
impl Default for TodoList {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_and_assigns_ids() {
let mut list = TodoList::new();
assert_eq!(list.add("a").unwrap(), 1);
assert_eq!(list.add("b").unwrap(), 2);
assert_eq!(list.tasks().len(), 2);
}
#[test]
fn rejects_empty_title() {
let mut list = TodoList::new();
assert_eq!(list.add(" "), Err(TodoError::EmptyTitle));
}
#[test]
fn completes_and_counts_pending() {
let mut list = TodoList::new();
let id = list.add("task").unwrap();
list.add("other").unwrap();
assert_eq!(list.pending(), 2);
list.complete(id).unwrap();
assert_eq!(list.pending(), 1);
}
#[test]
fn errors_on_missing_id() {
let mut list = TodoList::new();
assert_eq!(list.complete(99), Err(TodoError::NotFound(99)));
assert_eq!(list.remove(99), Err(TodoError::NotFound(99)));
}
#[test]
fn removes_task() {
let mut list = TodoList::new();
let id = list.add("temp").unwrap();
list.remove(id).unwrap();
assert!(list.tasks().is_empty());
}
#[test]
fn json_round_trip() {
let mut list = TodoList::new();
list.add("persist me").unwrap();
list.complete(1).unwrap();
let json = list.to_json().unwrap();
assert_eq!(TodoList::from_json(&json).unwrap(), list);
}
} examples/42_todo_cli/Cargo.toml
[package]
name = "rt_42_todo_cli"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
thiserror = "2"