Guided Project 1: Command-Line Todo Application

Guided Project 1: Command-Line Todo Application

In this guided project, you’ll build a functional command-line todo application. This project will reinforce many concepts you’ve learned, including:

  • Structs and Enums for data modeling.
  • Ownership and borrowing.
  • Error handling with Result and Option.
  • File I/O for persistent data storage.
  • Basic command-line argument parsing.

Our todo application will allow users to:

  • Add a new todo item.
  • Mark an item as completed.
  • List all todo items (showing completed status).

We’ll store todo items in a simple JSON file.

Project Setup

  1. Create a new binary project:

    cargo new rust_todo_cli
    cd rust_todo_cli
    
  2. Add dependencies: We’ll use serde for serialization/deserialization to JSON and serde_json as the JSON backend.

    # Cargo.toml
    [package]
    name = "rust_todo_cli"
    version = "0.1.0"
    edition = "2024"
    
    [dependencies]
    serde = { version = "1.0", features = ["derive"] } # For serializing/deserializing data
    serde_json = "1.0" # For JSON formatting
    

    Alternatively, use cargo add serde --features derive and cargo add serde_json.

Step-by-Step Implementation

Step 1: Data Structures for Todo Items

We need a way to represent a single todo item and a collection of todo items.

src/main.rs:

use serde::{Deserialize, Serialize}; // Import traits for (de)serialization
use std::{collections::HashMap, fs, io::Result, path::PathBuf}; // Import necessary modules

#[derive(Debug, Serialize, Deserialize)] // Derive traits for easy serialization/deserialization
struct Task {
    text: String,
    completed: bool,
}

#[derive(Debug, Serialize, Deserialize)]
struct TodoList {
    tasks: HashMap<u32, Task>, // Use HashMap for easy lookup by ID
    next_id: u32,
}

impl TodoList {
    // Associated function to create a new, empty TodoList
    fn new() -> TodoList {
        TodoList {
            tasks: HashMap::new(),
            next_id: 1, // Start IDs from 1
        }
    }

    // Method to add a new task
    fn add_task(&mut self, text: String) -> u32 {
        let id = self.next_id;
        self.tasks.insert(id, Task { text, completed: false });
        self.next_id += 1;
        id
    }

    // Method to mark a task as completed
    fn complete_task(&mut self, id: u32) -> bool {
        if let Some(task) = self.tasks.get_mut(&id) {
            task.completed = true;
            true
        } else {
            false // Task not found
        }
    }

    // Method to list all tasks
    fn list_tasks(&self) {
        if self.tasks.is_empty() {
            println!("No tasks in the list.");
            return;
        }
        for (id, task) in &self.tasks {
            let status = if task.completed { "[x]" } else { "[ ]" };
            println!("{}: {} {}", id, status, task.text);
        }
    }
}

fn main() {
    // For now, let's just test our data structures
    let mut todo_list = TodoList::new();
    let id1 = todo_list.add_task(String::from("Learn Rust ownership"));
    let id2 = todo_list.add_task(String::from("Build a CLI todo app"));
    todo_list.add_task(String::from("Read more documentation"));

    println!("Initial tasks:");
    todo_list.list_tasks();

    todo_list.complete_task(id1);
    println!("\nAfter completing task {}:", id1);
    todo_list.list_tasks();

    todo_list.complete_task(100); // Try to complete a non-existent task
    println!("\nAfter attempting to complete non-existent task:");
    todo_list.list_tasks();
}

Explanation:

  • #[derive(Debug, Serialize, Deserialize)]: These attributes from serde allow our structs to be automatically converted to/from JSON. Debug helps with println!("{:?}").
  • Task: Holds the task’s description (text) and its completion status (completed).
  • TodoList: Contains a HashMap mapping unique u32 IDs to Task structs, and next_id to generate new unique IDs.
  • Methods are defined to manage tasks.

Run this step: cargo run and observe the output.

Step 2: Persistence - Loading and Saving to File

Now, let’s make our todo list persistent by saving it to and loading it from a JSON file.

Modify src/main.rs:

// ... (previous use statements and structs) ...

const TODO_FILE: &str = "todo.json"; // Define the file name

impl TodoList {
    // ... (previous methods: new, add_task, complete_task, list_tasks) ...

