用 Rust 编写待办事项命令行工具
这个项目要做一个住在终端里的小小待办事项清单。你输入 todo add 买牛奶,它就记住了。关掉终端,明天再回来输入 todo list,你的任务还在那里。
它很适合当作你的第二个项目,因为它看起来就像一个真正的工具。它会用到结构体、枚举、vector、命令行参数、文件读写,以及搭配 ? 使用的 Result。我们会把每个部分单独做出来,先在 Playground 里试一试,最后再把它们拼到一起。
你将做出什么
Section titled “你将做出什么”做好的工具用起来是这样的:
$ 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 表示未完成。
先新建一个项目:
cargo new todocd todo第 1 步:Task 结构体
Section titled “第 1 步:Task 结构体”每个任务都有一个编号(方便你指定它)、一个是否完成的标记,以及一段文字。用结构体来表示再合适不过。我们还给它加两个方法:一个把任务变成写进文件的一行文字,另一个从一行文字里把任务读回来。
#[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,这样?才能用在它上面。
第 2 步:Command 枚举
Section titled “第 2 步:Command 枚举”这个工具认识四个命令。每个命令都是枚举的一个变体,有些变体还带着数据: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,至于怎么处理它,由调用者决定。
第 3 步:操作任务列表
Section titled “第 3 步:操作任务列表”所有任务都放在一个 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 的更多内容,见迭代器。
第 4 步:保存和读取文件
Section titled “第 4 步:保存和读取文件”现在,任务需要在程序结束后保存下来。我们用 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) 逐行读取,只留下能解析成功的那些行。要注意,它会悄悄跳过有问题的行。如果有人手动编辑文件时打错了字,这样很友好;但这也意味着错误可能没人发现。下面的扩展思路里有一条,就是改成给出警告。
关于读写文件的更多内容,见文件读写。
第 5 步:完整的程序
Section titled “第 5 步:完整的程序”下面是全部代码。把它放进 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 没有重复。
进阶:用 serde 保存成 JSON
Section titled “进阶:用 serde 保存成 JSON”我们的 id|done|text 格式很好读,但 to_line 和 from_line 都得你亲手写。任务的信息越来越多(截止日期、优先级、标签),这些手写代码也会越来越多。
在真实项目里,大家通常把这件事交给 serde(英文)系列 crate。用下面的命令添加它们:
cargo add serde --features derivecargo 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
- 一个 I/O 项目:构建命令行程序 —《Rust 程序设计语言》:一个更大的命令行项目教程,关于怎么把
main拆成更小、更好测试的部分,有很多好建议。 std::fs— 标准库文档(英文):read_to_string、write以及保存和读取代码背后的其他文件函数。std::env::args— 标准库文档(英文):命令行参数是怎么传进来的,以及为什么第一个参数是程序自己的名字。str::splitn— 标准库文档(英文):把一行切成最多 N 段。正是这个技巧让带|的任务文字保持完整。serde_json— docs.rs(英文):进阶部分用到的 JSON crate,有to_string和from_str的示例。serde 指南(英文)讲解了 derive 属性。