What you will build

The todo manager will support these commands:

  • add "task text" to create a new todo item
  • list to display all tasks
  • done <id> to mark a task as completed

Tasks will be stored in a JSON file in the current directory, making the tool easy to run without a database or external service.

Why this project is useful

A todo manager is a good beginner project because it combines several core Rust concepts in a realistic setting:

  • Structs and enums for modeling data and commands
  • Serde for serialization and deserialization
  • Standard library file APIs for persistence
  • Result-based error handling for robust behavior
  • Simple CLI design that can grow over time

Project setup

Create a new binary project:

cargo new todo_manager
cd todo_manager

Add dependencies to Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

We’ll use serde to convert Rust data structures to and from JSON, and serde_json to read and write the file.


Design the data model

Start by defining a todo item and a container for the full task list.

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
struct TodoItem {
    id: u64,
    title: String,
    done: bool,
}

#[derive(Debug, Serialize, Deserialize, Default)]
struct TodoList {
    items: Vec<TodoItem>,
}

Why this structure works

  • TodoItem represents one task.
  • TodoList groups all tasks together, which makes file persistence simpler.
  • Serialize and Deserialize let us save and load the list as JSON.
  • Default gives us an empty list when no file exists yet.

Using a list wrapper instead of writing raw vectors directly gives you room to add metadata later, such as a version number or last-updated timestamp.


Parse command-line arguments

For a first version, we can use std::env::args() instead of a CLI framework. This keeps the example lightweight and makes the core logic easy to see.

use std::env;

enum Command {
    Add(String),
    List,
    Done(u64),
}

fn parse_args() -> Result<Command, String> {
    let mut args = env::args().skip(1);

    match args.next().as_deref() {
        Some("add") => {
            let title = args.collect::<Vec<_>>().join(" ");
            if title.is_empty() {
                Err("usage: todo_manager add <task text>".into())
            } else {
                Ok(Command::Add(title))
            }
        }
        Some("list") => Ok(Command::List),
        Some("done") => {
            let id = args
                .next()
                .ok_or("usage: todo_manager done <id>")?
                .parse::<u64>()
                .map_err(|_| "task id must be a number")?;
            Ok(Command::Done(id))
        }
        _ => Err("usage: todo_manager <add|list|done>".into()),
    }
}

Best practice: keep parsing separate

Notice that parse_args() only interprets input. It does not load files or modify data. This separation makes the code easier to test and maintain.


Load and save tasks

Next, add functions to read and write the JSON file. We’ll store it as todo.json in the current working directory.

use std::fs;
use std::io;
use std::path::Path;

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

impl TodoList {
    fn load() -> io::Result<Self> {
        if !Path::new(DATA_FILE).exists() {
            return Ok(Self::default());
        }

        let contents = fs::read_to_string(DATA_FILE)?;
        let list = serde_json::from_str(&contents).unwrap_or_default();
        Ok(list)
    }

    fn save(&self) -> io::Result<()> {
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        fs::write(DATA_FILE, json)
    }
}

Notes on error handling

This version uses io::Result for file operations because the main failure modes are I/O-related. The JSON parse fallback with unwrap_or_default() is convenient for a tutorial, but in production code you should usually surface malformed data as an error instead of silently resetting state.

A more defensive version would return a custom error if the JSON file is corrupted.


Implement task operations

Now add methods for creating, listing, and completing tasks.

impl TodoList {
    fn next_id(&self) -> u64 {
        self.items.iter().map(|item| item.id).max().unwrap_or(0) + 1
    }

    fn add(&mut self, title: String) {
        let item = TodoItem {
            id: self.next_id(),
            title,
            done: false,
        };
        self.items.push(item);
    }

    fn mark_done(&mut self, id: u64) -> bool {
        if let Some(item) = self.items.iter_mut().find(|item| item.id == id) {
            item.done = true;
            true
        } else {
            false
        }
    }

