真实项目 advanced 45 分钟 更新 2026-06-15

真实项目:命令行待办应用

综合结构体、错误处理、serde 与 clap,做一个可持久化的待办 CLI。

真实项目:命令行待办应用

你会学到什么

  • 把前面学的内容综合到一个真实小项目里:建模、错误、集合、序列化、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.rsTodoList,完全不涉及命令行或文件,因此可以纯逻辑单测:

pub fn add(&mut self, title: &str) -> Result<u32, TodoError> {
    let title = title.trim();
    if title.is_empty() {
        return Err(TodoError::EmptyTitle);
    }
    // ... 分配 id 并入列
}

错误用 thiserror 定义(NotFoundEmptyTitle),持久化用 serde 把整个清单序列化成 JSON。

main.rs 只做三件事:用 clap 解析子命令、加载/保存文件、把命令转发给 TodoListmain 返回 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"