跳转到内容

用 Rust 编写待办事项命令行工具

这个项目要做一个住在终端里的小小待办事项清单。你输入 todo add 买牛奶,它就记住了。关掉终端,明天再回来输入 todo list,你的任务还在那里。

它很适合当作你的第二个项目,因为它看起来就像一个真正的工具。它会用到结构体、枚举、vector、命令行参数、文件读写,以及搭配 ? 使用的 Result。我们会把每个部分单独做出来,先在 Playground 里试一试,最后再把它们拼到一起。

做好的工具用起来是这样的:

Terminal window
$ cargo run -- add 买牛奶
已添加任务 1。
$ cargo run -- add 给奶奶打电话
已添加任务 2。
$ cargo run -- done 1
已将任务 1 标记为完成。
$ cargo run -- list
[x] 1: 买牛奶
[ ] 2: 给奶奶打电话
$ cargo run -- remove 1
已删除任务 1:买牛奶

-- 后面的所有内容都会传给你的程序,而不是传给 Cargo。任务保存在一个叫 tasks.txt 的文件里,每行一个任务:

2|0|给奶奶打电话

每一行的格式是 id|done|text,其中 done 为 1 表示已完成,为 0 表示未完成。

先新建一个项目:

Terminal window
cargo new todo
cd todo