    // Associated function to load TodoList from file
    fn load() -> Result<TodoList> {
        let path = PathBuf::from(TODO_FILE);
        if path.exists() {
            let json_string = fs::read_to_string(&path)?;
            let todo_list: TodoList = serde_json::from_str(&json_string)?;
            Ok(todo_list)
        } else {
            // If file doesn't exist, return a new, empty TodoList
            Ok(TodoList::new())
        }
    }

    // Method to save TodoList to file
    fn save(&self) -> Result<()> {
        let json_string = serde_json::to_string_pretty(&self)?; // pretty-print for readability
        fs::write(TODO_FILE, json_string)?;
        Ok(())
    }
}

fn main() -> Result<()> { // Main now returns a Result to propagate I/O errors
    let mut todo_list = TodoList::load()?; // Load from file, or create new if not found

    // For initial testing, let's add/complete tasks here
    // In final app, this will be handled by CLI arguments
    let id1 = todo_list.add_task(String::from("Learn Rust ownership"));
    let id2 = todo_list.add_task(String::from("Build a CLI todo app"));
    todo_list.add_task(String::from("Read more documentation"));

    println!("Initial tasks:");
    todo_list.list_tasks();

    todo_list.complete_task(id1);
    println!("\nAfter completing task {}:", id1);
    todo_list.list_tasks();

    todo_list.complete_task(100);
    println!("\nAfter attempting to complete non-existent task:");
    todo_list.list_tasks();

    todo_list.save()?; // Save the current state to file

    // Demonstrate loading after saving:
    println!("\n--- Re-loading from file to verify persistence ---");
    let reloaded_list = TodoList::load()?;
    reloaded_list.list_tasks();


    Ok(())
}

Explanation:

  • TODO_FILE: A constant for our JSON file name.
  • load(): Reads the file, deserializes JSON into TodoList. Handles NotFound error by returning a new TodoList.
  • save(): Serializes TodoList to JSON and writes it to the file.
  • main() now returns Result<()>, allowing ? to propagate io::Error from file operations.

Run this step: cargo run. After running, check your project directory for todo.json. Open it to see the structured JSON data. Run cargo run again, and you should see the same tasks, demonstrating persistence.

Step 3: Command-Line Argument Parsing

Now, we’ll allow users to interact with the app using command-line arguments. We’ll support:

  • add <TASK_DESCRIPTION>: Adds a new task.
  • complete <ID>: Marks a task as completed.
  • list: Lists all tasks.

Modify src/main.rs:

// ... (previous use statements, structs, and TodoList impl) ...

// Enum to represent command-line actions
enum Action {
    Add(String),
    Complete(u32),
    List,
    Help,
}

fn parse_args() -> Action {
    let args: Vec<String> = std::env::args().collect();

    if args.len() < 2 {
        return Action::Help;
    }

    let command = &args[1];
    match command.as_str() {
        "add" => {
            if args.len() < 3 {
                println!("Usage: {} add <TASK_DESCRIPTION>", args[0]);
                Action::Help
            } else {
                let task_text = args[2..].join(" "); // Join remaining args as task description
                Action::Add(task_text)
            }
        }
        "complete" => {
            if args.len() < 3 {
                println!("Usage: {} complete <TASK_ID>", args[0]);
                Action::Help
            } else {
                match args[2].parse::<u32>() {
                    Ok(id) => Action::Complete(id),
                    Err(_) => {
                        eprintln!("Error: Invalid task ID provided.");
                        Action::Help
                    }
                }
            }
        }
        "list" => Action::List,
        _ => {
            eprintln!("Unknown command: {}", command);
            Action::Help
        }
    }
}

fn print_help(program_name: &str) {
    println!("Usage: {} <command>", program_name);
    println!("");
    println!("Commands:");
    println!("  add <TASK_DESCRIPTION>  Adds a new task to the list.");
    println!("  complete <ID>           Marks a task as completed.");
    println!("  list                    Lists all tasks.");
    println!("  help                    Displays this help message.");
}


fn main() -> Result<()> {
    let mut todo_list = TodoList::load()?; // Load existing tasks

    let action = parse_args();
    let program_name = std::env::args().next().unwrap_or_else(|| String::from("rust_todo_cli"));

    match action {
        Action::Add(text) => {
            let id = todo_list.add_task(text);
            println!("Added task with ID: {}", id);
        }
        Action::Complete(id) => {
            if todo_list.complete_task(id) {
                println!("Task {} marked as completed.", id);
            } else {
                eprintln!("Error: Task {} not found.", id);
            }
        }
        Action::List => {
            todo_list.list_tasks();
        }
        Action::Help => {
            print_help(&program_name);
        }
    }

    todo_list.save()?; // Save changes after command execution
    Ok(())
}

