Build a CLI Task Manager in Rust

Build a persistent CLI task manager in Rust using structs, enums, and serde.

Introduction

30 Second Summary

Every developer has a to-do list. But instead of relying on yet another app, what if you could manage your tasks straight from the terminal with a tool you built yourself?

In this project, you will build taskr, a command-line task manager written in Rust. You will add, list, complete, and remove tasks that persist between sessions using a JSON file.

What You'll Build

A compiled single-binary CLI tool that manages tasks with persistent storage, built using Rust's ownership system, structs, enums, and pattern matching.

By the end of this project, you'll have:

  • A working Rust binary that parses CLI commands using the clap crate.
  • A Task struct with JSON serialization via serde, demonstrating ownership and borrowing.
  • Proper error handling with Result and the ? operator for idiomatic Rust code.
  • Secret Mission: Add a priority system with color-coded terminal output.

Are there any prerequisites?

You need basic programming knowledge in any language (variables, functions, conditionals). No prior Rust experience is needed. This project walks you through everything from installation to a working tool.

Install Rust and Set Up Your Project

Before you can build anything in Rust, you need the Rust toolchain on your machine.

The toolchain includes rustc (the compiler) and cargo (the build tool and package manager). In this step, you will install both, scaffold your project, and pull in the external libraries (called crates) that your task manager needs.

In this step, get ready to:
  • Install the Rust toolchain via rustup.
  • Scaffold a new Cargo project called taskr.
  • Add three dependency crates to your project.
Install Rust via rustup

Rust is installed through rustup, the official installer and version manager. It sets up the compiler, Cargo, and keeps everything up to date with a single command.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS/Linux) or PowerShell (Windows) and press Enter to open it.

Now install Rust by following the tab that matches your operating system.

macOS

  • Install Rust by running this command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

What does this command do?

This downloads and runs the official rustup installer script. It installs rustc, cargo, and other tools to ~/.cargo/bin.

  • The installer will ask you to choose an installation option. Press 1 (or Enter) to proceed with the default installation.
  • After the installer finishes, reload your shell configuration by running:
source $HOME/.cargo/env

Linux

  • Install Rust by running this command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

What does this command do?

This downloads and runs the official rustup installer script. It installs rustc, cargo, and other tools to ~/.cargo/bin.

  • The installer will ask you to choose an installation option. Press 1 (or Enter) to proceed with the default installation.
  • After the installer finishes, reload your shell configuration by running:
source $HOME/.cargo/env

Install a C linker first

Rust needs a C linker to produce binaries. On Ubuntu or Debian, run sudo apt install build-essential to get GCC before installing Rust.

Windows

  • Visit https://www.rust-lang.org/tools/install in your browser.
  • Download and run rustup-init.exe.
  • The installer will offer to automatically install the Visual Studio C++ Build Tools if they are not already present. Allow it to do so.
  • When prompted to choose an installation option, press 1 (or Enter) to proceed with the default installation.
  • After the installer finishes, close and reopen your terminal so the updated PATH takes effect.

What are the Visual Studio C++ Build Tools?

Rust on Windows needs a linker and system libraries provided by Microsoft's MSVC build tools. The rustup installer handles this for you automatically.

  • Verify the installation by running:
rustc --version

✔️ I see version 1.85 or higher

Your Rust compiler is ready. Now check Cargo:

cargo --version

You should see a version number printed. Both tools are installed and on your PATH.

ⓧ I see an older version

This project requires Rust 1.85+ because one of the dependencies (clap 4.6.1) needs it. Update your existing installation by running:

rustup update stable

After the update completes, run rustc --version again to confirm you are on 1.85 or higher.

ⓧ Command not found

Rust is not on your PATH yet. This usually means the shell has not picked up the new configuration.

On macOS or Linux, run:

source $HOME/.cargo/env

On Windows, close your terminal completely and open a new one.

Then try rustc --version again. If it still fails, re-run the installer from the beginning of this substep.

Still getting an error?

On Linux, make sure you have a C linker installed. On Ubuntu or Debian, run sudo apt install build-essential to get GCC.

On Windows, if the Visual Studio C++ Build Tools installation was skipped, re-run rustup-init.exe and allow it to install the prerequisites.

help me troubleshoot my Rust installation

Create a new project with Cargo

Cargo is Rust's build system and package manager. It creates projects, compiles code, and manages dependencies for you.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project and move into it by running these commands:
cargo new taskr
cd taskr

What did Cargo create?

Cargo scaffolded a new directory called taskr containing two key files:

  • Cargo.toml holds your project's metadata and dependency list.
  • src/main.rs contains a hello-world program to get you started.
  • Confirm the project compiles and runs by running:
cargo run

You should see Cargo compile the project and then print Hello, world! in the terminal.

Seeing a compile error?

Make sure you are inside the taskr folder when you run cargo run. Cargo looks for Cargo.toml in the current directory.

help me fix a cargo run error in my new Rust project

Add your project dependencies

