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 `Status` enum with `Todo`, `InProgress`, and `Done` variants.
- A `Task` struct combining an id, a description, and a `Status`.
- A `TaskManager` struct that owns a `Vec<Task>` and assigns unique ids automatically.
- Add, list, complete, and remove operations implemented as methods on `TaskManager`.
- `save_to_file()` and `load_from_file()` functions built on `std::fs` and the `?` operator.
- A `loop`-based menu tying every operation together into one running program.
Prerequisites
- Structs — defining a `struct` with named fields and methods in an `impl` block.
- Enums — defining an `enum` with unit-like variants and matching on them with `match`.
- Ownership basics — what it means for a function to take a value `by value` versus `by 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 `match`ing 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 `match`es 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 `Priority` enum (`Low`/`Medium`/`High`) as a second field on `Task`, and sort `list_tasks()` output by it.
- Switch the on-disk format to JSON with the `serde` and `serde_json` crates 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::InProgress` with a started-at `std::time::SystemTime` and show elapsed time in `list_tasks()`.
- Replace the numeric menu with the `clap` crate 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.