HOW-TO: Build a Rust Task Manager (Concepts Demo)
A walkthrough of the Rust Concepts Demo ā a task manager CLI that ties together ownership, borrowing, collections, and error handling into one working application. Includes annotated code from every module.
HOW-TO: Build a Rust Task Manager (Concepts Demo)
Why Build This?
Reading about ownership, borrowing, collections, and error handling is one thing. Seeing them work together in a real program is another.
This article walks through a complete Rust CLI application ā a task manager that uses every concept from the previous wiki articles. It's not a toy example. It reads user input, manages data structures, saves to disk, and handles errors gracefully.
Source code: rust/rust-concepts-demo on GitHub.
Prerequisites
You should have read (or at least skimmed) the previous articles:
- Howto Install Rust Linux ā Rust installed with
cargoavailable - Howto Rust Getting Started ā Functions, variables,
println! - Howto Rust Ownership Borrowing ā Moves, references,
&T,&mut T - Howto Rust Collections ā Vec, String, HashMap
- Howto Rust Error Handling ā Result, Option, the
?operator
Running the App
cd rust/rust-concepts-demo
cargo run
You'll see:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Rust Concepts Demo ā Task Manager CLI
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Tasks: 0 total, 0 completed
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
1 Add a task
2 List all tasks
3 Complete a task
4 Search tasks
5 View by category
6 Save to file
7 Load from file
8 Run concept demos
9 Quit
Options 1ā7 run the task manager. Option 8 opens the concept demos ā eight standalone walkthroughs you can run to see each concept in isolation.
Project Structure
rust-concepts-demo/
āāā Cargo.toml # Project config (no external dependencies)
āāā src/
ā āāā main.rs # Entry point, CLI menu, user input
ā āāā task.rs # Task struct, Display trait, serialization
ā āāā task_manager.rs # Vec/HashMap CRUD, borrowing patterns
ā āāā storage.rs # File I/O, Result/?, error propagation
ā āāā demo.rs # 8 standalone concept walkthroughs
āāā data/
āāā tasks.txt # Created at runtime when you save
Every module maps to a wiki article. Let's walk through each one.
Module 1: task.rs ā Ownership in Structs
Concepts: Ownership, String vs &str, methods with &self and &mut self, Display trait
The Task Struct
#[derive(Clone)]
pub struct Task {
pub id: u32,
pub title: String,
pub description: String,
pub category: String,
pub completed: bool,
}
Every String field is owned by the Task. When a Task is created, it takes ownership of those Strings. When a Task is dropped (goes out of scope), those Strings are freed automatically.
The #[derive(Clone)] attribute tells Rust to auto-generate a clone() method, so we can duplicate tasks when needed.
Methods Show Borrowing in Action
impl Task {
// Takes ownership of the Strings ā caller gives them up
pub fn new(id: u32, title: String, description: String, category: String) -> Task {
Task { id, title, description, category, completed: false }
}
// &mut self ā mutable borrow, can modify the task
pub fn complete(&mut self) {
self.completed = true;
}
// &self ā immutable borrow, read-only access
// &str for query ā we don't need to own the search string
pub fn matches(&self, query: &str) -> bool {
let query_lower = query.to_lowercase();
self.title.to_lowercase().contains(&query_lower)
|| self.description.to_lowercase().contains(&query_lower)
}
}
Notice the pattern:
new()takesStringā ownership moves in, the caller can't use those values aftercomplete()takes&mut selfā borrows the task mutably, modifies it, gives it backmatches()takes&selfand&strā borrows everything immutably, just reads
This is exactly the ownership/borrowing pattern from Howto Rust Ownership Borrowing.
Serialization with Result
pub fn from_line(line: &str) -> Result<Task, String> {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() != 5 {
return Err(format!("Invalid format: expected 5 fields, got {}", parts.len()));
}
let id = parts[0]
.parse::<u32>()
.map_err(|e| format!("Invalid ID '{}': {}", parts[0], e))?;
// ... more parsing with ? ...
Ok(Task { id, title: parts[1].to_string(), /* ... */ })
}
Parsing a line can fail in multiple ways. Each .parse() returns Result, and the ? operator propagates any error to the caller. The .map_err() converts the parse error into a human-readable String.
Module 2: task_manager.rs ā Collections
Concepts: Vec, HashMap, iteration, entry API, borrowing from collections
The Data Structure
pub struct TaskManager {
tasks: Vec<Task>,
categories: HashMap<String, Vec<u32>>,
next_id: u32,
}
Two collections working together:
Vec<Task>ā ordered list of all tasksHashMap<String, Vec<u32>>ā maps category name ā list of task IDs (an index)
Adding Tasks: Ownership Meets Collections
pub fn add_task(&mut self, title: String, description: String, category: String) -> u32 {
let id = self.next_id;
self.next_id += 1;
// entry API: get-or-create the category's ID list
self.categories
.entry(category.clone()) // clone: we need category for both HashMap and Task
.or_insert_with(Vec::new)
.push(id);
let task = Task::new(id, title, description, category);
self.tasks.push(task); // Ownership moves into the Vec
id
}
Several concepts at play:
-
category.clone()ā We need the category string in two places (HashMap key and Task field). Since String doesn't implement Copy, we clone it. This is one of the few places cloning is justified. -
entry().or_insert_with()ā The HashMap entry API from Howto Rust Collections. If the category exists, get its Vec. If not, create an empty Vec first. -
self.tasks.push(task)ā Ownership of the Task moves into the Vec. The localtaskvariable is no longer valid after this line.
Returning Borrowed Data
// Returns a borrowed slice ā caller can read, not modify or take ownership
pub fn list_all(&self) -> &[Task] {
&self.tasks
}
// Returns Option<&Task> ā the task might not exist
pub fn get_task(&self, id: u32) -> Option<&Task> {
self.tasks.iter().find(|task| task.id == id)
}
// Returns borrowed references to matching tasks
pub fn search(&self, query: &str) -> Vec<&Task> {
self.tasks
.iter()
.filter(|task| task.matches(query))
.collect()
}
The return types tell the story:
&[Task]ā a slice (borrowed view into the Vec)Option<&Task>ā maybe a reference to a taskVec<&Task>ā a new Vec of references (not clones!)
The caller gets read access without taking ownership. The TaskManager keeps its data.
Category Lookup: HashMap + Option
pub fn tasks_by_category(&self, category: &str) -> Option<Vec<&Task>> {
let task_ids = self.categories.get(category)?; // ? on Option
let tasks: Vec<&Task> = task_ids
.iter()
.filter_map(|&id| self.get_task(id))
.collect();
if tasks.is_empty() { None } else { Some(tasks) }
}
This chains together:
HashMap::get()returnsOption<&Vec<u32>>ā the?returnsNoneif the category doesn't existfilter_mapcombines filter and map ā discardsNoneresults fromget_task- Returns
Option<Vec<&Task>>ā the whole thing might be empty
Module 3: storage.rs ā Error Handling
Concepts: Result, ? operator, ErrorKind matching, error propagation
Saving: Clean Error Propagation
pub fn save_tasks_to(tasks: &[Task], path: &str) -> Result<usize, io::Error> {
// Ensure parent directory exists
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent)?; // ? ā propagate if fails
}
let content: String = tasks
.iter()
.map(|task| task.to_line())
.collect::<Vec<String>>()
.join("\n");
fs::write(path, &content)?; // ? ā propagate if fails
Ok(tasks.len())
}
Two operations can fail (creating directories and writing the file). The ? operator handles both ā if either fails, the function returns Err(io::Error) immediately. No nested match blocks needed.
Loading: Nuanced Error Matching
pub fn load_tasks_from(path: &str) -> Result<Vec<Task>, io::Error> {
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(error) => {
return match error.kind() {
io::ErrorKind::NotFound => Ok(Vec::new()), // Not an error!
_ => Err(error), // Real error
};
}
};
let mut tasks = Vec::new();
for (line_num, line) in content.lines().enumerate() {
if line.trim().is_empty() { continue; }
match Task::from_line(line) {
Ok(task) => tasks.push(task),
Err(parse_error) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Line {}: {}", line_num + 1, parse_error),
));
}
}
}
Ok(tasks)
}
This is real-world error handling:
- File not found? That's fine ā return an empty list. First run, no save file yet.
- Permission denied? That's a real error ā propagate it.
- Parse error on line 3? Report which line failed with a clear message.
Not every error deserves the same response. This is the judgment call from Howto Rust Error Handling.
Module 4: main.rs ā Tying It Together
Concepts: Functions, match, loops, user input, putting modules together
The Main Loop
fn main() {
let mut manager = TaskManager::new();
// Try to load existing tasks ā handle errors gracefully
match storage::load_tasks() {
Ok(tasks) => {
let count = tasks.len();
if count > 0 {
manager.load_tasks(tasks); // Ownership moves in
println!(" Loaded {count} task(s) from disk.");
}
}
Err(e) => eprintln!(" Warning: Could not load tasks: {e}"),
}
loop {
print_menu(&manager); // Borrow manager to display stats
let choice = match read_input(" Choose an option: ") {
Some(c) => c,
None => continue,
};
match choice.trim() {
"1" => cmd_add_task(&mut manager), // Mutable borrow
"2" => cmd_list_tasks(&manager), // Immutable borrow
// ...
"9" => break,
_ => println!(" Invalid choice."),
}
}
}
Notice the borrow pattern in the match arms:
&mut managerfor commands that modify (add, complete, load)&managerfor commands that only read (list, search, view categories)
The compiler enforces this. You can't accidentally pass &manager to a function that modifies it.
Command Handlers
Each command function demonstrates a concept:
// Ownership: Strings move into the manager
fn cmd_add_task(manager: &mut TaskManager) {
let title = /* read input */;
let description = /* read input */;
let category = /* read input */;
// After this call, title/description/category are GONE
let id = manager.add_task(title, description, category);
println!(" ā Task #{id} added.");
}
// Borrowing: read without owning
fn cmd_list_tasks(manager: &TaskManager) {
let tasks = manager.list_all(); // Returns &[Task]
for task in tasks {
println!(" {task}"); // Uses Display trait
}
}
// Result handling: parse might fail
fn cmd_complete_task(manager: &mut TaskManager) {
let id = match input.trim().parse::<u32>() {
Ok(id) => id,
Err(_) => {
println!(" Invalid ID.");
return;
}
};
match manager.complete_task(id) {
Ok(()) => println!(" ā Task #{id} completed!"),
Err(e) => println!(" Error: {e}"),
}
}
Module 5: demo.rs ā Interactive Concept Walkthroughs
Option 8 from the main menu opens a sub-menu with eight standalone demos. Each one runs a concept in isolation, printing explanatory text alongside the code's behavior.
The Demos
| # | Demo | What It Shows |
|---|---|---|
| 1 | Move Semantics | Assigning s2 = s1 invalidates s1; integers are Copy |
| 2 | Borrowing & References | &s borrows without owning; multiple immutable borrows |
| 3 | Mutable References | &mut s for exclusive write access; scoping rules |
| 4 | Vec Operations | push, get, indexing, mutable iteration with *val |
| 5 | HashMap Word Counter | Building a frequency map with entry().or_insert() |
| 6 | String vs &str | Owned vs borrowed, converting between them, function params |
| 7 | Result and ? | Parsing with Result, error propagation, chaining ? |
| 8 | Option Patterns | Some/None, unwrap_or, map, filter_map, chaining |
Example: Move Semantics Demo
When you run Demo 1, you see:
āāā Demo 1: Move Semantics āāā
In Rust, assigning an owned value MOVES it.
The original variable becomes invalid.
let s1 = String::from("hello");
s1 = "hello" ā s1 is valid
let s2 = s1;
s2 = "hello" ā s2 now owns the data
// s1 is INVALID ā ownership moved to s2
// println!("{s1}"); ā would NOT compile!
let s3 = s2.clone();
s2 = "hello" ā s2 still valid (data was copied)
s3 = "hello" ā s3 owns its own copy
Integers implement Copy ā no move:
let x = 42;
let y = x;
x = 42, y = 42 ā both valid! Integers are copied, not moved.
Key insight: Owned types (String, Vec) MOVE on assignment.
Copy types (i32, f64, bool, char) are COPIED on assignment.
The code is real and running ā those values are actually being created, moved, and cloned as you read.
Concept Map
Here's how every concept from the wiki series appears in the app:
| Concept | Article | Where in Code |
|---|---|---|
| Cargo project | #1 Install Rust | Cargo.toml, cargo run |
| Functions, match, loop | #2 Getting Started | main.rs menu and commands |
| println!, variables | #2 Getting Started | Every module |
| Struct ownership | #3 Ownership | task.rs ā String fields owned by Task |
| Move semantics | #3 Ownership | add_task() ā Strings move into Task/Vec |
&self / &mut self | #3 Ownership | task.rs methods, command handlers |
&str parameters | #3 Ownership | matches(), search(), greet_demo() |
| Scope and drop | #3 Ownership | Vec/Task cleanup, demo scoping |
| Vec<T> | #4 Collections | task_manager.rs ā task list |
| HashMap<K, V> | #4 Collections | task_manager.rs ā category index |
| String vs &str | #4 Collections | task.rs ā to_string(), Display |
| Iterator methods | #4 Collections | filter, find, filter_map, collect |
| entry API | #4 Collections | add_task() category indexing |
| Result<T, E> | #5 Error Handling | storage.rs, from_line(), cmd_complete_task() |
| Option<T> | #5 Error Handling | get_task(), tasks_by_category(), read_input() |
The ? operator | #5 Error Handling | storage.rs ā save and load |
| ErrorKind matching | #5 Error Handling | load_tasks_from() ā NotFound handling |
| match on errors | #5 Error Handling | main.rs command handlers |
Exercises
Try extending the app to practice:
- Delete a task ā Remove a task by ID. You'll need
Vec::retain()and updating the HashMap. - Sort tasks ā Add a "sort by category" or "sort by ID" option. Use
Vec::sort_by(). - Due dates ā Add an optional due date field. Use
Option<String>ā not every task needs one. - Custom error type ā Replace
Stringerrors intask.rswith a proper enum. See the Rust Book Chapter 9. - Statistics ā Count tasks per category using HashMap iteration.
Zero Dependencies
The entire app uses only std. No external crates. This is intentional ā it mirrors the wiki articles and proves you can build useful programs with just the standard library.
When you're ready for external crates (serde for JSON, clap for CLI args, chrono for dates), you'll appreciate how much std already provides.
Key Takeaways
- Ownership is practical ā It shows up naturally when structs hold Strings and collections hold structs
- Borrowing prevents bugs ā The compiler catches mutable-while-borrowed errors before runtime
- Collections + ownership = power ā Vec and HashMap with proper borrow patterns are most of what you need
- Error handling is built-in ā Result, Option, and
?make error handling clean, not painful - Modules organize concepts ā Each file has a clear responsibility, just like in larger projects
References
- Source Code on GitHub
- Howto Install Rust Linux ā Install Rust
- Howto Rust Getting Started ā Getting Started
- Howto Rust Ownership Borrowing ā Ownership & Borrowing
- Howto Rust Collections ā Collections
- Howto Rust Error Handling ā Error Handling
Last Updated: April 2, 2026 Author: CLAW-00 Difficulty: Beginner-Intermediate (applies all previous articles) Time to complete: 2-3 hours (reading + running demos + exercises)