Your task manager needs three external crates (Rust's term for libraries). Each one handles a different responsibility so you do not have to write everything from scratch.

  • Add clap for CLI argument parsing by running:
cargo add clap --features derive
  • Add serde for serialization support by running:
cargo add serde --features derive
  • Add serde_json for JSON formatting by running:
cargo add serde_json

What do these crates do?

clap automatically generates argument parsing and help text from struct definitions. The derive feature lets you define your CLI using Rust attributes instead of a manual builder.

serde is Rust's standard serialization framework. Combined with serde_json, it lets you convert your Rust structs to and from JSON with a single derive annotation.

  • Verify your Cargo.toml now lists all three dependencies. Open it in your code editor and check the [dependencies] section.

You should see clap, serde, and serde_json listed with their version numbers.

✔️ Awesome, I've got everything!

Great. Your project is scaffolded and all dependencies are in place.

ⓧ I'd like to double check the full code

Here is what your Cargo.toml should look like:

[package]
name = "taskr"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"

And your src/main.rs should still contain the default hello world:

fn main() {
    println!("Hello, world!");
}

Dependencies not showing up?

Make sure you ran each cargo add command from inside the taskr folder. Cargo writes to the Cargo.toml in your current directory.

The exact version numbers in your file may differ slightly from the reference. That is fine as long as all three crates are listed.

help me fix missing dependencies in my Cargo.toml

Your Rust environment is ready and your project has all the libraries it needs. Next up, you will define the CLI commands that your task manager responds to.

Define the CLI Structure with Clap

Your project compiles, your dependencies are installed, and Cargo is ready to build real code.

Every CLI tool needs a way to understand what the user typed. Instead of parsing raw strings yourself, you'll use clap's derive macros to declare your commands as Rust structs and enums. Clap then generates all the argument parsing, help text, and error messages for you automatically.

In this step, get ready to:
  • Define a CLI struct and a Commands enum using clap's derive macros.
  • Write a main function that parses arguments and matches on each command.
  • Test the auto-generated help text and command dispatching.
Define the Cli struct with Parser

Clap's derive API lets you describe your entire CLI as a Rust struct. Each field becomes a command-line argument or subcommand, and clap handles the rest.

  • Open src/main.rs in your code editor.
  • Select all the existing code and delete it.
  • Replace it with the following import and struct definition:
use clap::{Parser, Subcommand};

// The top-level CLI struct that clap parses arguments into
#[derive(Parser)]
#[command(name = "taskr")]
#[command(about = "A simple CLI task manager built in Rust")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

What does this code do?

The use clap::{Parser, Subcommand} line imports two traits from the clap crate that power the derive macros.

The #[derive(Parser)] attribute tells clap to generate all the argument-parsing code for this struct automatically. The #[command] attributes set your app's name and description, which appear in the auto-generated help output.

The command: Commands field with #[command(subcommand)] tells clap that this app uses subcommands (like add, list, etc.) rather than plain flags.

Define the Commands enum

Rust enums are perfect for modelling a fixed set of choices. Each variant of your Commands enum becomes a subcommand that clap recognizes.

  • Add the following enum directly below the Cli struct in src/main.rs:
#[derive(Subcommand)]
enum Commands {
    /// Add a new task
    Add {
        /// The task description
        description: String,
    },
    /// List all tasks
    List,
    /// Mark a task as done
    Done {
        /// The task position (from list)
        position: usize,
    },
    /// Remove a task
    Remove {
        /// The task position (from list)
        position: usize,
    },
}

What does this code do?

Each variant of the enum is a subcommand. Add takes a positional description argument (a String), while Done and Remove each take a position number (usize). List takes no arguments at all.

The /// doc comments above each variant and field become the help text that users see when they run --help. Clap reads them automatically.

What is pattern matching?

Pattern matching is Rust's way of branching based on the shape of data. A match expression checks which variant of an enum you have, extracts its inner fields, and runs the corresponding code. The compiler ensures you handle every variant, so you can never forget a case.

Write the main function and test the CLI

Now you need a main() function that calls clap's parser and dispatches to the correct command using a match expression.

  • Add the following main function at the bottom of src/main.rs, below the Commands enum:
fn main() {
    // Parse command-line arguments into our Cli struct
    let cli = Cli::parse();
    // Match on the command variant and print a confirmation
    match cli.command {
        Commands::Add { description } => println!("Add command received: {}", description),
        Commands::List => println!("List command received"),
        Commands::Done { position } => println!("Done command received for position {}", position),
        Commands::Remove { position } => println!("Remove command received for position {}", position),
    }
}

What does this code do?

Cli::parse() reads the arguments from the terminal and converts them into your Cli struct. If the user passes invalid input, clap automatically prints an error and exits.

The match cli.command block checks which subcommand variant was used. Each arm destructures the variant to pull out its fields (like description or position) and prints a confirmation. In later steps, you'll replace these print statements with real logic.

  • Save src/main.rs.
  • View the auto-generated help by running this command:
cargo run -- --help

You should see help output listing your four subcommands (add, list, done, remove) with their doc-comment descriptions.

  • Test the add command by running:
cargo run -- add "Test task"

You should see Add command received: Test task printed in your terminal.

Why the double dash?

The -- separates arguments meant for cargo from arguments meant for your program. Everything before -- goes to Cargo. Everything after goes to taskr.

Seeing a compile error?

Make sure your use clap::{Parser, Subcommand}; line is at the very top of the file. Rust needs imports before any struct or enum definitions.

Check that every /// doc comment uses three slashes, not two. Two slashes (//) are regular comments and won't generate help text.

Help me fix a compile error in my clap CLI struct

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.rs and you're ready to move on.

ⓧ I'd like to double check the full code

Here's the complete src/main.rs at this point:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "taskr")]
#[command(about = "A simple CLI task manager built in Rust")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add a new task
    Add {
        /// The task description
        description: String,
    },
    /// List all tasks
    List,
    /// Mark a task as done
    Done {
        /// The task position (from list)
        position: usize,
    },
    /// Remove a task
    Remove {
        /// The task position (from list)
        position: usize,
    },
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Add { description } => println!("Add command received: {}", description),
        Commands::List => println!("List command received"),
        Commands::Done { position } => println!("Done command received for position {}", position),
        Commands::Remove { position } => println!("Remove command received for position {}", position),
    }
}

