LearnAI ToolsCareerPractice BuildsPlayContact
RustBeginner~1.5 hours

CLI Task Manager

Build a command-line to-do app that reads and writes tasks to a file, using structs and enums to model tasks and their status.

StructsEnumsFile I/O

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.

What You'll Build
  • 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());
}
}
}
Example Usage

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

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.