上一篇我们把Rust环境搭好了,也跑通了Hello World。但说实话,那还是太简单了。学编程语言,最好的方式就是做点实际的东西出来。
所以这篇文章,我们来写一个真正能用的工具:命令行TODO管理器。
为什么选这个项目?因为它:
整个开发过程大概1小时,跟着做完,你对Rust的理解会上一个台阶。
先明确一下要做什么功能:
todo add "学习Rust"todo list - 显示所有任务todo done 1 - 把ID为1的任务标记为完成todo remove 1 - 删除指定任务todo clear - 一键清除所有已完成的任务数据要持久化保存在本地文件(用JSON格式),这样关掉终端再打开,数据还在。
老规矩,用cargo创建项目:
cargo new rust_todo
cd rust_todo
ls -la
可以看到cargo帮我们生成了完整的项目结构:
.git/ - 已经初始化了git仓库Cargo.toml - 项目配置文件src/main.rs - 源代码入口.gitignore - 已经配置好忽略规则Cargo真的很贴心,连git都帮你弄好了。
我们需要三个库:
编辑 Cargo.toml,在 [dependencies] 下添加:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"
为什么需要这些库?
serde 的 derive 特性可以自动生成序列化代码,不用手写serde_json 用来读写JSON文件chrono 让时间处理更方便这是整个项目的核心。完整代码大概200行,我会分模块讲解。

从截图可以看到,代码有212行,主要分为几个部分:
代码组织得很清晰,这也是Rust的优点之一。

#[derive(Debug, Serialize, Deserialize, Clone)]
struct Todo {
id: u32,
content: String,
completed: bool,
created_at: String,
}
impl Todo {
fn new(id: u32, content: String) -> Self {
use chrono::Local;
let now = Local::now().format("%Y-%m-%d %H:%M").to_string();
Todo {
id,
content,
completed: false,
created_at: now,
}
}
}这里有几个Rust特色的东西:
#[derive(...)] - 这是Rust的过程宏,自动实现traitDebug - 可以用 {:?} 打印Serialize 和 Deserialize - 可以转成JSONClone - 可以复制impl 块 - 给结构体添加方法fn new() 是构造函数-> Self 表示返回 Todo 类型Self 是 Todo 的别名completed: false 在创建时设置
fn get_todos_file() -> PathBuf {
let home = env::var("HOME").expect("无法获取HOME目录");
PathBuf::from(home).join(".todos.json")
}
fn load_todos() -> Vec<Todo> {
let file_path = get_todos_file();
if !file_path.exists() {
return Vec::new();
}
let data = fs::read_to_string(&file_path).expect("读取文件失败");
serde_json::from_str(&data).unwrap_or_else(|_| Vec::new())
}
fn save_todos(todos: &Vec<Todo>) {
let file_path = get_todos_file();
let json = serde_json::to_string_pretty(todos).expect("序列化失败");
fs::write(&file_path, json).expect("写入文件失败");
}这段代码展示了Rust的几个重要概念:
PathBuf - 可变的路径类型.join() 拼接路径& - save_todos(todos: &Vec<Todo>)& 表示借用,不转移所有权todosexpect() - 如果出错就panic并显示消息unwrap_or_else() - 出错时提供默认值为什么 save_todos 要用借用? 因为保存完后,我们可能还要继续操作这个列表,不能把所有权转移走。这就是Rust所有权系统的优雅之处。