每个任务都有一个编号(方便你指定它)、一个是否完成的标记,以及一段文字。用结构体来表示再合适不过。我们还给它加两个方法:一个把任务变成写进文件的一行文字,另一个从一行文字里把任务读回来。

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// 把任务变成一行文字,比如 "1|0|买牛奶"。
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// 从一行文字读回任务。这一行有问题时返回 None。
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
fn main() {
let task = Task { id: 1, done: false, text: String::from("买牛奶") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|给奶奶打电话"));
println!("{:?}", Task::from_line("3|0|了解 A|B 测试"));
println!("{:?}", Task::from_line("这一行坏掉了"));
}

输出:

1|0|买牛奶
Some(Task { id: 2, done: true, text: "给奶奶打电话" })
Some(Task { id: 3, done: false, text: "了解 A|B 测试" })
None
▶ 动手试一试

有两个细节值得放慢速度看一看:

  • splitn(3, '|') 最多切成 3 段。前两个 | 把字段分开,之后的所有内容都算作任务文字,哪怕文字里本身就有 |。如果只用 split('|'),“了解 A|B 测试”这个任务就会丢掉一半文字。
  • from_line 返回一个 Option,并且每一步都用了 ?。只要缺了某一段,或者 id 不是数字,整个函数就返回 None。.ok() 把 parse() 返回的 Result 转成 Option,这样 ? 才能用在它上面。

这个工具认识四个命令。每个命令都是枚举的一个变体,有些变体还带着数据:Add 需要任务文字,Done 和 Remove 需要任务编号。

#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("请给出任务编号"))?;
arg.parse().map_err(|_| format!("“{arg}”不是任务编号"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("用法:todo add <任务内容>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("未知命令“{other}”")),
}
}
fn main() {
let examples = [
vec!["add", "去超市", "买牛奶"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "two"],
vec!["remove"],
vec!["dance"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

输出:

["add", "去超市", "买牛奶"] -> Ok(Add("去超市 买牛奶"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "two"] -> Err("“two”不是任务编号")
["remove"] -> Err("请给出任务编号")
["dance"] -> Err("未知命令“dance”")
▶ 动手试一试

在真正的程序里,args 来自命令行。你在命令行参数页面里已经看到过,std::env::args() 的第一项是程序自己的名字,所以要跳过它:

let args: Vec<String> = std::env::args().skip(1).collect();

为什么 add 要用 args[1..].join(" ")?因为你的 shell 会把 todo add 去超市 买牛奶 拆成几个独立的词:add、去超市、买牛奶。把 add 后面的所有内容重新拼起来,就能还原任务文字,你也就不需要加引号了。

注意,parse_command 永远不会崩溃。错误的输入会变成一个带着有用提示的 Err,至于怎么处理它,由调用者决定。

所有任务都放在一个 Vec<Task> 里。每个命令都是一个修改这个 vector 的小函数:

  • Add:找出目前最大的 id,加 1,然后 push 一个新任务。
  • Done:找到这个 id 对应的任务,设置 done = true。
  • Remove:找到任务所在的位置,把它删掉。
  • List:打印所有任务。
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("没有编号为 {id} 的任务")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("没有编号为 {id} 的任务")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("没有要做的事,享受这一天吧!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn main() {
let mut tasks = Vec::new();
add_task(&mut tasks, String::from("买牛奶"));
add_task(&mut tasks, String::from("给奶奶打电话"));
add_task(&mut tasks, String::from("学习 Rust"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("已删除:{}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("错误:{message}");
}
print_tasks(&tasks);
}

输出:

已删除:买牛奶
错误:没有编号为 99 的任务
[ ] 2: 给奶奶打电话
[x] 3: 学习 Rust
▶ 动手试一试

这里有几个选择是故意这么做的:

  • id 取自现有最大的 id,而不是 tasks.len() + 1。如果你删掉任务 1 再添加一个新任务,用长度算出来的 id 会和已有的重复。
  • mark_done 接收 &mut [Task](一个 slice),因为它只修改元素。add_task 和 remove_task 接收 &mut Vec<Task>,因为它们会改变长度。
  • 错误类型是 Result<_, String>。String 错误是能用的最简单的方案。更大的程序可以看看自定义错误。

iter_mut().find(...) 给你一个指向匹配任务的可变引用,所以 task.done = true 改的就是 vector 里的那个任务。关于 find 和 position 的更多内容,见迭代器。

现在,任务需要在程序结束后保存下来。我们用 std::fs::write 保存,用 std::fs::read_to_string 读取。两者都可能失败,所以都返回 Result,而 ? 运算符会把错误交给调用者:

use std::fs;
use std::io;
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
// 第一次运行程序时,文件还不存在。
// 这不是错误,只是表示“还没有任务”。
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}

NotFound 这种情况比看上去更重要。少了它,第一次运行 todo add 就会失败,报错“No such file or directory”(文件或目录不存在),因为你还什么都没保存过。其他错误(比如“permission denied”,也就是没有权限)仍然会往上传递。

filter_map(Task::from_line) 逐行读取,只留下能解析成功的那些行。要注意,它会悄悄跳过有问题的行。如果有人手动编辑文件时打错了字,这样很友好;但这也意味着错误可能没人发现。下面的扩展思路里有一条,就是改成给出警告。

关于读写文件的更多内容,见文件读写。

下面是全部代码。把它放进 src/main.rs,然后用 cargo run -- add 买牛奶、cargo run -- list 等命令运行。

核心逻辑在 run_command 里,它只操作一个 Vec<Task>,对文件一无所知。main 只负责读取文件、调用 run_command,然后保存。把两者分开,逻辑就很容易测试,下一步做 Playground 版本也正是靠这一点。

use std::env;
use std::error::Error;
use std::fs;
use std::io;
use std::process;
const FILE: &str = "tasks.txt";
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("请给出任务编号"))?;
arg.parse().map_err(|_| format!("“{arg}”不是任务编号"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("用法:todo add <任务内容>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("未知命令“{other}”")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("没有编号为 {id} 的任务")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("没有编号为 {id} 的任务")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("没有要做的事,享受这一天吧!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("已添加任务 {id}。");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("已将任务 {id} 标记为完成。");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("已删除任务 {id}:{}", task.text);
}
}
Ok(())
}
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}
fn run() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().skip(1).collect();
let mut tasks = load_tasks(FILE)?;
run_command(&args, &mut tasks)?;
save_tasks(FILE, &tasks)?;
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("错误:{e}");
process::exit(1);
}
}

说一下 run() -> Result<(), Box<dyn Error>>。我们的函数会返回两种不同的错误类型:文件相关的函数返回 io::Error,命令相关的函数返回 String。Box<dyn Error> 是一个能装下任何错误类型的盒子,所以 ? 对两者都能用。然后 main 用 eprintln!(它写到错误输出)打印错误,并以退出码 1 结束程序,这是表示“出了点问题”的常见信号。

第 6 步:能在 Playground 里运行的版本

Section titled “第 6 步:能在 Playground 里运行的版本”

Playground 没法传命令行参数,我们也不想让它依赖文件。所以这个版本保留同一个 run_command,但改为喂给它一串预先写好的命令,最后再打印出 tasks.txt 会保存的内容:

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("请给出任务编号"))?;
arg.parse().map_err(|_| format!("“{arg}”不是任务编号"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("用法:todo add <任务内容>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("未知命令“{other}”")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("没有编号为 {id} 的任务")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("没有编号为 {id} 的任务")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("没有要做的事,享受这一天吧!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("已添加任务 {id}。");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("已将任务 {id} 标记为完成。");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("已删除任务 {id}:{}", task.text);
}
}
Ok(())
}
fn main() {
// 每一行就是你在 `todo` 后面输入的内容。
let script = [
"list",
"add 买牛奶",
"add 给奶奶打电话",
"add 学习 Rust",
"done 3",
"remove 1",
"done 9",
"jump",
"list",
];
let mut tasks: Vec<Task> = Vec::new();
for line in script {
println!("$ todo {line}");
let args: Vec<String> = line.split_whitespace().map(String::from).collect();
if let Err(message) = run_command(&args, &mut tasks) {
println!("错误:{message}");
}
}
println!();
println!("tasks.txt 里会保存这些内容:");
for task in &tasks {
println!("{}", task.to_line());
}
}

输出:

$ todo list
没有要做的事,享受这一天吧!
$ todo add 买牛奶
已添加任务 1。
$ todo add 给奶奶打电话
已添加任务 2。
$ todo add 学习 Rust
已添加任务 3。
$ todo done 3
已将任务 3 标记为完成。
$ todo remove 1
已删除任务 1:买牛奶
$ todo done 9
错误:没有编号为 9 的任务
$ todo jump
错误:未知命令“jump”
$ todo list
[ ] 2: 给奶奶打电话
[x] 3: 学习 Rust
tasks.txt 里会保存这些内容:
2|0|给奶奶打电话
3|1|学习 Rust
▶ 动手试一试

改一改这串命令再运行一次。试试先删掉一个任务再添加新任务,确认新的 id 没有重复。

我们的 id|done|text 格式很好读,但 to_line 和 from_line 都得你亲手写。任务的信息越来越多(截止日期、优先级、标签),这些手写代码也会越来越多。

在真实项目里,大家通常把这件事交给 serde(英文)系列 crate。用下面的命令添加它们:

Terminal window
cargo add serde --features derive
cargo add serde_json

然后只要一行 derive,保存和读取就都有了:

use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn main() {
let tasks = vec![
Task { id: 1, done: false, text: String::from("买牛奶") },
Task { id: 2, done: true, text: String::from("给奶奶打电话") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("重新读回了 {} 个任务", loaded.len());
}

输出:

[{"id":1,"done":false,"text":"买牛奶"},{"id":2,"done":true,"text":"给奶奶打电话"}]
重新读回了 2 个任务
▶ 动手试一试

像之前一样,用 fs::write 把这个 JSON 字符串写进 tasks.json 就行。用 serde 处理 JSON 页面会详细讲这些内容。

在什么都不返回的 main 里使用 ?。 很容易写成这样:

use std::fs;
fn main() {
let text = fs::read_to_string("tasks.txt")?;
println!("{text}");
}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:47
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let text = fs::read_to_string("tasks.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`

? 的意思是“如果这是一个错误,就从当前函数把它返回出去”。普通的 main 返回 (),错误没有地方可去。编译器说的就是这个:? 只能用在返回 Result 或 Option 的函数里,而这个函数返回的是 ()。解决办法有两个:给 main 加上像 Result<(), Box<dyn Error>> 这样的返回类型;或者像我们的程序那样,把工作放进一个返回 Result 的 run() 函数里,再在 main 里统一处理错误。

把文件不存在当成失败。 如果 load_tasks 只写 fs::read_to_string(path)?,程序第一次运行时就会失败,因为那时文件还不存在。应该检查 io::ErrorKind::NotFound,并从一个空列表开始。

用列表长度当下一个 id。 有任务 1、2、3 时,删掉任务 1 后还剩两个任务。len() + 1 又会得到 3,于是两个任务共用一个 id。应该用最大的 id 加一。

  • 撤销“完成”。 添加一个 undone <id> 命令,把 done 改回 false。
  • 编辑任务。 edit 2 今晚给奶奶打电话 替换任务 2 的文字。
  • 清除已完成的任务。 clear 删除所有已完成的任务。可以看看 Vec::retain(英文)。
  • 对有问题的行给出警告。 不再悄悄跳过错误的行,而是打印一条带行号的警告。
  • 一个 help 命令,打印所有命令和它们的用法。
  • 写测试。 parse_command 和 run_command 都不碰文件,所以很容易测试。
  • 用真正的参数解析器。 命令行参数页面提到过的 clap(英文)crate,能免费给你 --help 和子命令。
  • Task 结构体保存数据,to_line 和 from_line 把它转成文字,再转回来。
  • Command 枚举描述用户想做什么。parse_command 把参数变成一个 Command,或者一个带有用提示的 Err。
  • Vec<Task> 保存整个列表。几个小函数负责添加、查找、修改和删除任务。
  • fs::read_to_string 和 fs::write 返回 Result,? 把错误往上传。第一次运行时文件不存在,就当作“没有任务”处理。
  • 把逻辑(run_command)和文件、参数分开,测试起来很容易,也能在 Playground 里运行。

小测验

1. 为什么 from_line 用 splitn(3, '|'),而不是 split('|')?

2. tasks.txt 还不存在时,load_tasks 会怎么做?

3. 为什么不能在没有返回类型的 fn main() 里直接使用 ??

4. 想把结构体保存成 JSON,通常会升级用哪个 crate?

得分: 0 / 4