    fn print(&self) {
        if self.items.is_empty() {
            println!("No tasks found.");
            return;
        }

        for item in &self.items {
            let status = if item.done { "[x]" } else { "[ ]" };
            println!("{} {} {}", status, item.id, item.title);
        }
    }
}

Why next_id() scans the list

For a small local tool, generating the next ID by scanning existing items is simple and reliable. If the list grows large, you could store a separate counter in the file or use UUIDs. For a beginner project, this approach is perfectly reasonable.


Wire everything together in main

The main function loads the data, runs the selected command, and saves changes when needed.

use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let command = parse_args().map_err(|msg| {
        eprintln!("{msg}");
        std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
    })?;

    let mut list = TodoList::load()?;

    match command {
        Command::Add(title) => {
            list.add(title);
            list.save()?;
            println!("Task added.");
        }
        Command::List => {
            list.print();
        }
        Command::Done(id) => {
            if list.mark_done(id) {
                list.save()?;
                println!("Task marked as done.");
            } else {
                eprintln!("No task found with id {id}");
            }
        }
    }

    Ok(())
}

What this structure gives you

This layout keeps the program flow easy to follow:

  1. Parse the command
  2. Load persisted data
  3. Execute the requested action
  4. Save if the data changed

That sequence is a solid default for many CLI applications.


Try it out

Run the program from the terminal:

cargo run -- add "Review Rust ownership notes"
cargo run -- add "Refactor todo parser"
cargo run -- list
cargo run -- done 1
cargo run -- list

Example output:

Task added.
Task added.
[ ] 1 Review Rust ownership notes
[ ] 2 Refactor todo parser
Task marked as done.
[x] 1 Review Rust ownership notes
[ ] 2 Refactor todo parser

The todo.json file will be created automatically after the first write.


Common improvements for real projects

Once the basic version works, there are several practical enhancements you can make.

ImprovementBenefitNotes
Use clap for argument parsingBetter help text and validationUseful when commands grow beyond a few cases
Store data in a config directoryCleaner file placementPrefer OS-specific app data paths over the current directory
Return custom errorsBetter diagnosticsHelps distinguish I/O, parse, and validation failures
Add remove and edit commandsMore complete task managementKeep command handling in a dedicated module
Add tests for parsing and persistenceSafer refactoringEspecially valuable once behavior grows

When to switch to a CLI framework

Manual parsing is fine for learning and for tiny tools. If you expect subcommands, flags, defaults, and generated help output, a framework like clap will save time and reduce bugs. The point of this tutorial is to show the core mechanics first, not to avoid libraries forever.


Best practices to keep in mind

Prefer explicit state transitions

The done command changes a task from incomplete to complete. In more complex tools, model state changes clearly so you can reason about them and test them independently.

Avoid panics in user-facing code

This example uses Result for file operations and input validation. That is the right default for CLI tools because users should receive actionable error messages instead of a crash.

Keep persistence logic isolated

File loading and saving live in TodoList::load() and TodoList::save(). This makes it easier to replace JSON with another format later, or to move from a single file to a database.

Make output stable and readable

Simple, predictable terminal output is easier to use in scripts and easier to test. If you later add color or formatting, keep a plain-text mode available.


Extending the project safely

A good next step is to split the code into modules:

  • model.rs for TodoItem and TodoList
  • cli.rs for argument parsing
  • main.rs for orchestration

That separation becomes valuable once the file grows beyond a few dozen lines. You can also add unit tests for next_id(), mark_done(), and parse_args() to validate behavior without running the full program.

If you want to support multiple users or shared task lists, the design will need more than a local JSON file. At that point, you might introduce locking, a database, or a network service. The current architecture gives you a clean starting point for that evolution.


Summary

You built a small but practical Rust CLI application that demonstrates a realistic workflow:

  • model data with structs
  • parse commands from the terminal
  • persist state to JSON
  • handle errors with Result
  • keep logic organized for future growth

This is the kind of project that helps you move from language basics to production-style Rust code. The patterns here scale well to many other developer tools.

Learn more with useful resources