Building Doit: A Simple Todo App in Rust

In this tutorial, we’ll build Doit, a command-line todo application using Rust. This project will teach you practical Rust concepts including ownership, borrowing, pattern matching, and working with external libraries.

Our Doit app will let users:

  1. Add a new task: doit add "Buy milk"
  2. List all tasks: doit list
  3. Mark done: doit done 1
  4. Remove a task: doit remove 1

All tasks are saved in a JSON file so they don’t disappear when you close the program.

Project Setup

Create a new Rust project:

cargo new doit
cd doit
Bash

Open Cargo.toml and add the dependencies:

[dependencies]
clap = { version = "4.5.49", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
TOML

Alternatively, you can add dependencies using the command line:

cargo add clap --features derive
cargo add serde --features derive
cargo add serde_json
Bash

This command automatically updates your Cargo.toml file with the latest compatible versions. The --features flag enables specific features for that crate.

About the dependencies:

  • clap: Parses command-line arguments and generates help text
  • serde: Framework for serializing and deserializing Rust data structures
  • serde_json: JSON support for serde

Understanding the Code Structure

Before we write code, let’s understand what we need to build. Our application has four main parts that work together.
First, we need a CLI Structure. This is the part that understands what the user types in the terminal. When someone types doit add “Buy milk”, this structure figures out that they want to add a task with the description “Buy milk”.
Second, we need a Task Structure. This defines what a task looks like in our program. Just like a form that has fields for your name, address, and phone number, our task has fields for its ID number, description, and whether it’s completed or not. This structure helps us organize information about each task.
Third, we need Functions. One function loads tasks from the file, another saves them, another figures out what ID number to give a new task, and so on. Each function has one clear job to do.
Finally, we need the Main Function. When the program starts, the main function runs first. It reads what command the user wants, then calls the right functions to do the work.

Writing the Code

Let’s build this step by step. Open src/main.rs and we’ll write the code in sections.

Imports and Constants
use clap::{Parser, Subcommand}; // Import two traits from the clap crate
use serde::{Serialize, Deserialize}; // Import two traits from the serde crate
use std::fs; // Import the fs module from the standard library for file operations
use std::path::Path; // Import the Path type for working with file paths

// Name of the file where tasks are stored. 
// This is known at compile time, stored in the binary, and lives for the entire program duration.
const TASKS_FILE: &str = "tasks.json";
Rust

The imports bring in the traits, types, and modules we need for our application. Let’s look at what each import does:

use clap::{Parser, Subcommand};
Rust

This imports two traits from the clap library. Parser is a trait that, when derived on a struct, generates code to parse command-line arguments and populate that struct. Subcommand is a trait specifically for enums that represent CLI subcommands. These traits form the foundation of clap’s derive API – a way to define CLIs declaratively through Rust’s type system and attributes.

use serde::{Serialize, Deserialize};
Rust

These two traits enable conversion between Rust data structures and various data formats. Serialize converts Rust types to a format like JSON. Deserialize does the opposite, converting from JSON into Rust types. When we derive these traits on our Task struct, serde generates all the necessary conversion code automatically.

use std::fs;
use std::path::Path;
Rust

The std::fs import brings in the filesystem module, which provides functions for reading and writing files. We’ll use fs::read_to_string() to read our JSON file and fs::write() to save it.
The std::path::Path import brings in the Path type, which represents filesystem paths in a platform-independent way. We use it to check if our tasks file exists before trying to read it.

const TASKS_FILE: &str = "tasks.json";
Rust

The constant TASKS_FILE defines where we store our tasks. Using const means this value is determined at compile time and embedded directly in the binary. The type &str is a string literal.
We use a constant here for a practical reason: the filename is referenced in multiple places throughout our code (load_tasks and save_tasks functions). By defining it once as a constant, we only need to change it in one place if we ever want to use a different filename. Constants in Rust are conventionally named in SCREAMING_SNAKE_CASE (all capitals with underscores between words).

Defining the CLI Structure
/// Simple TODO list 
#[derive(Parser, Debug)] // Ask clap to automatically implement the Parser trait for this struct
#[command(version, about = "A tiny CLI todo app")] // The #[command()] attribute is provided by clap to configure the CLI: adds --version flag and sets the description
struct CLI {
    #[command(subcommand)] // Tell clap this field will hold which subcommand the user chose
    command: Commands, // This field stores the subcommand the user chose. Type is 'Commands' (an enum defined below)
}
Rust

This struct is the foundation of our command-line interface. Let’s examine each part carefully.

The doc comment /// Simple TODO list is special. It starts with three slashes instead of two. This documentation comment is used by Rust’s documentation generator and also by clap when generating help text. It describes what this CLI application does.

The #[derive(Parser, Debug)] line is an attribute that provides instructions to the Rust compiler. In Rust, #[derive(…)] is a procedural macro that automatically generates code for the traits listed inside. Think of traits as interfaces or capabilities that a type can have. Here we’re deriving two traits:

  • Parser: This trait comes from the clap crate. When we derive it, clap generates all the code needed to parse command-line arguments and populate our struct. Without this derive macro, we’d need to write hundreds of lines of boilerplate code to handle argument parsing, validation, and error messages. The clap library does all this work for us automatically by analyzing our struct’s structure at compile time.
  • Debug: This is a standard Rust trait that allows us to print our struct in a debug format using {:?} in print statements. It’s useful during development for inspecting the state of our program.

The #[command(version, about = "A tiny CLI todo app")] attribute is specific to clap. It’s not part of Rust itself. Clap provides custom attributes that let us configure the generated CLI. This attribute is a procedural macro that clap recognizes when it implements the Parser trait. The version parameter tells clap to automatically add a --version flag that displays the version from your Cargo.toml. The about parameter sets the description that appears at the top of the help text when users run doit --help.

Now let’s look at the struct body. We define one field called command with type Commands (an enum we’ll define next). The #[command(subcommand)] attribute above this field tells clap that this field will hold a subcommand. In CLI applications, a subcommand is a secondary command, like how git has subcommands like git add, git commit, etc. Our application will have subcommands like doit add, doit list, etc. When clap parses the arguments and sees a subcommand, it will populate this field with the appropriate variant from the Commands enum.

Defining the Commands
#[derive(Subcommand, Debug)] // Ask clap to automatically implement the Subcommand trait for this enum
enum Commands { // Each variant represents a different subcommand the user can run
    /// Show the whole todo list
    List,

    /// Add a new task
    Add {
        /// Text of the new task
        #[arg(value_name = "TASK")] // Customize how this argument appears in --help text
        task: String,
    },

    /// Mark a task as completed
    Done {
        /// ID of the task to mark done
        #[arg(value_name = "ID")] // Customize how this argument appears in --help text
        id: u8,
    },

    /// Delete a task
    Remove {
        /// ID of the task to delete
        #[arg(value_name = "ID")] // Customize how this argument appears in --help text
        id: u8,
    },
}
Rust

The Commands enum defines all possible subcommands our application supports. In Rust, an enum (enumeration) represents a type that can be one of several variants. When a user runs our program, they must choose exactly one of these commands.

The #[derive(Subcommand, Debug)] attribute works similarly to the Parser derive on our CLI struct. The Subcommand trait is provided by clap specifically for enums that represent subcommands. When we derive this trait, clap generates code that knows how to parse each variant from command-line arguments. The Debug trait again lets us print the enum for debugging purposes.

Notice the doc comments (starting with ///) above each variant. These aren’t just documentation. clap uses them to generate the help text. When a user types doit --help, they’ll see these descriptions next to each command.

Let’s examine each variant:

List variant:
/// Show the whole todo list
List,
Rust

This is the simplest variant. It has no associated data. The comma after List indicates this is a unit variant (no fields). When a user types doit list, no additional arguments are needed, so this variant stands alone.

Add variant:
/// Add a new task
Add {
    /// Text of the new task
    #[arg(value_name = "TASK")] // Customize how this argument appears in --help text
    task: String,
},
Rust

This variant has struct-like syntax with curly braces containing fields. The Add command needs data, specifically, the text of the task to add. We define a field called task with type String.

The doc comment /// Text of the new task describes this argument and appears in the help text. The #[arg(value_name = "TASK")] attribute is another clap-specific attribute. It customizes how this argument appears in the help and usage text. Without it, clap would display the argument as task (using the field name in lowercase). With this attribute, it displays as <TASK> in all caps, which is a common CLI convention for required arguments.

Done variant:
/// Mark a task as completed
Done {
    /// ID of the task to mark done
    #[arg(value_name = "ID")] // Customize how this argument appears in --help text
    id: u8,
},
Rust

The Done command marks a task as complete. It needs to know which task, so it requires an ID number. The field id has type u8, which is an unsigned 8-bit integer ranging from 0 to 255. I chose u8 because most personal todo lists won’t exceed 255 tasks. If you expect to handle more tasks, you could use u16 (0-65,535) or u32 (0-4,294,967,295) instead.

Remove variant:
/// Delete a task
Remove {
    /// ID of the task to delete
    #[arg(value_name = "ID")] // Customize how this argument appears in --help text
    id: u8,
},
Rust

The Remove command has the same structure as Done. It needs an ID to identify which task to delete.

The power of enums in Rust is that different variants can have different associated data. List needs nothing, while Add needs a String and Done/Remove need a number. The type system ensures we handle each variant appropriately.

Defining the Task Structure
struct Task {
    id: u8, // The unique identifier for this task
    description: String, // What the task is about
    completed: bool, // Whether the task is done or not
}
Rust

This struct defines the structure of a task in our application. Every task will be an instance of this struct with these three fields.

The #[derive(Serialize, Deserialize)] attribute is crucial for our application. These traits come from the serde library. When we derive these traits, serde automatically generates code that can:

  • Serialize: Convert a Task struct into JSON format that can be written to a file
  • Deserialize: Convert JSON text back into a Task struct that we can use in our program

Without deriving these traits, we’d have to manually write code to convert each field to JSON and back. For a simple struct like this, that might not seem too bad, but serde handles complex nested structures, optional fields, and various data formats automatically.

Let’s look at each field:

The id field:
id: u8, // The unique identifier for this task
Rust

This is the unique identifier for each task. When you have multiple tasks, you need a way to refer to specific ones, “mark task 3 as done” or “remove task 5”. The ID serves this purpose. Each task gets a unique number. I use type u8 which can hold values from 0 to 255. For a personal todo list, this is more than sufficient.

The description field:
description: String, // What the task is about
Rust

This holds the actual text of what the task is like “Buy milk” or “Finish Rust tutorial”. We use String rather than &str because we need to own this data. A String is a heap-allocated, growable string that the Task struct owns. When we save tasks to a file and load them back, we need the data to persist, which requires ownership. A &str is just a reference to string data elsewhere and wouldn’t work for our use case.

The completed field:
completed: bool, // Whether the task is done or not
Rust

This tracks whether the task is done or not. The bool type (boolean) has only two possible values: true or false. When we create a new task, this starts as false. When the user marks it done, we change it to true. This simple flag is all we need to track task status.

When serde serializes a Task to JSON, the output looks like this:

{
  "id": 1,
  "description": "Buy milk",
  "completed": false
}
JSON

The field names in the JSON match the field names in our struct. Serde handles the type conversions automatically. Rust’s u8 becomes a JSON number, String becomes a JSON string, and bool becomes a JSON boolean.

Loading Tasks from File
// Load tasks from the JSON file
fn load_tasks() -> Vec<Task> { // Returns a vector containing Task objects
    if Path::new(TASKS_FILE).exists() { // Path::new(TASKS_FILE) creates a Path object. exists() checks if the file actually exists. 
        let data = fs::read_to_string(TASKS_FILE) // Read file contents into a String. Returns Result<String, Error>
            .unwrap(); // Extract the String from Result (panics if error)
        serde_json::from_str(&data) // Use serde_json's from_str() to deserialize the JSON string into Vec<Task>. Returns Result<Vec<Task>, Error>
            .unwrap_or(Vec::new()) // Extract the vector, or return empty vector if deserialization fails
    } else {
        Vec::new() // Return an empty vector if file doesn't exist
    }
}
Rust

This function returns a Vec<TASK>, a vector (growable list) of Task objects.

First, we check if the file exists. Path::new(TASKS_FILE) creates a Path object representing our file location (it doesn’t create the actual file, just an object representing the path). The .exists() method checks if a file actually exists at that location.

If the file exists, we read its contents with fs::read_to_string(TASKS_FILE), which returns a Result<String, Error>. The .unwrap() extracts the String if successful, or panics (crashes) if there’s an error. In production code you might handle errors more gracefully, but for now, unwrap is acceptable.

Next, serde_json::from_str(&data) deserializes the JSON string into a Vec<Task>. We pass &data (a reference) because the function only needs to read the string, not take ownership of it. This also returns a Result. The .unwrap_or(Vec::new()) extracts the vector if deserialization succeeds, or returns an empty vector if it fails (for example, if the JSON is corrupted).

If the file doesn’t exist, we simply return an empty vector. This happens on the first run before any tasks are saved.

Saving Tasks to File
fn save_tasks(tasks: &Vec<Task>) {
    let json = serde_json::to_string_pretty(tasks) // Serialize the vector to pretty-formatted JSON string. Returns Result<String, Error>
        .unwrap(); // Extract the String from Result (panics if error)
    // Write the JSON string to file. Returns Result<(), Error>
    // The () or Ok(()) means "unit type" - means the function succeeded but has nothing to return (like void in other languages)
    fs::write(TASKS_FILE, json)
        .unwrap(); // If Ok(()), do nothing and continue. If Err(error), panic
}
Rust

This function takes a reference to a vector of tasks. The & is important here. It means we’re borrowing the vector, not taking ownership. After this function completes, the caller can still use their tasks vector. This is more efficient than moving the entire vector into the function, and it’s appropriate since we only need to read the tasks to convert them to JSON.

The serde_json::to_string_pretty(tasks) serializes the vector into a pretty-formatted JSON string (with indentation and newlines). It returns a Result<String, Error>, and we unwrap it to get the string.

The fs::write(TASKS_FILE, json) writes the string to the file, creating it if it doesn’t exist or replacing its contents if it does. This returns Result<(), Error>. The () type (called “unit”) means the function succeeded but has no meaningful return value, like void in other languages. It’s either Ok(()) if the write succeeded, or Err(error) if it failed.

Getting the Next Available ID
fn get_next_id(tasks: &Vec<Task>) -> u8 {
    tasks.iter() // Iterate over tasks
        .map(|t| t.id) // Extract just the IDs (example [1, 3, 5])
        .max() // Find the highest ID. Returns Option<u8>: Some(max_id) or None if empty
        .unwrap_or(0) + 1 // Extract the value from Some, or use 0 if None (no tasks exist) and Add 1 to get the next available ID
}
Rust

This function determines what ID to assign to a new task by finding the highest existing ID and adding 1.

We use an iterator chain here. tasks.iter() creates an iterator over the tasks. The .map(|t| t.id) transforms each task into just its ID number. The closure |t| t.id takes each task t and extracts its id field. A closure is an anonymous function, the syntax uses pipes || to surround parameters.

The .max() finds the maximum value, returning Option<u8>. It’s an Option because the iterator might be empty (no tasks exist). If there’s a maximum, we get Some(max_value). If the iterator is empty, we get None.

The .unwrap_or(0) handles both cases: if we have Some(value), it extracts the value; if we have None, it gives us 0. Adding + 1 gives us the next available ID.

Examples:

  • If tasks have IDs [1, 3, 5], the max is 5, so we return 6
  • If there are no tasks, max returns None, unwrap_or gives 0, so we return 1
The Main Function
fn main() {
    // Parse the command-line arguments provided by the user and create a CLI instance.
    // This line (CLI::parse()) reads all of that, validates it, and stores it in the `cli` variable.
    // This is where clap does all the work automatically for us.
    let cli = CLI::parse();
    // Match on which subcommand the user chose and execute the corresponding action
    match cli.command {
        // ... command handling
    }
}
Rust

The main function is where execution begins. CLI::parse() is an associated function (like a static method in other languages) that clap generated for us through the #[derive(Parser)] attribute. It reads the command-line arguments, validates them, and creates a CLI instance. If the user provides invalid arguments, clap automatically displays an error message and help text.

The match expression handles each possible command. The compiler ensures we handle every variant of the Commands enum. This prevents bugs where we forget to implement a command.

Handling the List Command
// User use the 'list' command. Display all tasks
Commands::List => { 
    // Get the tasks from the file and save them into a vector
    // The 'tasks' variable now holds all our tasks as Vec<Task>
    let tasks =  load_tasks();
    if tasks.is_empty() {
        println!("šŸ“ No tasks yet!"); // Show message if there is no tasks
    } else { // if there are tasks
        println!("šŸ—’ļø  Todo List:"); 
        for task in tasks { // Loop through each task in the vector
            // Check if task is completed and set the an emoji
            // If completed is true, use āœ…, otherwise use ⬜
            let status = if task.completed { "āœ…" } else { "⬜" };
            println!("  {} [{}] {}", status, task.id, task.description); // Display: emoji [id] description
        }
    }
},
Rust

When the user types doit list, we load all tasks and check if the vector is empty. If so, we show a message. Otherwise, we iterate through each task with a for loop.

For each task, we determine the status emoji using a conditional expression: if task.completed { "āœ…" } else { "⬜" }. This creates a visual indicator of completion.

The println! uses format placeholders {} which are replaced with the provided values: the emoji, the ID in brackets, and the description.

Handling the Add Command
Commands::Add { task } => {
    // Get the tasks from the file and save them to a mutable vector
    // We use 'mut' (mutable) because we will modify this vector later (by adding a new task)
    let mut tasks = load_tasks(); // Load tasks (mutable because we'll add to it)
    let new_task = Task { // Creata a new Task according to user's parameter
        id: get_next_id(&tasks), // Assign next available ID
        // .clone() creates a copy of 'task' string because we use it again in println! below
        // Without .clone(), 'task' would be moved here and we couldn't use it later
        // .clone() lets us use the same string in two places
        description: task.clone(),
        completed: false, // New tasks start as incomplete
    };
    tasks.push(new_task); // Add the new task to the vector
    save_tasks(&tasks); // Save the updated list to file
    println!("āœ…  Adding task: {}", task); // Show a successful message
},
Rust

The pattern Commands::Add { task } destructures the Add variant, extracting the task string directly.

We load the existing tasks with let mut tasks = load_tasks(). The mut keyword makes this variable mutable, meaning we can modify it. Without mut, Rust won’t let us change the vector. We need mutability because we’ll add a new task.

We create a new Task struct, populating each field. The ID comes from get_next_id(&tasks), we pass a reference because the function only needs to read the tasks. The description uses task.clone(), which creates a copy of the string. We need to clone because we use task again in the println! at the end. If we moved task into the struct (without cloning), Rust’s ownership rules would prevent us from using it afterward. Cloning lets both the struct and the println use the string.

The tasks.push(new_task) adds the new task to the end of the vector. Then we save all tasks to the file and print a confirmation message.

Handling the Done Command
// User use the 'done' command with a parameter. Mark a task as completed
Commands::Done { id } => {
    // Get the tasks from the file and save them to a mutable vector
    // We use 'mut' (mutable) because we will modify this vector later (by changing the status)
    let mut tasks = load_tasks(); // Load tasks (mutable because we'll modify one)
    // Search for a task with the matching ID
    // iter_mut() gives mutable references so we can modify the task
    // find() returns Option: Some(task) if found, None if not found
    if let Some(task) = tasks.iter_mut().find(|t| t.id == id) { // With Some(task) we extract the Some value to a task variable to use it in the if block.
        task.completed = true; // Mark as completed
        save_tasks(&tasks); // Save changes to file
        println!("āœ”ļø  Marked task #{} as done", id); // Display successful message
    } else {
        println!("āŒ Task #{} not found", id); // If no task found show no found message
    }
},
Rust

We destructure, Commands::Done { id }, to get the id and load tasks mutably since we’ll modify one.

The line if let Some(task) = tasks.iter_mut().find(|t| t.id == id) does several things. First, tasks.iter_mut() creates a mutable iterator – unlike iter() which provides read-only references, iter_mut() provides mutable references, allowing us to change the tasks.

The .find(|t| t.id == id) searches for a task matching the given ID. The closure |t| t.id == id is the test for each task t, we check if its ID equals the one we’re looking for. The find method returns Option<&mut Task>, either Some(&mut task) if found, or None if not found.

The if let Some(task) = … is pattern matching combined with a conditional. If the result is Some (we found the task), we extract the mutable reference and execute the block. If it’s None (task not found), we skip to the else block.

Inside the if block, we have a mutable reference to the specific task. We set task.completed = true, save all tasks to the file, and print a success message. In the else block, we inform the user the task wasn’t found.

Handling the Remove Command
// User use the 'remove' command with a parameter. Delete a task
Commands::Remove { id } => {
    // Get the tasks from the file and save them to a mutable vector
    // We use 'mut' (mutable) because we will modify this vector later (by removing a task)
    let mut tasks = load_tasks(); // Load tasks (mutable because we'll remove one)
    let original_len = tasks.len(); // Remember how many tasks we had
    tasks.retain(|t| t.id != id); // retain() keeps only tasks where the condition is true (id != the one we want to remove)
    // Check if a task was actually removed by comparing lengths. No need to save again the same vector if nothing removed
    if tasks.len() < original_len {
        save_tasks(&tasks); // Save the updated list to file
        println!("šŸ—‘ļø  Removed task #{}", id); // Display successful message
    } else {
        println!("āŒ Task #{} not found", id); // If no task found show no found message
    }
},
Rust

After loading tasks mutably, we store the original length with tasks.len(). This lets us check afterward whether anything was removed.

The tasks.retain(|t| t.id != id) method keeps only tasks that pass the test. The closure |t| t.id != id returns true for tasks whose ID doesn’t match the one we want to remove. Those tasks are kept; the task with matching ID is removed. This is an elegant way to remove an item without manually finding its index or creating a new vector.

By comparing the vector length before and after, we know if a task was actually removed. If the length decreased, we save the changes and print success. If the length stayed the same, the task wasn’t found, so we print an error and skip saving (since nothing changed).

Running the Application

Build your application:

cargo build
Bash

Try it out:

# Add tasks
cargo run -- add "Buy milk"
cargo run -- add "Learn Rust"
cargo run -- add "Walk the dog"

# List all tasks
cargo run -- list

# Mark task 1 as complete
cargo run -- done 1

# Remove task 2
cargo run -- remove 2

# List again to see changes
cargo run -- list

# Get help
cargo run -- --help
Bash

The cargo run -- syntax means “run my program, and pass everything after — as arguments to the program.”

After building with –-release,

cargo build --release
Bash

you can also run the binary directly:

./target/release/doit add "New task"
./target/release/doit list
Bash

Understanding Key Rust Concepts

Throughout this project, we’ve used several important Rust concepts. Let’s review them in context.

Ownership and Borrowing

Rust’s ownership system ensures memory safety without garbage collection. Every value has an owner, and when ownership moves, the original owner can’t use the value anymore.

In our code, we frequently use & to borrow values instead of moving them. For example, save_tasks(&tasks) borrows the vector. After the function returns, we can still use tasks because we only lent it to the function. Without the &, the function would take ownership and tasks would be unusable afterward.

Mutability

By default, variables in Rust are immutable. When we need to modify something, we explicitly mark it with mut. This appears throughout our main function: let mut tasks = load_tasks(). Without mut, attempting to call tasks.push() or tasks.retain() would cause a compile error.

Pattern Matching

The match expression lets us handle different possibilities exhaustively. When we match cli.command, we must handle every variant of the Commands enum. The compiler ensures we don’t forget a case.

We also use if let for simpler pattern matching: if let Some(task) = … extracts a value from an Option only if it’s Some, providing a concise way to handle optional values.

Error Handling with Result and Option

Rust doesn’t use exceptions for error handling. Instead, functions that can fail return Result<T, E> (either Ok(value) or Err(error)), and functions that might have no value return Option<T> (either Some(value) or None).

In our code, we use unwrap() to extract values from Results, which panics if there’s an error. We also use unwrap_or(default) to provide a fallback value when encountering None.

Iterators

Rust’s iterator system provides a powerful way to work with sequences of data.

The iter() method creates an iterator that yields immutable references (&T) to each element. We use this in get_next_id() with tasks.iter().map(|t| t.id).max(). Since we only need to read the task IDs, immutable references are sufficient. The iterator gives us &Task for each task, we extract just the id field, and find the maximum.

The iter_mut() method creates an iterator that yields mutable references (&mut T). We use this in the Done command: tasks.iter_mut().find(|t| t.id == id). Because we need to modify the task (changing completed to true), we need mutable access. Without iter_mut(), we could only read the tasks, not change them.

Iterators in Rust are lazy, they don’t perform any work until they’re consumed by a method like max(), find(), or a for loop. When we write tasks.iter().map(|t| t.id), no iteration happens yet. Only when max() is called does the iterator actually traverse the tasks. This laziness allows the compiler to optimize iterator chains into very efficient code.

Extending the Application – Join Me!

You can find the code of this tutorial here!

I’ve made Doit open source, and I’d love for you to contribute! If you’re new to open source or Rust, this project is a great place to start. After working through this tutorial, you understand the codebase, how the CLI works, how tasks are stored, how the commands are handled. This foundation makes it much easier to add new features or fix bugs.

Features to add:
  • Edit task descriptions (would need a new Edit command variant)
  • Add task priorities (high, medium, low) – extend the Task struct
  • Due dates and reminders
  • Categories or tags for organizing tasks
  • Archive completed tasks to a separate file
  • Search functionality to find tasks by keyword
  • Color-coded output for better readability
Code improvements:
  • Better error handling (avoid unwrap())
  • Support for multiple todo lists (maybe a --list flag)
  • Add unit tests for the functions
  • Interactive mode where you can browse tasks with arrow keys

Don’t be intimidated if you’re new to contributing to open source. Start small – maybe add a feature you personally want, fix a typo in the documentation, or improve error messages. Every contribution matters, and I’m happy to help you through the process. Check out how to contribute in my repository on GitHub and open an issue!

Conclusion

When I started this project, I thought I’d just practice Rust syntax and get more comfortable with the language. But I was surprised by how much deeper it went.

Building Doit taught me not just what to write in Rust, but why Rust works the way it does. The trait system became clearer. When we derive Serialize and Deserialize, we’re not just adding magic, we’re implementing a well-defined interface that serde knows how to work with. When clap generates code from our #[derive(Parser)], it’s reading our type structure at compile time and generating type-safe argument parsing.

This project showed me what Rust is really about: preventing bugs through types, staying fast through optimization, and always being explicit about what’s happening. It’s more than syntax, it’s a way of thinking about code.

I hope this tutorial helps you not only build a todo app, but also understand how Rust thinks. The code is available in my GitHub repository, and I’d love to hear what you build with it.

Leave a Reply

Your email address will not be published. Required fields are marked *