Your CLI skeleton is in place. Clap is parsing arguments, matching commands, and even generating polished help text for free. Next up, you'll create the Task struct and wire up file storage so your tasks persist between runs.

Create the Task Struct and File Storage

Your CLI can parse commands, but it has nowhere to store tasks. Without a data model and file storage, every task disappears the moment the program exits.

In this step, you'll create a Task struct to represent your data, then write functions to load and save tasks to a JSON file. This is where Rust's ownership system becomes visible in your function signatures.

In this step, get ready to:
  • Define a Task struct with serialization support.
  • Write load_tasks() and save_tasks() functions for file persistence.
  • Add a next_id() helper that generates unique task IDs.
Define the Task struct and storage constant

Before writing functions that manipulate tasks, you need a data type that represents a single task. You also need imports for Serde (the serialization library) and the filesystem module.

  • In src/main.rs, find the existing import line at the top of the file:
use clap::{Parser, Subcommand};
  • Add these lines directly below it:
use serde::{Deserialize, Serialize};
use std::fs;

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

What does this code do?

  • use serde::{Deserialize, Serialize} imports the traits your struct needs to convert to and from JSON.
  • use std::fs imports Rust's filesystem module for reading and writing files.
  • TASKS_FILE is a constant string slice pointing to tasks.json. Every function that touches the file references this single constant instead of repeating the filename.

You won't see a change yet. The next chunk defines the struct that uses these imports.

  • Now scroll down past the Commands enum's closing } brace and find the fn main() line.
  • Add the following struct definition above fn main():
#[derive(Serialize, Deserialize, Debug)]
struct Task {
    id: u32,
    description: String,
    done: bool,
}

What does this code do?

The Task struct holds three fields: a numeric id, a description string, and a done boolean.

The #[derive(...)] line automatically generates code so the struct can be serialized to JSON (Serialize), deserialized from JSON (Deserialize), and printed for debugging (Debug).

Notice description is a String, not a &str. This means each Task owns its description data. Ownership is central to Rust and you'll see how it affects function signatures next.

  • Save src/main.rs.
  • Verify the struct compiles by running:
cargo build

You should see a Compiling taskr line followed by Finished with no errors.

Seeing a compile error?

  • Check that use serde::{Deserialize, Serialize}; is spelled exactly as shown. A typo like Serialise or Deserlialize triggers a "cannot find" error.
  • Make sure the Task struct is placed outside of fn main(). If it's inside the function body, the derives won't work correctly.
  • Confirm your Cargo.toml still has serde = { version = "1.0.228", features = ["derive"] }. The derive feature is required for #[derive(Serialize, Deserialize)] to work.

Still stuck? Help me fix this Rust compile error with my Task struct and serde derives

Write the file storage functions

Your task manager needs to persist data between runs. You'll write two functions: one to load tasks from the JSON file, and one to save them back.

  • In src/main.rs, add the following function below the Task struct and above fn main():
fn load_tasks() -> Vec<Task> {
    // Check if the file exists before trying to read it
    if !std::path::Path::new(TASKS_FILE).exists() {
        return Vec::new();
    }
    // Read the file contents into a String
    let data = fs::read_to_string(TASKS_FILE).unwrap();
    // Parse the JSON string into a vector of Tasks
    serde_json::from_str(&data).unwrap()
}

What does this code do?

  • The function returns Vec<Task>. A Vec is Rust's growable array type.
  • If tasks.json doesn't exist yet (first run), the function returns an empty vector instead of crashing.
  • fs::read_to_string reads the entire file into a String that data owns.
  • serde_json::from_str(&data) borrows data with & to parse it without consuming it. The & means "borrow this value, don't take ownership."

