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