Skip to editor content
learningrust.orglesson 26 of 26

Capstone Project: CLI Todo App

In this capstone lesson, you will build a fully functional command-line todo application that brings together everything you have learned about Rust: structs, enums, error handling, traits, collections, and more. Rather than introducing new concepts, this lesson focuses on combining existing knowledge into a cohesive, real-world program.

Project Overview

The todo app will support creating tasks, marking them complete, listing tasks with filters, and deleting tasks. We will build it step by step, starting with the data model and progressively adding features.

Step 1: Define the Data Model

First, define the core types that represent a task and its priority level. We use an enum for priority and a struct for the task itself:

use std::fmt;

#[derive(Debug, Clone, PartialEq)]
enum Priority {
    Low,
    Medium,
    High,
}

impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Priority::Low => write!(f, "LOW"),
            Priority::Medium => write!(f, "MED"),
            Priority::High => write!(f, "HIGH"),
        }
    }
}

#[derive(Debug, Clone)]
struct Task {
    id: u32,
    title: String,
    description: String,
    priority: Priority,
    completed: bool,
}

impl Task {
    fn new(id: u32, title: &str, description: &str, priority: Priority) -> Self {
        Task {
            id,
            title: String::from(title),
            description: String::from(description),
            priority,
            completed: false,
        }
    }

    fn complete(&mut self) {
        self.completed = true;
    }
}

impl fmt::Display for Task {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let status = if self.completed { "x" } else { " " };
        write!(
            f,
            "[{}] #{:03} [{}] {} - {}",
            status, self.id, self.priority, self.title, self.description
        )
    }
}

fn main() {
    let mut task = Task::new(1, "Learn Rust", "Complete the Rust course", Priority::High);
    println!("Created: {}", task);

    task.complete();
    println!("After completing: {}", task);

    let task2 = Task::new(2, "Buy groceries", "Milk, eggs, bread", Priority::Low);
    println!("Another task: {}", task2);
}

Step 2: Build the TodoList Manager

Now create a TodoList struct that manages a collection of tasks. This is where Vec, HashMap-style lookups, and error handling come together:

use std::fmt;

#[derive(Debug, Clone, PartialEq)]
enum Priority {
    Low,
    Medium,
    High,
}

impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Priority::Low => write!(f, "LOW"),
            Priority::Medium => write!(f, "MED"),
            Priority::High => write!(f, "HIGH"),
        }
    }
}

#[derive(Debug, Clone)]
struct Task {
    id: u32,
    title: String,
    description: String,
    priority: Priority,
    completed: bool,
}

impl Task {
    fn new(id: u32, title: &str, description: &str, priority: Priority) -> Self {
        Task {
            id,
            title: String::from(title),
            description: String::from(description),
            priority,
            completed: false,
        }
    }
}

impl fmt::Display for Task {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let status = if self.completed { "x" } else { " " };
        write!(f, "[{}] #{:03} [{}] {}", status, self.id, self.priority, self.title)
    }
}

#[derive(Debug)]
enum TodoError {
    TaskNotFound(u32),
    EmptyTitle,
    DuplicateTitle(String),
}

impl fmt::Display for TodoError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            TodoError::TaskNotFound(id) => write!(f, "Task #{} not found", id),
            TodoError::EmptyTitle => write!(f, "Task title cannot be empty"),
            TodoError::DuplicateTitle(t) => write!(f, "Task '{}' already exists", t),
        }
    }
}

struct TodoList {
    tasks: Vec<Task>,
    next_id: u32,
}

impl TodoList {
    fn new() -> Self {
        TodoList {
            tasks: Vec::new(),
            next_id: 1,
        }
    }

    fn add(&mut self, title: &str, desc: &str, priority: Priority) -> Result<u32, TodoError> {
        if title.trim().is_empty() {
            return Err(TodoError::EmptyTitle);
        }
        if self.tasks.iter().any(|t| t.title == title) {
            return Err(TodoError::DuplicateTitle(String::from(title)));
        }
        let id = self.next_id;
        self.tasks.push(Task::new(id, title, desc, priority));
        self.next_id += 1;
        Ok(id)
    }