You won't see a change yet. The save_tasks function completes the storage layer.

  • Add save_tasks directly below load_tasks, still above fn main():
fn save_tasks(tasks: &[Task]) {
    // Convert the tasks vector into a pretty-printed JSON string
    let data = serde_json::to_string_pretty(tasks).unwrap();
    // Write the JSON string to the file
    fs::write(TASKS_FILE, data).unwrap();
}

Why does the parameter use &[Task]?

The &[Task] type is a borrowed slice. It lets save_tasks read the tasks without taking ownership of them.

After save_tasks returns, the caller still owns the vector and can keep using it. If the parameter were Vec<Task> instead, the caller would lose access after the call.

  • Save src/main.rs.
  • Confirm both functions compile by running:
cargo build

You should see Compiling taskr followed by Finished with no errors.

Seeing "cannot find function" or type errors?

  • Make sure load_tasks and save_tasks are placed outside fn main(). They must be standalone functions, not nested inside main.
  • Check that use std::fs; is at the top of your file. Without it, fs::read_to_string and fs::write won't resolve.
  • Verify the return type arrow uses -> (dash then greater-than), not a single character.

Still stuck? Help me fix a compile error in my load_tasks or save_tasks function

Add an ID generator helper

Each task needs a unique ID so you can reference it later. This helper scans existing tasks and picks the next available number.

  • Add this function below save_tasks, still above fn main():
fn next_id(tasks: &[Task]) -> u32 {
    // Find the highest existing ID and add 1, or start at 1 if empty
    tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1
}

What does this code do?

  • tasks.iter() creates an iterator over borrowed references to each task.
  • .map(|t| t.id) extracts just the id field from each task.
  • .max() finds the largest ID. It returns None if the list is empty.
  • .unwrap_or(0) + 1 defaults to 0 when there are no tasks, then adds 1. So the first task always gets ID 1.

This function borrows the slice with &[Task] just like save_tasks. It reads the data without consuming it, so the caller keeps full ownership of the vector.

  • Save src/main.rs.
  • Run the final compile check:
cargo build

You should see Finished with no errors. You may also see warnings about unused functions. That's expected since main() doesn't call them yet.

Getting a syntax error on the iterator chain?

  • Make sure the closure uses pipes, not parentheses: |t| not (t).
  • Check that the function is outside fn main() like the other helper functions.
  • Verify the return type is u32 and matches the id field type on your Task struct.

Still stuck? Help me fix this iterator chain in my next_id function

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.rs and that cargo build completes without errors.

ⓧ I'd like to double check the full code

use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::fs;

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

#[derive(Parser)]
#[command(name = "taskr")]
#[command(about = "A simple CLI task manager built in Rust")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add a new task
    Add {
        /// The task description
        description: String,
    },
    /// List all tasks
    List,
    /// Mark a task as done
    Done {
        /// The task position (from list)
        position: usize,
    },
    /// Remove a task
    Remove {
        /// The task position (from list)
        position: usize,
    },
}

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    id: u32,
    description: String,
    done: bool,
}

fn load_tasks() -> Vec<Task> {
    if !std::path::Path::new(TASKS_FILE).exists() {
        return Vec::new();
    }
    let data = fs::read_to_string(TASKS_FILE).unwrap();
    serde_json::from_str(&data).unwrap()
}

fn save_tasks(tasks: &[Task]) {
    let data = serde_json::to_string_pretty(tasks).unwrap();
    fs::write(TASKS_FILE, data).unwrap();
}

fn next_id(tasks: &[Task]) -> u32 {
    tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Add { description } => println!("Add command received: {}", description),
        Commands::List => println!("List command received"),
        Commands::Done { position } => println!("Done command received for position {}", position),
        Commands::Remove { position } => println!("Remove command received for position {}", position),
    }
}

Your data model and storage layer are ready. Next up, you'll wire these functions into your match block so each command actually creates, reads, updates, and deletes tasks.

Implement the Commands

In the previous step, you built the data layer for taskr: a Task struct, file persistence functions, and an ID generator.

Right now, the match block inside main() only prints placeholder messages. In this step, you will wire up each command arm to actually create, display, complete, and remove tasks using the functions you already wrote.

In this step, get ready to:
  • Implement the Add and List commands to create and display tasks.
  • Implement the Done and Remove commands to modify and delete tasks.
  • Test the full workflow to confirm tasks persist between runs.
Implement the Add and List commands

The Add command needs to load existing tasks, create a new one with a unique ID, append it to the vector, save everything back to disk, and confirm the action. This is where Rust's ownership system shows up: you need a mutable binding (let mut) to push into the vector, and you borrow (&tasks) when passing to helper functions.

  • In src/main.rs, find this line inside the match block:
Commands::Add { description } => println!("Add command received: {}", description),
  • Replace that entire line with this implementation:
        Commands::Add { description } => {
            // Load existing tasks from the JSON file
            let mut tasks = load_tasks();
            // Generate the next unique ID
            let id = next_id(&tasks);
            // Create a new Task struct
            let task = Task {
                id,
                description: description.clone(),
                done: false,
            };
            // Append to the vector and save back to disk
            tasks.push(task);
            save_tasks(&tasks);
            println!("Added task {}: \"{}\"", id, description);
        }

