Build a CLI Task Manager in Rust
Build a Rust CLI tool with ownership, borrowing, and JSON persistence.
Introduction
30 Second Summary
Managing a to-do list is something you could build in your sleep. But what if your compiler refused to let your code compile until it could prove there are no memory bugs or data races?
In this project, you will build taskr, a CLI task manager in Rust. The domain is deliberately simple so you can focus on how Rust's ownership system forces different design patterns for the same problems you already solve in other languages.
What You'll Build
A command-line task manager that supports adding, listing, completing, and deleting tasks with JSON file persistence.
By the end of this project, you'll have:
- A working Cargo project with serde for compile-time serialization (no runtime reflection).
- A TaskStore that demonstrates &self vs &mut self borrowing rules enforced by the compiler.
- A CLI dispatcher using exhaustive pattern matching with match and idiomatic Result chaining with the ? operator.
- Secret Mission: A search command using iterators, closures, and filter to replace manual for-loop patterns.
Are there any prerequisites?
This project assumes you have programming experience in Go or a similar language. You'll need the Rust toolchain installed via rustup and the RustRover IDE.
Create the Project and Add Dependencies
Every Rust project starts with Cargo, Rust's build system and package manager. Cargo handles project scaffolding, dependency management, and compilation in one tool.
In this step, you'll scaffold a new project called taskr, then add two dependencies for JSON serialization. By the end, you'll have a compiling project ready for real code.
In this step, get ready to:
- Scaffold the taskr project with Cargo.
- Add serde and serde_json as dependencies.
- Confirm the project compiles and runs.
Scaffold the project with Cargo
Cargo's cargo new command creates a complete project structure for you, including a manifest file and a starter source file.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the taskr project and move into it by running these commands:
cargo new taskr
cd taskr
- Confirm the project files exist by running:
ls
You should see Cargo.toml and a src folder in the output.
What did cargo new create?
Cargo generated a standard project layout with two key files. Cargo.toml is the manifest that declares your package name, version, and dependencies.
src/main.rs contains a starter Hello, world! program. Cargo also initialized a Git repository with a .gitignore file.
Seeing an error from cargo new?
- If you see a "destination already exists" error, a taskr folder already exists on your Desktop. Delete it first or pick a different location.
- If you see "command not found", your Rust toolchain may not be on your PATH. Try opening a new terminal window so the shell picks up the rustup environment.
Why can't I create my taskr project?
- Switch to RustRover.
- Click File in the top menu bar.
- Click Open.
- Navigate to your Desktop, select the taskr folder, and click Open.
Why open in RustRover?
RustRover gives you code completion, inline error highlighting, and a built-in terminal all in one window. You'll use it for the rest of the project to write and run your Rust code.
Add serde and serde_json dependencies
Your task manager will save tasks to a JSON file between runs. The serde library handles serialization (converting structs to data formats and back), and serde_json provides the JSON-specific implementation.
- Back in your terminal (still inside the taskr directory), add serde with the derive feature by running:
cargo add serde --features derive
- Add serde_json by running:
cargo add serde_json
What does the derive feature do?
The derive feature enables #[derive(Serialize, Deserialize)] on your structs and enums. This generates serialization code at compile time, so there is no runtime reflection.
This is fundamentally different from Go's encoding/json which uses reflection to read struct tags at runtime. Rust's approach catches serialization errors before your code ever runs.
- Verify your dependencies are listed by running:
cargo build
You should see Cargo download the serde and serde_json crates, then compile them without errors.
Build failing or downloads stuck?
- If you see a network error, check your internet connection. Cargo downloads crates from crates.io during the first build.
- If you see "could not find Cargo.toml", make sure your terminal is inside the taskr directory. Run pwd to confirm your current location.
Why is my cargo build failing?
Verify the scaffold runs
The default src/main.rs contains a simple program that prints to the terminal. Running it confirms your entire toolchain and dependencies are wired correctly.
- Run the project by executing:
cargo run
You should see Hello, world! printed in your terminal.
Not seeing Hello, world!?
- If the build step succeeded but cargo run fails, check that src/main.rs has not been accidentally modified. It should still contain the default println!("Hello, world!") line.
Why isn't cargo run working for me?
✔️ Awesome, I've got everything!
Great. Double check that you've saved any open files, and that your terminal shows Hello, world! after running cargo run.
ⓧ I'd like to double check the full code
Here is what your Cargo.toml should look like after adding both dependencies:
[package]
name = "taskr"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
And here is your src/main.rs (untouched from the scaffold):
fn main() {
println!("Hello, world!");
}
Your project is scaffolded and your dependencies are locked in. Next up, you'll define the data model that represents tasks and their status.
Define the Data Model
Your project compiles and your dependencies are ready. Now it's time to define the data structures that represent a task.
Every task manager needs a way to describe what a task looks like and what states it can be in. In Rust, you express this with structs and enums. You will also use serde's derive macros to auto-generate serialization code at compile time.
In this step, get ready to:
- Define a Status enum and a Task struct with derived traits.
- Verify the derive macros work by creating and printing a Task instance.
Define the Status enum and Task struct
In Go, you would define constants with iota and a struct with exported fields. In Rust, you define an enum with named variants and a struct with typed fields. The key difference is what happens next.
- In RustRover, open src/main.rs.
- Replace the entire contents of the file with this data model code:
use serde::{Deserialize, Serialize};
// Enum representing the two states a task can be in
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
enum Status {
Todo,
Done,
}
// A single task with an ID, title, and current status
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Task {
id: u32,
title: String,
status: Status,
}
What does this code do?
The #[derive(...)] lines tell the Rust compiler to auto-generate trait implementations at compile time. There is no runtime reflection happening here.
- Debug lets you print the value with {:?} format.
- Serialize and Deserialize generate JSON conversion code. Unlike Go's encoding/json which uses reflection at runtime, serde reads your struct fields at compile time.
- Clone lets you duplicate values (since Rust moves by default instead of copying).
- PartialEq on the enum enables equality comparisons between Status values.
This code won't compile yet because there is no main() function. You will add that next.
Add a temporary main function that creates and prints a Task
To verify the derive macros are working, you need a main() function that creates a Task and prints it using the Debug trait.
- Add this main() function below the Task struct definition:
fn main() {
// Create a test task to verify derives work
let task = Task {
id: 1,
title: String::from("Test"),
status: Status::Todo,
};
println!("{:?}", task);
}
What does this code do?
This creates a Task instance with an ID of 1, a title of "Test", and a status of Todo. The {:?} format specifier uses the Debug trait you derived to print the struct's fields.
String::from("Test") creates an owned String on the heap. In Go, strings are immutable reference types passed by value. In Rust, String is an owned, heap-allocated type that gets moved (not copied) when assigned.
- Save src/main.rs.
- Run the project by executing this command in your terminal:
cargo run
You should see output like: Task { id: 1, title: "Test", status: Todo }.
Seeing a compile error?
- Check that the use serde::{Deserialize, Serialize}; line is at the very top of your file. Without this import, the derive macros cannot find Serialize and Deserialize.
- Confirm you have only one fn main() in the file. If the old "Hello, world!" main is still there, remove it.
- Make sure the derive list on Status includes PartialEq and that Task does not. The two derive lists are intentionally different.
Help me debug a Rust derive macro compile error in my Task struct
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at this point:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
enum Status {
Todo,
Done,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Task {
id: u32,
title: String,
status: Status,
}
fn main() {
let task = Task {
id: 1,
title: String::from("Test"),
status: Status::Todo,
};
println!("{:?}", task);
}
Your data model is defined and the derives are generating code at compile time. Next, you will build a TaskStore that manages a collection of tasks and persists them to disk. This is where you will see &self vs &mut self in action.
Build the TaskStore
Your data model compiles and prints. You have a Task struct and a Status enum ready to represent individual tasks.
Now you need somewhere to store and manage a collection of those tasks. In this step, you'll build a TaskStore that loads tasks from a JSON file, provides methods to add, complete, delete, and list them, and saves changes back to disk.
In this step, get ready to:
- Define the TaskStore struct and its constructor with file-based persistence.
- Implement mutating methods (add, complete, delete) and a read-only list method.
- Add a save method that serializes tasks to JSON with idiomatic error chaining.
Define the TaskStore struct and constructor
The TaskStore holds a vector of tasks and the path to the JSON file it persists them to. Its constructor either reads existing tasks from disk or starts empty.
- In src/main.rs, find the line that says use serde::{Deserialize, Serialize}; and add these three imports directly below it:
use std::fs;
use std::io;
use std::path::PathBuf;
What do these imports do?
- std::fs provides file reading and writing functions.
- std::io gives you the io::Result type for propagating I/O errors.
- std::path::PathBuf is an owned, mutable file path (similar to a String but for paths).
- Now find the fn main() function at the bottom of the file. Add the following code above it, between the Task struct and fn main():
struct TaskStore {
tasks: Vec<Task>,
file_path: PathBuf,
}
impl TaskStore {
fn new(file_path: PathBuf) -> io::Result<Self> {
// If the JSON file exists, read and deserialize it
let tasks = if file_path.exists() {
let data = fs::read_to_string(&file_path)?;
serde_json::from_str(&data).unwrap_or_else(|_| Vec::new())
} else {
Vec::new()
};
Ok(Self { tasks, file_path })
}
}
What does this code do?
- TaskStore is a struct with two fields: tasks (a vector of Task values) and file_path (where to save them on disk).
- The new function returns io::Result<Self>. This is Rust's version of Go's (*TaskStore, error) return pattern, but as a single value.
- The ? operator after fs::read_to_string propagates errors up to the caller automatically. No if err != nil boilerplate needed.
- unwrap_or_else(|_| Vec::new()) handles corrupt JSON gracefully by starting fresh instead of crashing.
Why io::Result instead of (value, error)?
In Go you would return (*TaskStore, error) and check if err != nil at the call site. In Rust, io::Result<Self> wraps both outcomes into a single type. The caller uses ? or match to unwrap it.
The ? operator is the key insight. It replaces Go's repetitive three-line error check with a single character that propagates the error upward.
- Save src/main.rs.
- Confirm the code compiles by running:
cargo build
You should see a line ending with Finished and no errors.
Seeing a compile error?
- Check that the three use std:: lines are below the existing use serde line, not inside a function.
- Make sure the struct TaskStore and impl TaskStore blocks are between the Task struct and fn main().
- Confirm the impl TaskStore block has its own closing } brace.
Help me fix a compile error in my TaskStore constructor
Implement add, complete, delete, and list methods
Your TaskStore needs methods that modify the task collection and one that reads it. This is where Rust's borrowing rules become visible.
What is &self vs &mut self?
In Go, all methods use pointer receivers (*TaskStore) and you decide at runtime whether to mutate. In Rust, you declare your intent in the method signature.
&mut self means "I need exclusive mutable access." &self means "I only need to read." The compiler enforces that you cannot hold both at the same time.
- In src/main.rs, find the closing } brace of the impl TaskStore block. Add these three mutating methods inside the impl block, just above that closing brace:
fn add(&mut self, title: String) {
// Generate the next ID by finding the current max
let id = self.tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
let task = Task {
id,
title,
status: Status::Todo,
};
println!("Added task {}: {}", task.id, task.title);
self.tasks.push(task);
}
fn complete(&mut self, id: u32) -> Result<(), String> {
// Find the task by ID and mutate its status in place
let task = self
.tasks
.iter_mut()
.find(|t| t.id == id)
.ok_or_else(|| format!("Task {} not found", id))?;
task.status = Status::Done;
println!("Completed task {}: {}", task.id, task.title);
Ok(())
}
fn delete(&mut self, id: u32) -> Result<(), String> {
// Find the index, then remove by position
let pos = self
.tasks
.iter()
.position(|t| t.id == id)
.ok_or_else(|| format!("Task {} not found", id))?;
let removed = self.tasks.remove(pos);
println!("Deleted task {}: {}", removed.id, removed.title);
Ok(())
}
What does this code do?
- All three methods take &mut self because they modify the tasks vector.
- add() takes ownership of the title String (not a reference). Once you pass a String in, the caller can no longer use it. This is ownership transfer.
- complete() uses iter_mut() to get mutable references to each task, then find() to locate the one with the matching ID.
- delete() uses iter().position() to get the index, then remove() to pull it out of the vector.
- ok_or_else() converts an Option::None into an Err value, and ? propagates it up. Same error-chaining pattern as the constructor.
You won't see these methods in action yet. The next chunk adds the read-only list() method, and the CLI wiring in the next step calls everything.
- Now add the list method directly below the delete method, still inside the impl TaskStore block:
fn list(&self) {
if self.tasks.is_empty() {
println!("No tasks yet. Add one with: taskr add \"your task\"");
return;
}
// Iterate over borrowed references to each task
for task in &self.tasks {
let marker = match task.status {
Status::Todo => "[ ]",
Status::Done => "[x]",
};
println!("{} {} - {}", marker, task.id, task.title);
}
}
What does this code do?
- list() takes &self (immutable borrow) because it only reads. The compiler guarantees no mutation happens inside this method.
- &self.tasks borrows the vector without taking ownership. Each task in the loop is a &Task reference.
- The match on task.status is exhaustive. If you add a new Status variant later, the compiler forces you to handle it here.
- Save src/main.rs.
- Confirm the code compiles by running:
cargo build
You should see the Finished message with no errors.
Getting unused warnings?
Rust warns about code that is never called. You may see warnings like "method is never used." These are fine for now. The CLI in the next step calls these methods, which removes the warnings.
Help me understand Rust unused code warnings in my TaskStore
Implement the save method
The save method serializes the task list to pretty-printed JSON and writes it to disk. This is where you'll see Rust's error chaining replace Go's multi-block if err != nil pattern.
- In src/main.rs, find the closing } brace of the new method inside impl TaskStore. Add the save method below it, above the add method:
fn save(&self) -> io::Result<()> {
// Serialize tasks to pretty JSON, converting the serde error to io::Error
let data = serde_json::to_string_pretty(&self.tasks)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(&self.file_path, data)
}
What does this code do?
- serde_json::to_string_pretty serializes the tasks vector into formatted JSON.
- map_err converts the serde error type into an io::Error so the return type stays consistent as io::Result<()>.
- The ? at the end propagates any error upward. The final fs::write call returns io::Result<()> directly, so no ? is needed there.
Why map_err instead of multiple if-checks?
In Go, you would have three separate if err != nil blocks: one for marshal, one for file creation, one for write. In Rust, you chain operations with ? and use map_err to unify error types into a single pipeline.
The entire save operation collapses into two lines of logic. Errors propagate automatically without repetitive checking.
- Save src/main.rs.
- Confirm the full file compiles by running:
cargo build
You should see the Finished message with no errors. You are not running the program yet because main() still has the temporary test code from the previous step.
Seeing a type mismatch error?
- Make sure save is placed between new and add inside the impl TaskStore block. The order of methods inside an impl block does not affect compilation, but placing it correctly now matches the final code.
- Check that the return type is io::Result<()> (with the unit type () inside the angle brackets).
- Verify the .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? line ends with ? followed by a semicolon.
Help me fix a type error in my save method
✔️ Awesome, I've got everything!
Great. Double check you've saved src/main.rs and that cargo build succeeds with no errors.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at this point:
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
enum Status {
Todo,
Done,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Task {
id: u32,
title: String,
status: Status,
}
struct TaskStore {
tasks: Vec<Task>,
file_path: PathBuf,
}
impl TaskStore {
fn new(file_path: PathBuf) -> io::Result<Self> {
let tasks = if file_path.exists() {
let data = fs::read_to_string(&file_path)?;
serde_json::from_str(&data).unwrap_or_else(|_| Vec::new())
} else {
Vec::new()
};
Ok(Self { tasks, file_path })
}
fn save(&self) -> io::Result<()> {
let data = serde_json::to_string_pretty(&self.tasks)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(&self.file_path, data)
}
fn add(&mut self, title: String) {
let id = self.tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
let task = Task {
id,
title,
status: Status::Todo,
};
println!("Added task {}: {}", task.id, task.title);
self.tasks.push(task);
}
fn complete(&mut self, id: u32) -> Result<(), String> {
let task = self
.tasks
.iter_mut()
.find(|t| t.id == id)
.ok_or_else(|| format!("Task {} not found", id))?;
task.status = Status::Done;
println!("Completed task {}: {}", task.id, task.title);
Ok(())
}
fn delete(&mut self, id: u32) -> Result<(), String> {
let pos = self
.tasks
.iter()
.position(|t| t.id == id)
.ok_or_else(|| format!("Task {} not found", id))?;
let removed = self.tasks.remove(pos);
println!("Deleted task {}: {}", removed.id, removed.title);
Ok(())
}
fn list(&self) {
if self.tasks.is_empty() {
println!("No tasks yet. Add one with: taskr add \"your task\"");
return;
}
for task in &self.tasks {
let marker = match task.status {
Status::Todo => "[ ]",
Status::Done => "[x]",
};
println!("{} {} - {}", marker, task.id, task.title);
}
}
}
fn main() {
let task = Task {
id: 1,
title: String::from("Test"),
status: Status::Todo,
};
println!("{:?}", task);
}
Your TaskStore is fully built. It can load, save, add, complete, delete, and list tasks. Next up, you'll wire all of these methods to command-line arguments using Rust's exhaustive pattern matching.
Wire Up the CLI with Pattern Matching
Your TaskStore has all the logic to add, complete, delete, and list tasks. But there is no way to call it from the terminal yet.
In this step, you will replace the temporary main() with a real CLI dispatcher. Rust's match expression is the tool for this. It forces you to handle every possible command, including unknown ones.
In this step, get ready to:
- Parse command-line arguments into a Vec.
- Dispatch commands with exhaustive pattern matching.
- Test the full CLI workflow with JSON persistence.
Parse command-line arguments
The first job of a CLI is reading what the user typed. In Rust, std::env::args() gives you an iterator over the arguments. You collect them into a Vec<String> so you can index into them freely.
- In src/main.rs, find the temporary main() function at the bottom of the file.
- Delete the entire temporary main() function (the one that creates a test Task and prints it).
- Replace it with this new main() that collects arguments and creates a TaskStore:
fn main() {
// Collect CLI arguments into a vector
let args: Vec<String> = std::env::args().collect();
// If no command provided, show usage and exit
if args.len() < 2 {
print_usage();
return;
}
// Create a TaskStore backed by tasks.json
let file_path = PathBuf::from("tasks.json");
let mut store = match TaskStore::new(file_path) {
Ok(store) => store,
Err(e) => {
eprintln!("Error loading tasks: {}", e);
std::process::exit(1);
}
};
}
What does this code do?
- std::env::args().collect() gathers all command-line arguments into a Vec<String>. Index 0 is always the program name, so the actual command starts at index 1.
- The length check ensures the user typed a command. If they just ran cargo run with nothing else, it shows help and exits.
- The match on TaskStore::new() handles the Result type. If the file loads successfully, you get the store. If it fails, the program prints the error and exits.
Why match instead of if/else?
In Go you would use if/else chains or a switch statement here. Rust's match is more powerful because it is exhaustive. When matching on enums, the compiler refuses to compile if you miss a variant. For string matching (like CLI commands), the _ wildcard arm forces you to explicitly handle unknown input.
You won't see a change yet. The code references print_usage() which does not exist yet. You will add it in a later substep.
Match on the command and dispatch
Now you need the dispatch logic. Each command maps to a TaskStore method, and any mutating command triggers a save to disk afterwards.
- Inside your main() function, replace the closing } with the following match block and save logic:
// Dispatch based on the command the user typed
match args[1].as_str() {
"add" => {
if args.len() < 3 {
eprintln!("Usage: taskr add \"task title\"");
std::process::exit(1);
}
// Join all remaining args as the task title
let title = args[2..].join(" ");
store.add(title);
}
"list" => {
store.list();
return;
}
"done" => {
if args.len() < 3 {
eprintln!("Usage: taskr done <id>");
std::process::exit(1);
}
// Parse the ID string into a u32
let id: u32 = args[2].parse().unwrap_or_else(|_| {
eprintln!("Invalid task ID: {}", args[2]);
std::process::exit(1);
});
if let Err(e) = store.complete(id) {
eprintln!("{}", e);
std::process::exit(1);
}
}
"delete" => {
if args.len() < 3 {
eprintln!("Usage: taskr delete <id>");
std::process::exit(1);
}
let id: u32 = args[2].parse().unwrap_or_else(|_| {
eprintln!("Invalid task ID: {}", args[2]);
std::process::exit(1);
});
if let Err(e) = store.delete(id) {
eprintln!("{}", e);
std::process::exit(1);
}
}
_ => {
print_usage();
return;
}
}
// Save after any mutating command
if let Err(e) = store.save() {
eprintln!("Error saving tasks: {}", e);
std::process::exit(1);
}
}
What does this code do?
- args[1].as_str() converts the first real argument from a String to a &str so it can be matched against string literals.
- The "add" arm joins all remaining arguments into a single title. This lets the user type cargo run -- add Learn Rust ownership without quotes.
- The "list" arm calls store.list() and returns immediately. Listing does not mutate data, so there is nothing to save.
- The "done" and "delete" arms parse the ID with .parse::<u32>() and use unwrap_or_else to handle non-numeric input gracefully.
- The _ wildcard catches any unrecognized command and shows usage help. This is Rust's equivalent of a default case.
- store.save() runs only after mutating commands (add, done, delete). The if let Err(e) pattern handles the save error without a full match block.
The code still will not compile because print_usage() is not defined yet. You will fix that next.
Implement print_usage and test the full CLI
The last piece is the helper function that tells users what commands are available.
- Below the closing } of main(), add the print_usage() function:
fn print_usage() {
println!("taskr - A simple task manager");
println!();
println!("Usage:");
println!(" taskr add <title> Add a new task");
println!(" taskr list List all tasks");
println!(" taskr done <id> Mark a task as done");
println!(" taskr delete <id> Delete a task");
}
What does this code do?
print_usage() is a standalone function (not a method on any struct). It prints all available commands to the terminal. Both the argument-count check and the _ wildcard arm call this function.
- Save src/main.rs.
- Confirm everything compiles by running:
cargo build
- You should see a successful compilation with no errors.
- Now test the full workflow by running these commands one at a time:
cargo run -- add "Learn Rust ownership"
cargo run -- add "Fight the borrow checker"
cargo run -- list
cargo run -- done 1
cargo run -- list
cargo run -- delete 2
cargo run -- list
What should I see?
The add commands print confirmation messages. The first list shows two tasks with [ ] markers. After done 1, the first task shows [x]. After delete 2, only one task remains.
A tasks.json file now exists in your project root. Each command that modifies data saves to this file, so tasks persist between runs.
Seeing a compile error?
- If you see "cannot find function print_usage", make sure you added the print_usage() function below the closing brace of main().
- If you see "expected item" errors, check that you fully deleted the old temporary main() before pasting the new one. There should be exactly one fn main() in the file.
- If tasks are not persisting between runs, check that you are running commands from inside the taskr directory.
Help me debug my taskr CLI compile error.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs and that tasks.json appeared in your project root.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at the end of this step:
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
enum Status {
Todo,
Done,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Task {
id: u32,
title: String,
status: Status,
}
struct TaskStore {
tasks: Vec<Task>,
file_path: PathBuf,
}
impl TaskStore {
fn new(file_path: PathBuf) -> io::Result<Self> {
let tasks = if file_path.exists() {
let data = fs::read_to_string(&file_path)?;
serde_json::from_str(&data).unwrap_or_else(|_| Vec::new())
} else {
Vec::new()
};
Ok(Self { tasks, file_path })
}
fn save(&self) -> io::Result<()> {
let data = serde_json::to_string_pretty(&self.tasks)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(&self.file_path, data)
}
fn add(&mut self, title: String) {
let id = self.tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
let task = Task {
id,
title,
status: Status::Todo,
};
println!("Added task {}: {}", task.id, task.title);
self.tasks.push(task);
}
fn complete(&mut self, id: u32) -> Result<(), String> {
let task = self
.tasks
.iter_mut()
.find(|t| t.id == id)
.ok_or_else(|| format!("Task {} not found", id))?;
task.status = Status::Done;
println!("Completed task {}: {}", task.id, task.title);
Ok(())
}
fn delete(&mut self, id: u32) -> Result<(), String> {
let pos = self
.tasks
.iter()
.position(|t| t.id == id)
.ok_or_else(|| format!("Task {} not found", id))?;
let removed = self.tasks.remove(pos);
println!("Deleted task {}: {}", removed.id, removed.title);
Ok(())
}
fn list(&self) {
if self.tasks.is_empty() {
println!("No tasks yet. Add one with: taskr add \"your task\"");
return;
}
for task in &self.tasks {
let marker = match task.status {
Status::Todo => "[ ]",
Status::Done => "[x]",
};
println!("{} {} - {}", marker, task.id, task.title);
}
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
print_usage();
return;
}
let file_path = PathBuf::from("tasks.json");
let mut store = match TaskStore::new(file_path) {
Ok(store) => store,
Err(e) => {
eprintln!("Error loading tasks: {}", e);
std::process::exit(1);
}
};
match args[1].as_str() {
"add" => {
if args.len() < 3 {
eprintln!("Usage: taskr add \"task title\"");
std::process::exit(1);
}
let title = args[2..].join(" ");
store.add(title);
}
"list" => {
store.list();
return;
}
"done" => {
if args.len() < 3 {
eprintln!("Usage: taskr done <id>");
std::process::exit(1);
}
let id: u32 = args[2].parse().unwrap_or_else(|_| {
eprintln!("Invalid task ID: {}", args[2]);
std::process::exit(1);
});
if let Err(e) = store.complete(id) {
eprintln!("{}", e);
std::process::exit(1);
}
}
"delete" => {
if args.len() < 3 {
eprintln!("Usage: taskr delete <id>");
std::process::exit(1);
}
let id: u32 = args[2].parse().unwrap_or_else(|_| {
eprintln!("Invalid task ID: {}", args[2]);
std::process::exit(1);
});
if let Err(e) = store.delete(id) {
eprintln!("{}", e);
std::process::exit(1);
}
}
_ => {
print_usage();
return;
}
}
if let Err(e) = store.save() {
eprintln!("Error saving tasks: {}", e);
std::process::exit(1);
}
}
fn print_usage() {
println!("taskr - A simple task manager");
println!();
println!("Usage:");
println!(" taskr add <title> Add a new task");
println!(" taskr list List all tasks");
println!(" taskr done <id> Mark a task as done");
println!(" taskr delete <id> Delete a task");
}
Your CLI task manager is fully functional. Tasks persist to tasks.json between runs, and every command is handled through exhaustive pattern matching. Next up, take on the Secret Mission and add search with iterators and closures.
Secret mission
Search with Iterators and Closures
Your task manager works, but finding a specific task means scanning the entire list with your eyes. Add a search command that filters tasks by title using Rust's iterator chains and closures. This replaces the imperative for-loop-and-append pattern you would write in Go with a functional pipeline.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future experimentation or delete them entirely.
Resources you used:
- The taskr project directory on your Desktop (contains Cargo.toml, src/main.rs, and compiled output).
- The tasks.json file inside the project root (created at runtime when you added tasks).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your task manager or extending it further.
- Your taskr directory stays on your Desktop.
- You can continue running commands like cargo run -- list any time.
- Your tasks.json file persists your tasks between sessions.
Pause - I'll come back to this later
Since this project is entirely local with no running processes, there is nothing to pause. Your files stay on disk until you decide to delete them.
- No background processes are running. The binary only executes when you call cargo run.
- Come back any time and pick up where you left off.
Delete - I don't want to use this again
Remove the entire project directory to free up disk space and start fresh.
- Delete the project folder by running this command in your terminal:
rm -rf ~/Desktop/taskr
On Windows, run this instead:
rmdir /s /q ~\Desktop\taskr
- This removes src/main.rs, Cargo.toml, tasks.json, the compiled target/ directory, and all other project files.
- Confirm the folder is gone by running:
ls ~/Desktop/taskr
You should see a "No such file or directory" message.
Nice Work!
Nice Work!
Nice work! You just built a fully functional CLI task manager in Rust that persists tasks to JSON, all while navigating ownership, borrowing, and error handling the Rust way.
You've learned how to:
- Built a CLI tool with JSON persistence using serde derive macros for compile-time serialization with no runtime reflection.
- Enforced &self vs &mut self borrowing rules through the compiler, choosing immutable or mutable access for each method.
- Replaced if err != nil boilerplate with Result chaining and the ? operator for idiomatic error propagation.
- Secret Mission: Implemented search using iterators, closures, and filter returning borrowed references that the compiler verifies are safe.
Ready to quiz yourself?