    fn complete(&mut self, id: u32) -> Result<(), TodoError> {
        match self.tasks.iter_mut().find(|t| t.id == id) {
            Some(task) => {
                task.completed = true;
                Ok(())
            }
            None => Err(TodoError::TaskNotFound(id)),
        }
    }

    fn delete(&mut self, id: u32) -> Result<Task, TodoError> {
        let pos = self.tasks.iter().position(|t| t.id == id);
        match pos {
            Some(index) => Ok(self.tasks.remove(index)),
            None => Err(TodoError::TaskNotFound(id)),
        }
    }

    fn list_all(&self) -> &[Task] {
        &self.tasks
    }

    fn pending_count(&self) -> usize {
        self.tasks.iter().filter(|t| !t.completed).count()
    }
}

fn main() {
    let mut todos = TodoList::new();

    // Add tasks
    let id1 = todos.add("Write documentation", "API docs for v2", Priority::High).unwrap();
    let id2 = todos.add("Fix login bug", "Users can't reset password", Priority::High).unwrap();
    let _id3 = todos.add("Update dependencies", "Run cargo update", Priority::Medium).unwrap();
    let _id4 = todos.add("Add dark mode", "CSS theme support", Priority::Low).unwrap();

    println!("== All Tasks ==");
    for task in todos.list_all() {
        println!("  {}", task);
    }

    // Complete some tasks
    todos.complete(id1).unwrap();
    todos.complete(id2).unwrap();

    // Try to complete a non-existent task
    match todos.complete(99) {
        Ok(()) => println!("Completed!"),
        Err(e) => println!("Error: {}", e),
    }

    // Try to add a duplicate
    match todos.add("Fix login bug", "duplicate", Priority::Low) {
        Ok(_) => println!("Added!"),
        Err(e) => println!("Error: {}", e),
    }

    println!("\n== After Updates ==");
    for task in todos.list_all() {
        println!("  {}", task);
    }
    println!("\nPending tasks: {}", todos.pending_count());
}

Step 3: Add Filtering and Statistics with Traits

Define a Filterable trait for flexible querying. This shows how traits can add behavior to your data structures without modifying the core type:

use std::fmt;

#[derive(Debug, Clone, PartialEq)]
enum Priority { Low, Medium, High }

impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Priority::Low => write!(f, "LOW"),
            Priority::Medium => write!(f, "MED"),
            Priority::High => write!(f, "HIGH"),
        }
    }
}

#[derive(Debug, Clone)]
struct Task {
    id: u32,
    title: String,
    priority: Priority,
    completed: bool,
}

impl Task {
    fn new(id: u32, title: &str, priority: Priority) -> Self {
        Task { id, title: String::from(title), priority, completed: false }
    }
}

impl fmt::Display for Task {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let status = if self.completed { "x" } else { " " };
        write!(f, "[{}] #{:03} [{}] {}", status, self.id, self.priority, self.title)
    }
}

trait Filterable {
    fn by_priority(&self, priority: &Priority) -> Vec<&Task>;
    fn by_status(&self, completed: bool) -> Vec<&Task>;
    fn search(&self, query: &str) -> Vec<&Task>;
}

trait Statistics {
    fn completion_rate(&self) -> f64;
    fn count_by_priority(&self) -> Vec<(Priority, usize)>;
    fn summary(&self) -> String;
}

impl Filterable for Vec<Task> {
    fn by_priority(&self, priority: &Priority) -> Vec<&Task> {
        self.iter().filter(|t| &t.priority == priority).collect()
    }

    fn by_status(&self, completed: bool) -> Vec<&Task> {
        self.iter().filter(|t| t.completed == completed).collect()
    }

    fn search(&self, query: &str) -> Vec<&Task> {
        let query = query.to_lowercase();
        self.iter()
            .filter(|t| t.title.to_lowercase().contains(&query))
            .collect()
    }
}