Explanation:

  • Action enum: Represents the different commands our CLI app can handle.
  • parse_args(): Reads command-line arguments using std::env::args(), then uses a match statement to determine the action and extract any necessary data.
  • Error handling for parse::<u32>() is included.
  • main(): Now calls parse_args() to get the action, then executes the corresponding logic.
  • args[2..].join(" "): A neat trick to collect all words after the “add” command into a single task description string.

Run this step:

  • cargo run -- add "Buy groceries"
  • cargo run -- add "Call Mom"
  • cargo run -- list
  • cargo run -- complete 1 (or whatever ID was assigned to “Buy groceries”)
  • cargo run -- list
  • cargo run -- help
  • cargo run -- invalid_command

You should see your todo list updating and persisting across runs!

Step 4 (Optional): More Robust Argument Parsing with clap

For more complex CLIs, the clap crate is highly recommended. It handles parsing, validation, and generates help messages automatically.

Modify Cargo.toml:

# Cargo.toml
[dependencies]
# ... (existing dependencies) ...
clap = { version = "4.0", features = ["derive"] } # For declarative argument parsing

Modify src/main.rs:

use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, io, path::PathBuf}; // Add `io` to Result
use clap::{Parser, Subcommand}; // Import from clap

#[derive(Debug, Serialize, Deserialize)]
struct Task {
    text: String,
    completed: bool,
}

#[derive(Debug, Serialize, Deserialize)]
struct TodoList {
    tasks: HashMap<u32, Task>,
    next_id: u32,
}

impl TodoList {
    // ... (same TodoList methods: new, add_task, complete_task, list_tasks, load, save) ...
    // Make sure `load` and `save` return `io::Result<TodoList>` and `io::Result<()>` respectively
    fn load() -> io::Result<TodoList> {
        let path = PathBuf::from(TODO_FILE);
        if path.exists() {
            let json_string = fs::read_to_string(&path)?;
            let todo_list: TodoList = serde_json::from_str(&json_string)
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; // Convert serde_json error to io::Error
            Ok(todo_list)
        } else {
            Ok(TodoList::new())
        }
    }

    fn save(&self) -> io::Result<()> {
        let json_string = serde_json::to_string_pretty(&self)
            .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; // Convert serde_json error
        fs::write(TODO_FILE, json_string)?;
        Ok(())
    }
}

const TODO_FILE: &str = "todo.json";

// Use Clap for argument parsing
#[derive(Parser, Debug)]
#[command(author, version, about = "A simple Rust CLI todo application", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Add a new task to the todo list
    Add {
        #[arg(help = "The description of the task to add.")]
        text: Vec<String>, // Vec<String> to capture multiple words
    },
    /// Mark a task as completed
    Complete {
        #[arg(help = "The ID of the task to complete.")]
        id: u32,
    },
    /// List all tasks in the todo list
    List,
}

fn main() -> io::Result<()> {
    let mut todo_list = TodoList::load()?;

    let cli = Cli::parse(); // Parse command-line arguments

    match cli.command {
        Commands::Add { text } => {
            let full_text = text.join(" ");
            let id = todo_list.add_task(full_text);
            println!("Added task with ID: {}", id);
        }
        Commands::Complete { id } => {
            if todo_list.complete_task(id) {
                println!("Task {} marked as completed.", id);
            } else {
                eprintln!("Error: Task {} not found.", id);
            }
        }
        Commands::List => {
            todo_list.list_tasks();
        }
    }

    todo_list.save()?;
    Ok(())
}

Explanation:

  • #[derive(Parser)]: The main struct for your CLI application.
  • #[command(subcommand)]: Indicates that Commands enum defines subcommands.
  • #[derive(Subcommand)]: Enum for different commands. Clap automatically derives argument parsing based on the struct/enum fields and doc comments.
  • #[arg(help = "...")]: Provides help text for arguments.
  • Vec<String> for Add’s text field automatically collects all remaining arguments.

Run this step: cargo run -- add "Learn more about Rust CLIs" cargo run -- complete 1 cargo run -- list cargo run -- help cargo run -- add (observe the auto-generated error)

This project provides a solid foundation for building more complex command-line tools in Rust.