Overview
A to-do list is a small enough problem that it is easy to forget how much it is actually asking of a language: model a task that can be in one of a few distinct states, hold a growing collection of them in memory, and persist that collection to disk between runs without corrupting it. Rust's answer to the first two parts is a struct for the data and an enum for the finite set of states a task can be in — Status::Todo, Status::InProgress, or Status::Done, and nothing else, because the compiler will not let a Status value be anything outside those three variants.
The third part — reading and writing the file — is where Rust's Result type earns its keep. Every std::fs operation that can fail (the file might not exist, the disk might be full, permissions might be wrong) returns a Result<T, std::io::Error> instead of throwing an exception or returning a null-like sentinel. By the end of this tutorial you will have a working task manager backed by a Vec<Task>, with save and load functions that use the ? operator to propagate I/O failures instead of swallowing or panicking on them.
- A
Statusenum withTodo,InProgress, andDonevariants. - A
Taskstruct combining an id, a description, and aStatus. - A
TaskManagerstruct that owns aVec<Task>and assigns unique ids automatically. - Add, list, complete, and remove operations implemented as methods on
TaskManager. save_to_file()andload_from_file()functions built onstd::fsand the?operator.- A
loop-based menu tying every operation together into one running program.
Prerequisites
- Structs — defining a
structwith named fields and methods in animplblock. - Enums — defining an
enumwith unit-like variants and matching on them withmatch. - Ownership basics — what it means for a function to take a value
by valueversusby reference. Result<T, E>and the?operator — propagating an error out of a function instead of handling it immediately.- Reading from standard input with
std::io::stdin().read_line(&mut buffer).
Project Structure
Create a new binary project with cargo new task_manager and put everything in the generated src/main.rs — this project is small enough that splitting it across modules would add ceremony without adding clarity. The file has three parts, in order: the Status enum and Task struct that model one to-do item, the TaskManager struct with its impl block that owns every task and every operation on them, and main(), which owns nothing itself and only ever calls methods on a single TaskManager instance.
That last rule matters in Rust more than it did in something like Java: main() never reaches into manager.tasks directly to push or remove an item, because keeping every mutation behind a method on TaskManager means there is exactly one place in the whole program that needs to stay consistent with the on-disk file format you will define in Step 5.
Step 1: Define the Task Struct and Status Enum
A Rust enum is not just a set of named integer constants the way it can be in some languages — it defines a closed set of possible values, and matching on one is checked exhaustively by the compiler. Deriving PartialEq on Status is what lets you later compare two statuses with ==; deriving Clone lets you copy a Status out of a Task without moving (and thereby emptying) the field it came from.
// Status models the finite set of states a task can be in. Because it is a// real enum (not a string or an int), the compiler guarantees a Status can// never hold a fourth, misspelled, or otherwise invalid value.#[derive(Debug, Clone, PartialEq)]enum Status { Todo, InProgress, Done,}
impl Status { // Converts a Status to the short code stored on disk. Takes &self (a // shared borrow), never self, because printing or saving a status // should never require giving up ownership of it. fn to_code(&self) -> &'static str { match self { Status::Todo => "TODO", Status::InProgress => "IN_PROGRESS", Status::Done => "DONE", } }
// Parses a status back from its on-disk code. Returns a Result instead // of panicking, because a hand-edited or corrupted save file is a // normal, recoverable situation, not a bug in the program itself. fn from_code(code: &str) -> Result<Status, String> { match code { "TODO" => Ok(Status::Todo), "IN_PROGRESS" => Ok(Status::InProgress), "DONE" => Ok(Status::Done), other => Err(format!("unknown status code: {other}")), } }}
// One to-do item. id is assigned by TaskManager, never chosen by the caller.// description is an owned String (not a borrowed &str) because a Task must// keep its text alive for as long as the Task itself exists in the Vec.#[derive(Debug, Clone)]struct Task { id: u32, description: String, status: Status,}Step 2: Build a TaskManager Around a Vec<Task>
TaskManager owns a Vec<Task>, Rust's growable array type, plus a next_id counter so the manager — never the caller — decides every task's id. Vec::new() starts empty and with no heap allocation at all; the first push() allocates, and later pushes grow the backing buffer automatically, so nothing here needs to pre-size the collection.
struct TaskManager { tasks: Vec<Task>, // Every task currently in memory, in insertion order next_id: u32, // Next id to hand out; incremented after every successful add}
impl TaskManager { fn new() -> TaskManager { TaskManager { tasks: Vec::new(), // No tasks yet; no heap allocation happens until the first push() next_id: 1, } }}Step 3: Add and List Tasks
add_task() takes description: String by value, which means ownership of that String moves into the method call and then straight into the new Task — the caller is left without a usable copy, and does not need one, since the task now owns the only copy that matters. list_tasks() only needs to read each task, so it borrows the Vec with &self.tasks inside a for loop rather than taking ownership of it.
impl TaskManager { // description: String takes ownership -- the caller's String is moved // in here, then moved again into the Task pushed onto self.tasks. fn add_task(&mut self, description: String) -> u32 { let id = self.next_id; self.tasks.push(Task { id, description, status: Status::Todo }); self.next_id += 1; // Guarantees every future task gets a fresh, never-reused id id }
fn list_tasks(&self) { if self.tasks.is_empty() { println!("No tasks yet."); return; } for task in &self.tasks { // Borrows each Task in turn; ownership never leaves the Vec let marker = if task.status == Status::Done { "x" } else { " " }; println!("[{marker}] #{} {} - {}", task.id, task.description, task.status.to_code()); } }}Click Run to see what this code prints.
Step 4: Complete and Remove Tasks
complete_task() uses iter_mut(), which yields a &mut Task for each element instead of a read-only &Task, so the loop body is actually allowed to assign task.status = Status::Done. remove_task() uses Vec::retain(), which keeps every element the closure returns true for and drops the rest in place — the idiomatic Rust way to delete-by-predicate without manually tracking an index while mutating the same Vec you are iterating.
impl TaskManager { fn complete_task(&mut self, id: u32) -> bool { for task in self.tasks.iter_mut() { // &mut Task per element -- this loop is allowed to mutate in place if task.id == id { task.status = Status::Done; return true; } } false // No task with that id; the caller decides how to report this }
fn remove_task(&mut self, id: u32) -> bool { let before = self.tasks.len(); self.tasks.retain(|t| t.id != id); // Keeps everything except the matching task; no manual index bookkeeping self.tasks.len() < before // True only if retain() actually dropped something }}Step 5: Save Tasks to a File
The file format here is deliberately simple: one task per line, three fields separated by |. fs::File::create() returns io::Result<File>, and the ? operator after it means "if this failed, stop save_to_file() right now and return that same io::Error to whoever called it" — no manual if let Err(e) = ... { return Err(e); } boilerplate needed. writeln!() also returns a Result, so it gets the same ? treatment on every line.
use std::fs;use std::io::{self, Write};
impl TaskManager { fn save_to_file(&self, path: &str) -> io::Result<()> { let mut file = fs::File::create(path)?; // Truncates/creates the file; ? bails out early on failure for task in &self.tasks { // Borrow each Task; saving never needs to own them writeln!(file, "{}|{}|{}", task.id, task.description, task.status.to_code())?; } Ok(()) // Explicit unit Ok: the function's job is the side effect of writing, not producing a value }}Step 6: Load Tasks From a File
load_from_file() is an associated function, not a method — it takes no &self because its whole job is to build a brand-new TaskManager from scratch, so there is no existing instance to borrow. Parsing each line can fail in two independent ways (a non-numeric id, or an unrecognized status code), and std::io::Error does not implement a direct conversion From<String>, so both failure paths are converted explicitly with .map_err(...) before the ? operator is allowed to propagate them.
impl TaskManager { fn load_from_file(path: &str) -> io::Result<TaskManager> { let contents = fs::read_to_string(path)?; // ? returns early with io::Error if the file can't be read let mut manager = TaskManager::new();
for line in contents.lines() { if line.trim().is_empty() { continue; // Skip blank lines, e.g. a trailing newline at end of file } let parts: Vec<&str> = line.split('|').collect(); if parts.len() != 3 { continue; // Skip a malformed line rather than aborting the whole load }
// parts[0].parse::<u32>() returns Result<u32, ParseIntError>. io::Error has // no built-in From<ParseIntError>, so map_err() converts explicitly before ?. let id: u32 = parts[0].parse().map_err(|_| { io::Error::new(io::ErrorKind::InvalidData, format!("invalid id in line: {line}")) })?; let status = Status::from_code(parts[2]).map_err(|e| { io::Error::new(io::ErrorKind::InvalidData, e) // String -> io::Error via the (kind, message) form })?;
manager.tasks.push(Task { id, description: parts[1].to_string(), status }); if id >= manager.next_id { manager.next_id = id + 1; // Keep next_id ahead of every id actually loaded from disk } }
Ok(manager) }}Step 7: Build the Menu Loop
main() tries to load tasks.txt on startup and falls back to TaskManager::new() on any error — including "file does not exist," which is expected and normal on the very first run. The loop reads one line of input per iteration and matches on the trimmed choice string; the save happens explicitly on exit rather than after every single edit, so the program only touches disk when the user actually asks it to.
use std::io::{self, Write};
fn main() { let path = "tasks.txt"; let mut manager = match TaskManager::load_from_file(path) { Ok(m) => m, Err(_) => TaskManager::new(), // No file yet on first run (or it's unreadable) -- start empty either way };
loop { println!("\n===== CLI TASK MANAGER ====="); println!("1. Add Task"); println!("2. List Tasks"); println!("3. Complete Task"); println!("4. Remove Task"); println!("5. Save & Exit"); print!("Enter your choice: "); io::stdout().flush().unwrap(); // print! doesn't auto-flush like println!; flush so the prompt shows before input
let mut choice = String::new(); io::stdin().read_line(&mut choice).expect("failed to read input");
match choice.trim() { "1" => { print!("Enter task description: "); io::stdout().flush().unwrap(); let mut desc = String::new(); io::stdin().read_line(&mut desc).expect("failed to read input"); let id = manager.add_task(desc.trim().to_string()); println!("Added! Assigned ID: {id}"); } "2" => manager.list_tasks(), "3" => { print!("Enter task ID to complete: "); io::stdout().flush().unwrap(); let mut input = String::new(); io::stdin().read_line(&mut input).expect("failed to read input"); match input.trim().parse::<u32>() { Ok(id) if manager.complete_task(id) => println!("Task marked complete."), Ok(_) => println!("No task with that ID."), Err(_) => println!("Please enter a valid number."), } } "4" => { print!("Enter task ID to remove: "); io::stdout().flush().unwrap(); let mut input = String::new(); io::stdin().read_line(&mut input).expect("failed to read input"); match input.trim().parse::<u32>() { Ok(id) if manager.remove_task(id) => println!("Task removed."), Ok(_) => println!("No task with that ID."), Err(_) => println!("Please enter a valid number."), } } "5" => { match manager.save_to_file(path) { Ok(()) => println!("Saved. Goodbye!"), Err(e) => println!("Failed to save: {e}"), } break; // Exit the loop; main() ends right after, dropping manager } _ => println!("Invalid choice, try again."), } }}Complete Code
Here is the full program assembled in the correct order, ready to save as src/main.rs and run with cargo run.
use std::fs;use std::io::{self, Write};
#[derive(Debug, Clone, PartialEq)]enum Status { Todo, InProgress, Done,}
impl Status { fn to_code(&self) -> &'static str { match self { Status::Todo => "TODO", Status::InProgress => "IN_PROGRESS", Status::Done => "DONE", } }
fn from_code(code: &str) -> Result<Status, String> { match code { "TODO" => Ok(Status::Todo), "IN_PROGRESS" => Ok(Status::InProgress), "DONE" => Ok(Status::Done), other => Err(format!("unknown status code: {other}")), } }}
#[derive(Debug, Clone)]struct Task { id: u32, description: String, status: Status,}
struct TaskManager { tasks: Vec<Task>, next_id: u32,}
impl TaskManager { fn new() -> TaskManager { TaskManager { tasks: Vec::new(), next_id: 1 } }
fn add_task(&mut self, description: String) -> u32 { let id = self.next_id; self.tasks.push(Task { id, description, status: Status::Todo }); self.next_id += 1; id }
fn list_tasks(&self) { if self.tasks.is_empty() { println!("No tasks yet."); return; } for task in &self.tasks { let marker = if task.status == Status::Done { "x" } else { " " }; println!("[{marker}] #{} {} - {}", task.id, task.description, task.status.to_code()); } }
fn complete_task(&mut self, id: u32) -> bool { for task in self.tasks.iter_mut() { if task.id == id { task.status = Status::Done; return true; } } false }
fn remove_task(&mut self, id: u32) -> bool { let before = self.tasks.len(); self.tasks.retain(|t| t.id != id); self.tasks.len() < before }
fn save_to_file(&self, path: &str) -> io::Result<()> { let mut file = fs::File::create(path)?; for task in &self.tasks { writeln!(file, "{}|{}|{}", task.id, task.description, task.status.to_code())?; } Ok(()) }
fn load_from_file(path: &str) -> io::Result<TaskManager> { let contents = fs::read_to_string(path)?; let mut manager = TaskManager::new();
for line in contents.lines() { if line.trim().is_empty() { continue; } let parts: Vec<&str> = line.split('|').collect(); if parts.len() != 3 { continue; }
let id: u32 = parts[0].parse().map_err(|_| { io::Error::new(io::ErrorKind::InvalidData, format!("invalid id in line: {line}")) })?; let status = Status::from_code(parts[2]).map_err(|e| { io::Error::new(io::ErrorKind::InvalidData, e) })?;
manager.tasks.push(Task { id, description: parts[1].to_string(), status }); if id >= manager.next_id { manager.next_id = id + 1; } }
Ok(manager) }}
fn main() { let path = "tasks.txt"; let mut manager = match TaskManager::load_from_file(path) { Ok(m) => m, Err(_) => TaskManager::new(), };
loop { println!("\n===== CLI TASK MANAGER ====="); println!("1. Add Task"); println!("2. List Tasks"); println!("3. Complete Task"); println!("4. Remove Task"); println!("5. Save & Exit"); print!("Enter your choice: "); io::stdout().flush().unwrap();
let mut choice = String::new(); io::stdin().read_line(&mut choice).expect("failed to read input");
match choice.trim() { "1" => { print!("Enter task description: "); io::stdout().flush().unwrap(); let mut desc = String::new(); io::stdin().read_line(&mut desc).expect("failed to read input"); let id = manager.add_task(desc.trim().to_string()); println!("Added! Assigned ID: {id}"); } "2" => manager.list_tasks(), "3" => { print!("Enter task ID to complete: "); io::stdout().flush().unwrap(); let mut input = String::new(); io::stdin().read_line(&mut input).expect("failed to read input"); match input.trim().parse::<u32>() { Ok(id) if manager.complete_task(id) => println!("Task marked complete."), Ok(_) => println!("No task with that ID."), Err(_) => println!("Please enter a valid number."), } } "4" => { print!("Enter task ID to remove: "); io::stdout().flush().unwrap(); let mut input = String::new(); io::stdin().read_line(&mut input).expect("failed to read input"); match input.trim().parse::<u32>() { Ok(id) if manager.remove_task(id) => println!("Task removed."), Ok(_) => println!("No task with that ID."), Err(_) => println!("Please enter a valid number."), } } "5" => { match manager.save_to_file(path) { Ok(()) => println!("Saved. Goodbye!"), Err(e) => println!("Failed to save: {e}"), } break; } _ => println!("Invalid choice, try again."), } }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a
Priorityenum (Low/Medium/High) as a second field onTask, and sortlist_tasks()output by it. - Switch the on-disk format to JSON with the
serdeandserde_jsoncrates instead of hand-rolled|-delimited parsing. - Add a
find_by_keyword(&self, query: &str) -> Vec<&Task>method that returns every task whose description contains the query. - Wrap
Status::InProgresswith a started-atstd::time::SystemTimeand show elapsed time inlist_tasks(). - Replace the numeric menu with the
clapcrate so tasks can be added/listed/completed as one-shot command-line arguments instead of an interactive loop.
Summary
You built a task manager where a Status enum makes an invalid state unrepresentable, a Task struct bundles that status with an id and description as one coherent value, and TaskManager owns the Vec<Task> behind a small set of methods rather than letting main() touch it directly. The save/load pair you wrote in Steps 5 and 6 is the first real look this course gives you at Result, the ? operator, and explicit error conversion with .map_err() — patterns you will use in nearly every Rust program that touches a file, a network socket, or user input from here on.