impl Statistics for Vec<Task> {
    fn completion_rate(&self) -> f64 {
        if self.is_empty() {
            return 0.0;
        }
        let completed = self.iter().filter(|t| t.completed).count();
        (completed as f64 / self.len() as f64) * 100.0
    }

    fn count_by_priority(&self) -> Vec<(Priority, usize)> {
        vec![
            (Priority::High, self.iter().filter(|t| t.priority == Priority::High).count()),
            (Priority::Medium, self.iter().filter(|t| t.priority == Priority::Medium).count()),
            (Priority::Low, self.iter().filter(|t| t.priority == Priority::Low).count()),
        ]
    }

    fn summary(&self) -> String {
        let total = self.len();
        let completed = self.iter().filter(|t| t.completed).count();
        let pending = total - completed;
        format!(
            "Total: {} | Completed: {} | Pending: {} | Rate: {:.0}%",
            total, completed, pending, self.completion_rate()
        )
    }
}

fn main() {
    let mut tasks = vec![
        Task::new(1, "Write unit tests", Priority::High),
        Task::new(2, "Update README", Priority::Low),
        Task::new(3, "Fix memory leak", Priority::High),
        Task::new(4, "Add search feature", Priority::Medium),
        Task::new(5, "Refactor database module", Priority::Medium),
        Task::new(6, "Write API documentation", Priority::Low),
    ];

    // Complete some tasks
    tasks[0].completed = true;
    tasks[2].completed = true;

    // Filter by priority
    println!("== High Priority ==");
    for task in tasks.by_priority(&Priority::High) {
        println!("  {}", task);
    }

    // Filter by status
    println!("\n== Pending Tasks ==");
    for task in tasks.by_status(false) {
        println!("  {}", task);
    }

    // Search
    println!("\n== Search: 'write' ==");
    for task in tasks.search("write") {
        println!("  {}", task);
    }

    // Statistics
    println!("\n== Statistics ==");
    println!("{}", tasks.summary());
    println!("\nBy priority:");
    for (priority, count) in tasks.count_by_priority() {
        println!("  {}: {} tasks", priority, count);
    }
}

Step 4: Command Processing

Build a command parser that turns user input into actions. This demonstrates enums with data, pattern matching, and the Result type working together:

use std::fmt;

#[derive(Debug, Clone, PartialEq)]
enum Priority { Low, Medium, High }

impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Priority::Low => write!(f, "LOW"),
            Priority::Medium => write!(f, "MED"),
            Priority::High => write!(f, "HIGH"),
        }
    }
}

#[derive(Debug)]
enum Command {
    Add { title: String, priority: Priority },
    Complete(u32),
    Delete(u32),
    List,
    Help,
    Quit,
}

#[derive(Debug)]
enum ParseError {
    UnknownCommand(String),
    MissingArgument(String),
    InvalidId(String),
    InvalidPriority(String),
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ParseError::UnknownCommand(c) => write!(f, "Unknown command: '{}'", c),
            ParseError::MissingArgument(a) => write!(f, "Missing argument: {}", a),
            ParseError::InvalidId(id) => write!(f, "Invalid task ID: '{}'", id),
            ParseError::InvalidPriority(p) => write!(f, "Invalid priority: '{}' (use low/med/high)", p),
        }
    }
}

fn parse_priority(s: &str) -> Result<Priority, ParseError> {
    match s.to_lowercase().as_str() {
        "low" | "l" => Ok(Priority::Low),
        "med" | "medium" | "m" => Ok(Priority::Medium),
        "high" | "h" => Ok(Priority::High),
        other => Err(ParseError::InvalidPriority(String::from(other))),
    }
}

