Guided Project 1: Command-Line Todo Application
In this guided project, you’ll build a functional command-line todo application. This project will reinforce many concepts you’ve learned, including:
- Structs and Enums for data modeling.
- Ownership and borrowing.
- Error handling with
ResultandOption. - File I/O for persistent data storage.
- Basic command-line argument parsing.
Our todo application will allow users to:
- Add a new todo item.
- Mark an item as completed.
- List all todo items (showing completed status).
We’ll store todo items in a simple JSON file.
Project Setup
Create a new binary project:
cargo new rust_todo_cli cd rust_todo_cliAdd dependencies: We’ll use
serdefor serialization/deserialization to JSON andserde_jsonas the JSON backend.# Cargo.toml [package] name = "rust_todo_cli" version = "0.1.0" edition = "2024" [dependencies] serde = { version = "1.0", features = ["derive"] } # For serializing/deserializing data serde_json = "1.0" # For JSON formattingAlternatively, use
cargo add serde --features deriveandcargo add serde_json.
Step-by-Step Implementation
Step 1: Data Structures for Todo Items
We need a way to represent a single todo item and a collection of todo items.
src/main.rs:
use serde::{Deserialize, Serialize}; // Import traits for (de)serialization
use std::{collections::HashMap, fs, io::Result, path::PathBuf}; // Import necessary modules
#[derive(Debug, Serialize, Deserialize)] // Derive traits for easy serialization/deserialization
struct Task {
text: String,
completed: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct TodoList {
tasks: HashMap<u32, Task>, // Use HashMap for easy lookup by ID
next_id: u32,
}
impl TodoList {
// Associated function to create a new, empty TodoList
fn new() -> TodoList {
TodoList {
tasks: HashMap::new(),
next_id: 1, // Start IDs from 1
}
}
// Method to add a new task
fn add_task(&mut self, text: String) -> u32 {
let id = self.next_id;
self.tasks.insert(id, Task { text, completed: false });
self.next_id += 1;
id
}
// Method to mark a task as completed
fn complete_task(&mut self, id: u32) -> bool {
if let Some(task) = self.tasks.get_mut(&id) {
task.completed = true;
true
} else {
false // Task not found
}
}
// Method to list all tasks
fn list_tasks(&self) {
if self.tasks.is_empty() {
println!("No tasks in the list.");
return;
}
for (id, task) in &self.tasks {
let status = if task.completed { "[x]" } else { "[ ]" };
println!("{}: {} {}", id, status, task.text);
}
}
}
fn main() {
// For now, let's just test our data structures
let mut todo_list = TodoList::new();
let id1 = todo_list.add_task(String::from("Learn Rust ownership"));
let id2 = todo_list.add_task(String::from("Build a CLI todo app"));
todo_list.add_task(String::from("Read more documentation"));
println!("Initial tasks:");
todo_list.list_tasks();
todo_list.complete_task(id1);
println!("\nAfter completing task {}:", id1);
todo_list.list_tasks();
todo_list.complete_task(100); // Try to complete a non-existent task
println!("\nAfter attempting to complete non-existent task:");
todo_list.list_tasks();
}
Explanation:
#[derive(Debug, Serialize, Deserialize)]: These attributes fromserdeallow our structs to be automatically converted to/from JSON.Debughelps withprintln!("{:?}").Task: Holds the task’s description (text) and its completion status (completed).TodoList: Contains aHashMapmapping uniqueu32IDs toTaskstructs, andnext_idto generate new unique IDs.- Methods are defined to manage tasks.
Run this step: cargo run and observe the output.
Step 2: Persistence - Loading and Saving to File
Now, let’s make our todo list persistent by saving it to and loading it from a JSON file.
Modify src/main.rs:
// ... (previous use statements and structs) ...
const TODO_FILE: &str = "todo.json"; // Define the file name
impl TodoList {
// ... (previous methods: new, add_task, complete_task, list_tasks) ...
// Associated function to load TodoList from file
fn load() -> Result<TodoList> {
let path = PathBuf::from(TODO_FILE);
if path.exists() {
let json_string = fs::read_to_string(&path)?;
let todo_list: TodoList = serde_json::from_str(&json_string)?;
Ok(todo_list)
} else {
// If file doesn't exist, return a new, empty TodoList
Ok(TodoList::new())
}
}
// Method to save TodoList to file
fn save(&self) -> Result<()> {
let json_string = serde_json::to_string_pretty(&self)?; // pretty-print for readability
fs::write(TODO_FILE, json_string)?;
Ok(())
}
}
fn main() -> Result<()> { // Main now returns a Result to propagate I/O errors
let mut todo_list = TodoList::load()?; // Load from file, or create new if not found
// For initial testing, let's add/complete tasks here
// In final app, this will be handled by CLI arguments
let id1 = todo_list.add_task(String::from("Learn Rust ownership"));
let id2 = todo_list.add_task(String::from("Build a CLI todo app"));
todo_list.add_task(String::from("Read more documentation"));
println!("Initial tasks:");
todo_list.list_tasks();
todo_list.complete_task(id1);
println!("\nAfter completing task {}:", id1);
todo_list.list_tasks();
todo_list.complete_task(100);
println!("\nAfter attempting to complete non-existent task:");
todo_list.list_tasks();
todo_list.save()?; // Save the current state to file
// Demonstrate loading after saving:
println!("\n--- Re-loading from file to verify persistence ---");
let reloaded_list = TodoList::load()?;
reloaded_list.list_tasks();
Ok(())
}
Explanation:
TODO_FILE: A constant for our JSON file name.load(): Reads the file, deserializes JSON intoTodoList. HandlesNotFounderror by returning a newTodoList.save(): SerializesTodoListto JSON and writes it to the file.main()now returnsResult<()>, allowing?to propagateio::Errorfrom file operations.
Run this step: cargo run. After running, check your project directory for todo.json. Open it to see the structured JSON data. Run cargo run again, and you should see the same tasks, demonstrating persistence.
Step 3: Command-Line Argument Parsing
Now, we’ll allow users to interact with the app using command-line arguments. We’ll support:
add <TASK_DESCRIPTION>: Adds a new task.complete <ID>: Marks a task as completed.list: Lists all tasks.
Modify src/main.rs:
// ... (previous use statements, structs, and TodoList impl) ...
// Enum to represent command-line actions
enum Action {
Add(String),
Complete(u32),
List,
Help,
}
fn parse_args() -> Action {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
return Action::Help;
}
let command = &args[1];
match command.as_str() {
"add" => {
if args.len() < 3 {
println!("Usage: {} add <TASK_DESCRIPTION>", args[0]);
Action::Help
} else {
let task_text = args[2..].join(" "); // Join remaining args as task description
Action::Add(task_text)
}
}
"complete" => {
if args.len() < 3 {
println!("Usage: {} complete <TASK_ID>", args[0]);
Action::Help
} else {
match args[2].parse::<u32>() {
Ok(id) => Action::Complete(id),
Err(_) => {
eprintln!("Error: Invalid task ID provided.");
Action::Help
}
}
}
}
"list" => Action::List,
_ => {
eprintln!("Unknown command: {}", command);
Action::Help
}
}
}
fn print_help(program_name: &str) {
println!("Usage: {} <command>", program_name);
println!("");
println!("Commands:");
println!(" add <TASK_DESCRIPTION> Adds a new task to the list.");
println!(" complete <ID> Marks a task as completed.");
println!(" list Lists all tasks.");
println!(" help Displays this help message.");
}
fn main() -> Result<()> {
let mut todo_list = TodoList::load()?; // Load existing tasks
let action = parse_args();
let program_name = std::env::args().next().unwrap_or_else(|| String::from("rust_todo_cli"));
match action {
Action::Add(text) => {
let id = todo_list.add_task(text);
println!("Added task with ID: {}", id);
}
Action::Complete(id) => {
if todo_list.complete_task(id) {
println!("Task {} marked as completed.", id);
} else {
eprintln!("Error: Task {} not found.", id);
}
}
Action::List => {
todo_list.list_tasks();
}
Action::Help => {
print_help(&program_name);
}
}
todo_list.save()?; // Save changes after command execution
Ok(())
}
Explanation:
Actionenum: Represents the different commands our CLI app can handle.parse_args(): Reads command-line arguments usingstd::env::args(), then uses amatchstatement to determine the action and extract any necessary data.- Error handling for
parse::<u32>()is included. main(): Now callsparse_args()to get the action, then executes the corresponding logic.args[2..].join(" "): A neat trick to collect all words after the “add” command into a single task description string.
Run this step:
cargo run -- add "Buy groceries"cargo run -- add "Call Mom"cargo run -- listcargo run -- complete 1(or whatever ID was assigned to “Buy groceries”)cargo run -- listcargo run -- helpcargo run -- invalid_command
You should see your todo list updating and persisting across runs!
Step 4 (Optional): More Robust Argument Parsing with clap
For more complex CLIs, the clap crate is highly recommended. It handles parsing, validation, and generates help messages automatically.
Modify Cargo.toml:
# Cargo.toml
[dependencies]
# ... (existing dependencies) ...
clap = { version = "4.0", features = ["derive"] } # For declarative argument parsing
Modify src/main.rs:
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, io, path::PathBuf}; // Add `io` to Result
use clap::{Parser, Subcommand}; // Import from clap
#[derive(Debug, Serialize, Deserialize)]
struct Task {
text: String,
completed: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct TodoList {
tasks: HashMap<u32, Task>,
next_id: u32,
}
impl TodoList {
// ... (same TodoList methods: new, add_task, complete_task, list_tasks, load, save) ...
// Make sure `load` and `save` return `io::Result<TodoList>` and `io::Result<()>` respectively
fn load() -> io::Result<TodoList> {
let path = PathBuf::from(TODO_FILE);
if path.exists() {
let json_string = fs::read_to_string(&path)?;
let todo_list: TodoList = serde_json::from_str(&json_string)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; // Convert serde_json error to io::Error
Ok(todo_list)
} else {
Ok(TodoList::new())
}
}
fn save(&self) -> io::Result<()> {
let json_string = serde_json::to_string_pretty(&self)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; // Convert serde_json error
fs::write(TODO_FILE, json_string)?;
Ok(())
}
}
const TODO_FILE: &str = "todo.json";
// Use Clap for argument parsing
#[derive(Parser, Debug)]
#[command(author, version, about = "A simple Rust CLI todo application", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Add a new task to the todo list
Add {
#[arg(help = "The description of the task to add.")]
text: Vec<String>, // Vec<String> to capture multiple words
},
/// Mark a task as completed
Complete {
#[arg(help = "The ID of the task to complete.")]
id: u32,
},
/// List all tasks in the todo list
List,
}
fn main() -> io::Result<()> {
let mut todo_list = TodoList::load()?;
let cli = Cli::parse(); // Parse command-line arguments
match cli.command {
Commands::Add { text } => {
let full_text = text.join(" ");
let id = todo_list.add_task(full_text);
println!("Added task with ID: {}", id);
}
Commands::Complete { id } => {
if todo_list.complete_task(id) {
println!("Task {} marked as completed.", id);
} else {
eprintln!("Error: Task {} not found.", id);
}
}
Commands::List => {
todo_list.list_tasks();
}
}
todo_list.save()?;
Ok(())
}
Explanation:
#[derive(Parser)]: The main struct for your CLI application.#[command(subcommand)]: Indicates thatCommandsenum defines subcommands.#[derive(Subcommand)]: Enum for different commands. Clap automatically derives argument parsing based on the struct/enum fields and doc comments.#[arg(help = "...")]: Provides help text for arguments.Vec<String>forAdd’stextfield automatically collects all remaining arguments.
Run this step: cargo run -- add "Learn more about Rust CLIs"
cargo run -- complete 1
cargo run -- list
cargo run -- help
cargo run -- add (observe the auto-generated error)
This project provides a solid foundation for building more complex command-line tools in Rust.