
Getting Started with Rust: Building a Command-Line Todo Manager
What you will build
The todo manager will support these commands:
add "task text"to create a new todo itemlistto display all tasksdone <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_managerAdd 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
TodoItemrepresents one task.TodoListgroups all tasks together, which makes file persistence simpler.SerializeandDeserializelet us save and load the list as JSON.Defaultgives 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:
- Parse the command
- Load persisted data
- Execute the requested action
- 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 -- listExample 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 parserThe 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.
| Improvement | Benefit | Notes |
|---|---|---|
Use clap for argument parsing | Better help text and validation | Useful when commands grow beyond a few cases |
| Store data in a config directory | Cleaner file placement | Prefer OS-specific app data paths over the current directory |
| Return custom errors | Better diagnostics | Helps distinguish I/O, parse, and validation failures |
Add remove and edit commands | More complete task management | Keep command handling in a dedicated module |
| Add tests for parsing and persistence | Safer refactoring | Especially 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.rsforTodoItemandTodoListcli.rsfor argument parsingmain.rsfor 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.