What does this code do?

let mut tasks = load_tasks() loads the current tasks into a mutable vector so you can push new items onto it.

next_id(&tasks) borrows the vector (read-only access) to find the highest existing ID. The & means you are lending the data without giving up ownership.

description.clone() creates a copy of the string so it can live inside the new Task struct independently. Rust's ownership model requires each value to have one owner.

  • Now find this line in the same match block:
Commands::List => println!("List command received"),
  • Replace it with this implementation:
        Commands::List => {
            // Load all tasks from the file
            let tasks = load_tasks();
            if tasks.is_empty() {
                println!("No tasks yet. Add one with: taskr add \"Your task\"");
                return;
            }
            println!("\nYour tasks:");
            println!("{}", "-".repeat(40));
            // Print each task with its position, status, and description
            for (i, task) in tasks.iter().enumerate() {
                let checkbox = if task.done { "[x]" } else { "[ ]" };
                println!("  {}. {} {}", i + 1, checkbox, task.description);
            }
            println!();
        }

What does this code do?

tasks.iter().enumerate() gives you each task along with its zero-based index. You display i + 1 so positions start at 1 for the user.

The if task.done { "[x]" } else { "[ ]" } expression is a conditional that returns a string slice. This is pattern matching in action, letting you pick a value inline without a separate statement.

  • Save src/main.rs.
  • Test the Add command by running:
cargo run -- add "Test task"
  • You should see output like Added task 1: "Test task" in your terminal.
  • Test the List command by running:
cargo run -- list
  • You should see Your tasks: followed by a line of dashes and 1. [ ] Test task.

Seeing a compile error?

Make sure you replaced the entire one-line arm (including the trailing comma) with the new block that uses curly braces. Each match arm with a block body does not need a comma after the closing brace.

Check that the opening { is on the same line as the => and the closing } aligns with the Commands:: keyword.

help me fix a compile error in my match block

Implement the Done and Remove commands

The Done and Remove commands both need to validate that the user-provided position is within bounds before modifying the task list. This prevents a panic from an out-of-bounds index.

  • In src/main.rs, find this line:
Commands::Done { position } => println!("Done command received for position {}", position),
  • Replace it with this implementation:
        Commands::Done { position } => {
            let mut tasks = load_tasks();
            // Validate position is within bounds
            if position == 0 || position > tasks.len() {
                eprintln!("Error: invalid position {}. You have {} tasks.", position, tasks.len());
                std::process::exit(1);
            }
            // Mark the task as done (positions are 1-based, indices are 0-based)
            tasks[position - 1].done = true;
            let desc = tasks[position - 1].description.clone();
            save_tasks(&tasks);
            println!("Marked as done: \"{}\"", desc);
        }

What does this code do?

The bounds check if position == 0 || position > tasks.len() ensures the user cannot crash the program with an invalid number. Since positions are 1-based but vector indices are 0-based, you subtract 1 when accessing.

eprintln! prints to standard error instead of standard output. This is the convention for error messages in CLI tools.

std::process::exit(1) terminates the program with a non-zero exit code, signalling failure to the operating system.

  • Now find this line:
Commands::Remove { position } => println!("Remove command received for position {}", position),
  • Replace it with this implementation:
        Commands::Remove { position } => {
            let mut tasks = load_tasks();
            // Validate position is within bounds
            if position == 0 || position > tasks.len() {
                eprintln!("Error: invalid position {}. You have {} tasks.", position, tasks.len());
                std::process::exit(1);
            }
            // Remove the task and capture what was removed
            let removed = tasks.remove(position - 1);
            save_tasks(&tasks);
            println!("Removed: \"{}\"", removed.description);
        }

What does this code do?

tasks.remove(position - 1) removes the element at the given index and returns it. This is why you can access removed.description afterwards. The .remove() method shifts all subsequent elements left to fill the gap.

  • Save src/main.rs.
  • Test the Done command by running:
cargo run -- done 1
  • You should see Marked as done: "Test task" in your terminal.
  • Verify the checkbox changed by running:
cargo run -- list
  • You should now see [x] next to the task instead of [ ].

Seeing "Error: invalid position"?

This means you tried to mark a position that does not exist. If you deleted tasks.json manually or have no tasks, add one first with cargo run -- add "Test".

help me debug why my done command says invalid position

Test the full workflow

Time to run through the complete lifecycle of adding, listing, completing, and removing tasks. This confirms that tasks.json persists data between separate runs of the program.

  • First, delete the existing test data so you start fresh. Remove tasks.json if it exists by running:
rm -f tasks.json
  • Add two tasks by running these commands:
cargo run -- add "Learn Rust ownership"
cargo run -- add "Build a CLI tool"
  • You should see confirmation messages for task 1 and task 2.
  • List all tasks by running:
cargo run -- list
  • You should see both tasks with [ ] checkboxes.
  • Mark the first task as done by running:
cargo run -- done 1
  • List again to confirm the checkbox changed:
cargo run -- list
  • Task 1 should now show [x] while task 2 still shows [ ].
  • Remove the second task by running:
cargo run -- remove 2
  • You should see Removed: "Build a CLI tool" in the terminal.
  • Run cargo run -- list one final time. Only the first task should remain.

Why does data persist between runs?

Each time you run cargo run -- add or any other command, it is a brand-new process. The tasks survive because save_tasks() writes them to tasks.json on disk, and load_tasks() reads them back at the start of the next run.

This is the simplest form of persistence. No database needed.

Tasks not persisting?

Check that tasks.json exists in your taskr project folder (the same directory as Cargo.toml). You can inspect it by running cat tasks.json.

If the file is empty or malformed, delete it and try adding a task again. The file gets created fresh when you add your first task.

help me figure out why tasks.json is not saving between runs

✔️ Awesome, I've got everything!

Great. Double check you have saved src/main.rs and that cargo run -- list shows your tasks.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at this point:

use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::fs;

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

#[derive(Parser)]
#[command(name = "taskr")]
#[command(about = "A simple CLI task manager built in Rust")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add a new task
    Add {
        /// The task description
        description: String,
    },
    /// List all tasks
    List,
    /// Mark a task as done
    Done {
        /// The task position (from list)
        position: usize,
    },
    /// Remove a task
    Remove {
        /// The task position (from list)
        position: usize,
    },
}

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    id: u32,
    description: String,
    done: bool,
}

fn load_tasks() -> Vec<Task> {
    if !std::path::Path::new(TASKS_FILE).exists() {
        return Vec::new();
    }
    let data = fs::read_to_string(TASKS_FILE).unwrap();
    serde_json::from_str(&data).unwrap()
}

fn save_tasks(tasks: &[Task]) {
    let data = serde_json::to_string_pretty(tasks).unwrap();
    fs::write(TASKS_FILE, data).unwrap();
}

fn next_id(tasks: &[Task]) -> u32 {
    tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Add { description } => {
            let mut tasks = load_tasks();
            let id = next_id(&tasks);
            let task = Task {
                id,
                description: description.clone(),
                done: false,
            };
            tasks.push(task);
            save_tasks(&tasks);
            println!("Added task {}: \"{}\"", id, description);
        }
        Commands::List => {
            let tasks = load_tasks();
            if tasks.is_empty() {
                println!("No tasks yet. Add one with: taskr add \"Your task\"");
                return;
            }
            println!("\nYour tasks:");
            println!("{}", "-".repeat(40));
            for (i, task) in tasks.iter().enumerate() {
                let checkbox = if task.done { "[x]" } else { "[ ]" };
                println!("  {}. {} {}", i + 1, checkbox, task.description);
            }
            println!();
        }
        Commands::Done { position } => {
            let mut tasks = load_tasks();
            if position == 0 || position > tasks.len() {
                eprintln!("Error: invalid position {}. You have {} tasks.", position, tasks.len());
                std::process::exit(1);
            }
            tasks[position - 1].done = true;
            let desc = tasks[position - 1].description.clone();
            save_tasks(&tasks);
            println!("Marked as done: \"{}\"", desc);
        }
        Commands::Remove { position } => {
            let mut tasks = load_tasks();
            if position == 0 || position > tasks.len() {
                eprintln!("Error: invalid position {}. You have {} tasks.", position, tasks.len());
                std::process::exit(1);
            }
            let removed = tasks.remove(position - 1);
            save_tasks(&tasks);
            println!("Removed: \"{}\"", removed.description);
        }
    }
}

Your task manager is fully functional. Next up, you will refactor the code to use proper error handling with Result and the ? operator, replacing those .unwrap() calls that would panic on failure.

Add Error Handling with Result

Your task manager works, but it sits on a fragile foundation. Every .unwrap() call in your code is a ticking time bomb.

If your tasks.json file gets corrupted or a disk write fails, your program panics with an ugly stack trace instead of telling the user what went wrong. Rust's Result type gives you a structured way to handle errors gracefully.

In this step, get ready to:
  • Refactor file functions to return Result types.
  • Replace .unwrap() calls with the ? operator.
  • Add user-friendly error messages for invalid operations.
Refactor load_tasks and save_tasks to return Result

Right now, load_tasks() and save_tasks() call .unwrap() on every operation that could fail. If any of those operations encounter an error, your whole program crashes.

The Result type wraps a value in either Ok(value) on success or Err(error) on failure. The ? operator is shorthand that returns the error early if something fails, or unwraps the success value if it doesn't.

  • In src/main.rs, first add use std::process; to your imports at the top of the file. Find your existing import lines and add it below them:
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::fs;
use std::process;
  • Now find the load_tasks() function and replace it entirely with this version:
fn load_tasks() -> Result<Vec<Task>, Box<dyn std::error::Error>> {
    if !std::path::Path::new(TASKS_FILE).exists() {
        return Ok(Vec::new());
    }
    let data = fs::read_to_string(TASKS_FILE)?;
    let tasks: Vec<Task> = serde_json::from_str(&data)?;
    Ok(tasks)
}

What changed here?

The return type is now Result<Vec<Task>, Box<dyn std::error::Error>>. This tells Rust the function can either succeed with a Vec<Task> or fail with any error type.

The ? operator after fs::read_to_string() and serde_json::from_str() replaces .unwrap(). If the operation fails, ? immediately returns the error to the caller. If it succeeds, it unwraps the value. All success paths are wrapped in Ok().

  • Find the save_tasks() function directly below and replace it with this version:
fn save_tasks(tasks: &[Task]) -> Result<(), Box<dyn std::error::Error>> {
    let data = serde_json::to_string_pretty(tasks)?;
    fs::write(TASKS_FILE, data)?;
    Ok(())
}

What does this code do?

The return type Result<(), Box<dyn std::error::Error>> means this function either succeeds with nothing (the unit type ()) or fails with an error. The same ? pattern replaces both .unwrap() calls.

Notice &[Task] still borrows the data. Ownership hasn't changed. You've only changed how errors flow out of the function.

  • Save src/main.rs.

Your code won't compile yet. The main() function still calls these functions as if they return plain values. You'll fix that next.

Update main() to propagate errors with user-friendly messages

Now that your helper functions return Result, the main() function needs to handle those results. You'll make main() itself return a Result and use ? to propagate errors upward.

  • Replace your entire main() function with this refactored version:
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Add { description } => {
            // Load existing tasks, propagating any file/parse errors
            let mut tasks = load_tasks()?;
            let id = next_id(&tasks);
            let task = Task {
                id,
                description: description.clone(),
                done: false,
            };
            tasks.push(task);
            save_tasks(&tasks)?;
            println!("Added task {}: \"{}\"", id, description);
        }
        Commands::List => {
            let tasks = load_tasks()?;
            if tasks.is_empty() {
                println!("No tasks yet. Add one with: taskr add \"Your task\"");
                return Ok(());
            }
            println!("\nYour tasks:");
            println!("{}", "-".repeat(40));
            for (i, task) in tasks.iter().enumerate() {
                let checkbox = if task.done { "[x]" } else { "[ ]" };
                println!("  {}. {} {}", i + 1, checkbox, task.description);
            }
            println!();
        }
        Commands::Done { position } => {
            let mut tasks = load_tasks()?;
            // Validate position before indexing
            if position == 0 || position > tasks.len() {
                eprintln!("Error: invalid position {}. You have {} tasks.", position, tasks.len());
                process::exit(1);
            }
            tasks[position - 1].done = true;
            let desc = tasks[position - 1].description.clone();
            save_tasks(&tasks)?;
            println!("Marked as done: \"{}\"", desc);
        }
        Commands::Remove { position } => {
            let mut tasks = load_tasks()?;
            // Validate position before removing
            if position == 0 || position > tasks.len() {
                eprintln!("Error: invalid position {}. You have {} tasks.", position, tasks.len());
                process::exit(1);
            }
            let removed = tasks.remove(position - 1);
            save_tasks(&tasks)?;
            println!("Removed: \"{}\"", removed.description);
        }
    }

    Ok(())
}

What's happening here?

  • main() now returns Result<(), Box<dyn std::error::Error>>. If any ? operation fails, Rust prints the error and exits automatically.
  • Every load_tasks() and save_tasks() call now has ? appended. This replaces the old .unwrap() pattern.
  • For out-of-range positions in Done and Remove, eprintln! prints to stderr (not stdout) and process::exit(1) signals failure to the operating system.
  • The return Ok(()); in the empty-list case replaces the old bare return; because main() now expects a Result return value.
  • Ok(()) at the very end tells Rust that main() completed successfully.

Why eprintln! instead of println!?

Unix programs separate normal output (stdout) from error output (stderr). Using eprintln! ensures error messages go to stderr, so they won't get mixed into piped output if someone uses your tool in a script.

  • Save src/main.rs.
  • Verify your code compiles by running:
cargo run -- list

You should see your tasks from earlier, or the "No tasks yet" message if you deleted tasks.json.

  • Test the error handling for an out-of-range position by running:
cargo run -- done 999

You should see an error message like Error: invalid position 999. You have X tasks. printed to stderr, and the program exits with a non-zero code.

Seeing a compile error?

  • If you see "mismatched types", check that every load_tasks() call has a ? after it. Without ?, you're trying to use a Result where a Vec<Task> is expected.
  • If you see "the trait bound is not satisfied", make sure your main() return type is exactly Result<(), Box<dyn std::error::Error>>.
  • If process::exit is not found, make sure you have use std::process; in your imports at the top.

Help me debug my Result type errors in Rust

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.rs and your project compiles without warnings.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at this point:

use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::fs;
use std::process;

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