fn parse_command(input: &str) -> Result<Command, ParseError> {
    let parts: Vec<&str> = input.trim().splitn(3, ' ').collect();

    match parts.first().map(|s| s.to_lowercase()).as_deref() {
        Some("add") => {
            let title = parts.get(1)
                .ok_or(ParseError::MissingArgument(String::from("title")))?;
            let priority = parts.get(2)
                .map(|p| parse_priority(p))
                .unwrap_or(Ok(Priority::Medium))?;
            Ok(Command::Add {
                title: String::from(*title),
                priority,
            })
        }
        Some("done") | Some("complete") => {
            let id_str = parts.get(1)
                .ok_or(ParseError::MissingArgument(String::from("task ID")))?;
            let id = id_str.parse::<u32>()
                .map_err(|_| ParseError::InvalidId(String::from(*id_str)))?;
            Ok(Command::Complete(id))
        }
        Some("delete") | Some("rm") => {
            let id_str = parts.get(1)
                .ok_or(ParseError::MissingArgument(String::from("task ID")))?;
            let id = id_str.parse::<u32>()
                .map_err(|_| ParseError::InvalidId(String::from(*id_str)))?;
            Ok(Command::Delete(id))
        }
        Some("list") | Some("ls") => Ok(Command::List),
        Some("help") | Some("?") => Ok(Command::Help),
        Some("quit") | Some("exit") | Some("q") => Ok(Command::Quit),
        Some(other) => Err(ParseError::UnknownCommand(String::from(other))),
        None => Err(ParseError::MissingArgument(String::from("command"))),
    }
}

fn main() {
    let test_inputs = vec![
        "add Learn-Rust high",
        "add Buy-groceries low",
        "add Exercise",
        "done 1",
        "list",
        "delete 2",
        "help",
        "quit",
        "invalid",
        "done abc",
        "add",
    ];

    println!("== Command Parser Demo ==\n");
    for input in test_inputs {
        print!("Input: {:>25} -> ", format!("\"{}\"", input));
        match parse_command(input) {
            Ok(cmd) => println!("OK: {:?}", cmd),
            Err(e) => println!("ERR: {}", e),
        }
    }
}

Step 5: The Complete Application

Here is the full application wired together. It simulates user input to demonstrate all features working in concert:

use std::fmt;

#[derive(Debug, Clone, PartialEq)]
enum Priority { Low, Medium, High }

impl fmt::Display for Priority {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Priority::Low => write!(f, " LOW"),
            Priority::Medium => write!(f, " MED"),
            Priority::High => write!(f, "HIGH"),
        }
    }
}

#[derive(Debug, Clone)]
struct Task {
    id: u32,
    title: String,
    priority: Priority,
    completed: bool,
}

impl fmt::Display for Task {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let status = if self.completed { "x" } else { " " };
        write!(f, "  [{}] #{:03} [{}] {}", status, self.id, self.priority, self.title)
    }
}

struct App {
    tasks: Vec<Task>,
    next_id: u32,
}

impl App {
    fn new() -> Self {
        App { tasks: Vec::new(), next_id: 1 }
    }

    fn add(&mut self, title: &str, priority: Priority) -> Result<u32, String> {
        if title.trim().is_empty() {
            return Err(String::from("Title cannot be empty"));
        }
        let id = self.next_id;
        self.tasks.push(Task {
            id, title: String::from(title), priority, completed: false,
        });
        self.next_id += 1;
        Ok(id)
    }

    fn complete(&mut self, id: u32) -> Result<(), String> {
        self.tasks.iter_mut()
            .find(|t| t.id == id)
            .map(|t| { t.completed = true; })
            .ok_or(format!("Task #{} not found", id))
    }

    fn delete(&mut self, id: u32) -> Result<String, String> {
        let pos = self.tasks.iter().position(|t| t.id == id)
            .ok_or(format!("Task #{} not found", id))?;
        let task = self.tasks.remove(pos);
        Ok(task.title)
    }

    fn list(&self) {
        if self.tasks.is_empty() {
            println!("  No tasks yet. Add one with 'add <title> <priority>'");
            return;
        }
        for task in &self.tasks {
            println!("{}", task);
        }
        let done = self.tasks.iter().filter(|t| t.completed).count();
        let total = self.tasks.len();
        println!("  ---");
        println!("  {}/{} completed ({:.0}%)",
            done, total,
            if total > 0 { done as f64 / total as f64 * 100.0 } else { 0.0 }
        );
    }
}

