LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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 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());
}
}
}
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 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

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.