#[derive(Parser)]
#[command(name = "taskr")]
#[command(about = "A simple CLI task manager built in Rust")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add a new task
    Add {
        /// The task description
        description: String,
    },
    /// List all tasks
    List,
    /// Mark a task as done
    Done {
        /// The task position (from list)
        position: usize,
    },
    /// Remove a task
    Remove {
        /// The task position (from list)
        position: usize,
    },
}

#[derive(Serialize, Deserialize, Debug)]
struct Task {
    id: u32,
    description: String,
    done: bool,
}

fn load_tasks() -> Result<Vec<Task>, Box<dyn std::error::Error>> {
    if !std::path::Path::new(TASKS_FILE).exists() {
        return Ok(Vec::new());
    }
    let data = fs::read_to_string(TASKS_FILE)?;
    let tasks: Vec<Task> = serde_json::from_str(&data)?;
    Ok(tasks)
}

fn save_tasks(tasks: &[Task]) -> Result<(), Box<dyn std::error::Error>> {
    let data = serde_json::to_string_pretty(tasks)?;
    fs::write(TASKS_FILE, data)?;
    Ok(())
}

fn next_id(tasks: &[Task]) -> u32 {
    tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Add { description } => {
            let mut tasks = load_tasks()?;
            let id = next_id(&tasks);
            let task = Task {
                id,
                description: description.clone(),
                done: false,
            };
            tasks.push(task);
            save_tasks(&tasks)?;
            println!("Added task {}: \"{}\"", id, description);
        }
        Commands::List => {
            let tasks = load_tasks()?;
            if tasks.is_empty() {
                println!("No tasks yet. Add one with: taskr add \"Your task\"");
                return Ok(());
            }
            println!("\nYour tasks:");
            println!("{}", "-".repeat(40));
            for (i, task) in tasks.iter().enumerate() {
                let checkbox = if task.done { "[x]" } else { "[ ]" };
                println!("  {}. {} {}", i + 1, checkbox, task.description);
            }
            println!();
        }
        Commands::Done { position } => {
            let mut tasks = load_tasks()?;
            if position == 0 || position > tasks.len() {
                eprintln!("Error: invalid position {}. You have {} tasks.", position, tasks.len());
                process::exit(1);
            }
            tasks[position - 1].done = true;
            let desc = tasks[position - 1].description.clone();
            save_tasks(&tasks)?;
            println!("Marked as done: \"{}\"", desc);
        }
        Commands::Remove { position } => {
            let mut tasks = load_tasks()?;
            if position == 0 || position > tasks.len() {
                eprintln!("Error: invalid position {}. You have {} tasks.", position, tasks.len());
                process::exit(1);
            }
            let removed = tasks.remove(position - 1);
            save_tasks(&tasks)?;
            println!("Removed: \"{}\"", removed.description);
        }
    }

    Ok(())
}

Your task manager now handles errors like a proper Rust program. No more panics from corrupted files or failed writes. Next up, you'll take this tool further in the Secret Mission by adding priority levels with color-coded output.

Secret mission

Add Priority Levels with Color Output

Your task manager works, but every task looks the same. In a real workflow, some tasks are urgent while others can wait. Extend taskr with a priority system that color-codes your terminal output so high-priority items jump off the screen.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The taskr project folder on your Desktop.
  • The tasks.json file inside your project folder.
  • The Rust toolchain (rustc, cargo, rustup) installed on your system.

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend your task manager with more features.

  • Your taskr project stays on your Desktop, ready to compile and run anytime.
  • The Rust toolchain remains installed for future Rust projects.
  • Your tasks.json file keeps your saved tasks between sessions.

Pause - I'll come back to this later

Since this project is entirely local, there is nothing to pause. Your files stay on disk and take up minimal space until you return.

  • Close your code editor and terminal windows.
  • Your project folder and Rust toolchain remain installed and ready when you come back.

Delete - I don't want to use this again

Remove all project resources and the Rust toolchain to start fresh.

  • Delete the taskr project folder by running this command:

macOS

rm -rf ~/Desktop/taskr

Windows

Remove-Item -Recurse -Force ~\Desktop\taskr
  • Uninstall Rust and rustup by running:
rustup self uninstall

What does this remove?

This removes rustc, cargo, rustup, and all installed toolchains from ~/.cargo and ~/.rustup. If you want to use Rust again later, you can reinstall it with the same curl command from Step 1.

Nice Work!

Nice Work!

You're a Rustacean now. You built taskr, a fully functional CLI task manager in Rust that persists tasks to a JSON file and compiles to a single binary you can run from anywhere.

You've learned how to:

  • Built a working CLI tool using ownership and borrowing to pass data safely between functions, structs with derived traits for serialization, and enums with pattern matching to dispatch commands.
  • Implemented proper error handling with Result and the ? operator, replacing panics with user-friendly messages and non-zero exit codes.
  • Worked with external crates (clap, serde, serde_json) via Cargo to add CLI parsing, serialization, and JSON file I/O to your project.
  • Added priority levels with color-coded terminal output using enums and the colored crate.

Ready to quiz yourself?