这里实现了 add_todo 和 list_todos 函数。
添加任务的逻辑:
let new_id = todos.iter().map(|t| t.id).max().unwrap_or(0) + 1;这行代码很有Rust风格:
.iter() - 创建迭代器.map(|t| t.id) - 闭包,提取每个任务的ID.max() - 找最大值,返回 Option<u32>.unwrap_or(0) - 如果没有任务(None),返回0列出任务的逻辑:
[✓] 和删除线[ ]这里用到了ANSI转义序列:
format!("\x1b[9m{}\x1b[0m", todo.content) // 删除线效果\x1b[9m 是删除线,\x1b[0m 是重置样式。这让已完成的任务看起来更明显。
代码写完后,第一次编译:
cargo build
可以看到cargo在下载和编译依赖:
proc-macro2、quote - serde需要的宏相关库serde、serde_json - 我们配置的依赖chrono - 时间处理库第一次编译会慢一点,因为要下载和编译所有依赖。 后续编译就快了,cargo会缓存编译结果。
编译成功后,可执行文件在 target/debug/rust_todo。
cargo run -- help
注意这里的 --,它告诉cargo:后面的参数是给我们的程序的,不是给cargo的。
帮助信息显示得很清楚:
连续添加5个任务:
cargo run -- add "学习Rust所有权系统"
cargo run -- add "写TODO工具"
cargo run -- add "阅读Rust官方文档"
cargo run -- add "练习Rust错误处理"
cargo run -- add "完成第二篇文章"
每次添加都显示"✅ 任务已添加!",说明功能正常。
有个细节:后面几次编译都显示 Finished in 0.01s,说明cargo检测到代码没变,直接用缓存了。这就是增量编译的好处。
cargo run -- list
显示结果很清晰:
[ ] 表示未完成Rust的字符串处理很方便,用 "=".repeat(60) 就能画出分割线。
把ID为1和3的任务标记为完成:
cargo run -- done 1
cargo run -- done 3
cargo run -- list
再次查看列表,可以看到:
[✓]这里体现了Rust的可变性控制:
if let Some(todo) = todos.iter_mut().find(|t| t.id == id) {
todo.completed = true;
}.iter_mut() - 可变迭代器,可以修改元素if let Some(todo) - 优雅的模式匹配删除ID为4的任务:
cargo run -- remove 4
cargo run -- list
删除后,列表从5个任务变成4个。ID为4的"练习Rust错误处理"消失了。
删除用到了 .retain() 方法:
todos.retain(|t| t.id != id);这行代码的意思是:保留所有ID不等于指定ID的任务。简洁又清晰。
cargo run -- clear
cargo run -- list
执行 clear 命令后,显示"🗑️ 已清除 2 个已完成任务"。
再看列表,只剩2个待办任务了:
注意ID不连续,这是正常的。因为ID为1和3被清除了,ID为4被删除了。
cat ~/.todos.json
数据文件是pretty print的JSON格式,可读性很好:
[
{
"id": 2,
"content": "写TODO工具",
"completed": false,
"created_at": "2025-11-01 16:17"
},
{
"id": 5,
"content": "完成第二篇文章",
"completed": false,
"created_at": "2025-11-01 16:17"
}
]这就是 serde_json::to_string_pretty() 的效果,比压缩的JSON好读多了。
试试各种错误情况:
cargo run -- done 999 # 不存在的ID
cargo run -- xyz # 无效命令
cargo run -- add # 缺少参数
每种错误都有友好的提示:
良好的错误提示是优秀工具的标志。 Rust让我们很容易做到这一点。
通过这个项目,我们实际用到了很多Rust核心特性:
fn save_todos(todos: &Vec<Todo>) // 借用,不转移所有权如果写成 todos: Vec<Todo>(不加&),调用后外面就不能再用这个变量了。加了 & 就只是借用,用完还回去。
match args[1].as_str() {
"add" => { ... }
"list" | "ls" => { ... }
_ => { ... }
}Rust的 match 必须覆盖所有情况,编译器会检查。这避免了很多bug。
todos.iter().map(|t| t.id).max() // 返回 Option<u32>
fs::read_to_string(&file_path) // 返回 Result<String, Error>Rust没有null,用 Option 表示可能没有值。用 Result 表示可能出错。这让错误处理更明确。
todos.iter() // 不可变迭代
todos.iter_mut() // 可变迭代
todos.iter().filter() // 过滤
todos.retain() // 保留符合条件的Rust的迭代器是零成本抽象,写起来优雅,性能和手写循环一样。
.map(|t| t.id) // 提取字段
.find(|t| t.id == id) // 查找
.unwrap_or_else(|_| Vec::new()) // 提供默认值闭包语法简洁,|参数| 是参数列表,后面是函数体。
#[derive(Debug, Serialize, Deserialize, Clone)]通过 derive 自动实现常用trait,不用手写一堆模板代码。
这个TODO工具虽然简单,但麻雀虽小五脏俱全:
功能完整:
代码质量:
Rust特性应用:
大约200行代码,实现了一个真正可用的工具。 这就是Rust的魅力:在保证安全性的同时,代码还能写得很简洁。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。