fn main() {
    let mut app = App::new();

    println!("=== Todo App ===\n");

    // Simulate a series of user commands
    let commands: Vec<(&str, Box<dyn Fn(&mut App)>)> = vec![
        ("add 'Write tests' (high)", Box::new(|app: &mut App| {
            match app.add("Write unit tests", Priority::High) {
                Ok(id) => println!("  Added task #{}", id),
                Err(e) => println!("  Error: {}", e),
            }
        })),
        ("add 'Update docs' (low)", Box::new(|app: &mut App| {
            match app.add("Update documentation", Priority::Low) {
                Ok(id) => println!("  Added task #{}", id),
                Err(e) => println!("  Error: {}", e),
            }
        })),
        ("add 'Fix auth bug' (high)", Box::new(|app: &mut App| {
            match app.add("Fix authentication bug", Priority::High) {
                Ok(id) => println!("  Added task #{}", id),
                Err(e) => println!("  Error: {}", e),
            }
        })),
        ("add 'Add caching' (medium)", Box::new(|app: &mut App| {
            match app.add("Add response caching", Priority::Medium) {
                Ok(id) => println!("  Added task #{}", id),
                Err(e) => println!("  Error: {}", e),
            }
        })),
        ("list", Box::new(|app: &mut App| { app.list(); })),
        ("complete #1", Box::new(|app: &mut App| {
            match app.complete(1) {
                Ok(()) => println!("  Task #1 completed!"),
                Err(e) => println!("  Error: {}", e),
            }
        })),
        ("complete #3", Box::new(|app: &mut App| {
            match app.complete(3) {
                Ok(()) => println!("  Task #3 completed!"),
                Err(e) => println!("  Error: {}", e),
            }
        })),
        ("delete #2", Box::new(|app: &mut App| {
            match app.delete(2) {
                Ok(title) => println!("  Deleted '{}'", title),
                Err(e) => println!("  Error: {}", e),
            }
        })),
        ("complete #99 (error)", Box::new(|app: &mut App| {
            match app.complete(99) {
                Ok(()) => println!("  Completed!"),
                Err(e) => println!("  Error: {}", e),
            }
        })),
        ("list (final)", Box::new(|app: &mut App| { app.list(); })),
    ];

    for (description, action) in &commands {
        println!("> {}", description);
        action(&mut app);
        println!();
    }
}

Extension Ideas

Now that you have a working todo app, here are some ideas to extend it further on your own:

  1. Persistence: Serialize tasks to a JSON file using serde and serde_json, then load them on startup
  2. Due dates: Add a due_date field using chrono and sort tasks by deadline
  3. Tags: Let tasks have multiple tags and filter by tag
  4. Undo: Use a command history stack to implement undo/redo
  5. Real CLI: Use the clap crate to build a proper command-line interface with flags and subcommands

Key Takeaways

  • Real applications combine many Rust features: structs for data, enums for variants, traits for behavior, and Result for errors
  • The type system guides your design: if your code compiles, many classes of bugs are already eliminated
  • Pattern matching with enums makes command dispatch clean and exhaustive
  • Error handling with Result keeps your code robust without exceptions
  • Traits let you add capabilities like filtering and statistics without modifying core types
  • Iterators and closures make collection processing expressive and efficient
  • Start simple and layer on complexity: get the data model right first, then add behavior

Pro Tip: When building a real-world Rust project, start by defining your types (structs and enums), then add behavior with impl blocks and traits. Let the compiler guide you -- Rust's error messages are excellent and will often tell you exactly what to fix. A program that compiles is surprisingly likely to work correctly.

Course Complete!

Congratulations — you've worked through the entire Learning Rust curriculum! You now have a solid foundation in Rust's ownership model, type system, error handling, concurrency, and practical tools like serde and CLI argument parsing.

What to do next:

  • Build a project that combines what you've learned — a CLI tool that reads JSON files is a great start
  • Explore the Rust Playground to experiment further
  • Check out the Rust Book for even deeper coverage

Course complete!

You've reached the end of the curriculum. Keep practicing to sharpen what you've learned.