Beginner Projects

Simple To-Do List

A command-line to-do list manager models tasks with unique IDs, priority levels, and completion states (Pending vs Done), supporting CRUD subcommands (add, complete, remove, list) and durable file persistence using a tab-separated plain-text format.

To-Do List Persistence & Command Architecture:

  CLI Command (`Add`, `Complete(id)`, `Remove(id)`, `List(filter)`)
        |
        v
  [ `TodoStore` (`next_id: u64`, `tasks: Vec<Task>`) ]
        |
        +--- `.serialize()` ---> Tab-Separated Disk Format (`id \t done \t priority \t title`)
        +--- `.deserialize()` <- Loaded on startup via `fs::read_to_string`

Domain Model and Storage Schema

Type Key Fields / Variants Responsibility
Priority Low, Medium, High Implements Ord so tasks can be sorted highest-priority first.
Task id: u64, title: String, priority: Priority, completed: bool Single task record serialized as `"{id}\t{0
TodoStore next_id: u64, tasks: Vec<Task> Manages ID generation, state transitions, filtering, and disk save/load.

Complete Runnable Implementation

use std::env;
use std::fs;
use std::io;
use std::path::Path;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Priority {
    Low,
    Medium,
    High,
}

impl Priority {
    pub const fn code(self) -> &'static str {
        match self {
            Priority::Low => "L",
            Priority::Medium => "M",
            Priority::High => "H",
        }
    }

    pub fn from_code(s: &str) -> Option<Self> {
        match s.trim() {
            "L" | "low" => Some(Priority::Low),
            "M" | "med" | "medium" => Some(Priority::Medium),
            "H" | "high" => Some(Priority::High),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Task {
    pub id: u64,
    pub completed: bool,
    pub priority: Priority,
    pub title: String,
}

#[derive(Debug)]
pub struct TodoStore {
    next_id: u64,
    tasks: Vec<Task>,
}

impl Default for TodoStore {
    fn default() -> Self {
        Self::new()
    }
}

impl TodoStore {
    pub const fn new() -> Self {
        Self {
            next_id: 1,
            tasks: Vec::new(),
        }
    }

    pub fn add(&mut self, title: impl Into<String>, priority: Priority) -> u64 {
        let id = self.next_id;
        self.next_id += 1;
        // Strip tabs/newlines so the line-oriented storage format cannot be corrupted:
        let clean_title = title.into().replace(['\t', '\r', '\n'], " ");
        self.tasks.push(Task {
            id,
            completed: false,
            priority,
            title: clean_title,
        });
        id
    }

    pub fn complete(&mut self, id: u64) -> bool {
        if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
            task.completed = true;
            true
        } else {
            false
        }
    }

    pub fn remove(&mut self, id: u64) -> bool {
        if let Some(pos) = self.tasks.iter().position(|t| t.id == id) {
            self.tasks.remove(pos);
            true
        } else {
            false
        }
    }

    pub fn pending_tasks(&self) -> impl Iterator<Item = &Task> {
        self.tasks.iter().filter(|t| !t.completed)
    }

    pub fn save_to_file(&self, path: impl AsRef<Path>) -> io::Result<()> {
        let mut out = String::new();
        for t in &self.tasks {
            let done_flag = u8::from(t.completed);
            out.push_str(&format!(
                "{}\t{}\t{}\t{}\n",
                t.id,
                done_flag,
                t.priority.code(),
                t.title
            ));
        }
        fs::write(path, out)
    }

    pub fn load_from_file(path: impl AsRef<Path>) -> io::Result<Self> {
        let content = fs::read_to_string(path)?;
        let mut tasks = Vec::new();
        let mut max_id = 0u64;

        for line in content.lines() {
            if line.trim().is_empty() {
                continue;
            }
            let parts: Vec<&str> = line.splitn(4, '\t').collect();
            if let [id_str, done_str, prio_str, title] = parts.as_slice() {
                let id = id_str.parse::<u64>().unwrap_or(0);
                let completed = *done_str == "1";
                let priority = Priority::from_code(prio_str).unwrap_or(Priority::Medium);
                max_id = max_id.max(id);
                tasks.push(Task {
                    id,
                    completed,
                    priority,
                    title: (*title).to_string(),
                });
            }
        }

        Ok(Self {
            next_id: max_id + 1,
            tasks,
        })
    }
}

fn main() -> io::Result<()> {
    let db_path = env::temp_dir().join("rust_ch66_todos.tsv");
    let mut store = TodoStore::new();

    let id1 = store.add("Write unit tests for parser", Priority::High);
    let id2 = store.add("Update Cargo.toml edition to 2024", Priority::Medium);
    let _id3 = store.add("Clean up temporary logs", Priority::Low);

    assert!(store.complete(id1));
    assert!(store.remove(id2));

    // Persist to disk and reload into a fresh TodoStore instance:
    store.save_to_file(&db_path)?;
    let reloaded = TodoStore::load_from_file(&db_path)?;

    for task in reloaded.pending_tasks() {
        println!(
            "[{}] #{} ({:?}): {}",
            if task.completed { "x" } else { " " },
            task.id,
            task.priority,
            task.title
        );
    }

    fs::remove_file(&db_path)?;
    Ok(())
}
[ ] #3 (Low): Clean up temporary logs