Originally published on PEAKIQ
Source: https://www.peakiq.in/blog/building-a-simple-cli-to-do-app-in-rust
Getting Started
Ensure you have Rust installed. If not, install it via Rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Once installed, create a new project and open it:
cargo new rust_todo
cd rust_todo
Open src/main.rs — that's where all the code goes.
The Full Implementation
use std::io::stdin;
struct Task {
id: u32,
name: String,
}
fn main() {
let mut tasks: Vec<Task> = Vec::new();
loop {
println!("\nSimple CRUD App");
println!("1. Add Task");
println!("2. View Tasks");
println!("3. Delete Task");
println!("4. Exit");
println!("Enter your choice: ");
let mut choice = String::new();
stdin().read_line(&mut choice).unwrap();
let choice: u32 = match choice.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Invalid input. Please enter a number.");
continue;
}
};
match choice {
1 => {
println!("Enter task name: ");
let mut name = String::new();
stdin().read_line(&mut name).unwrap();
let id = tasks.len() as u32 + 1;
tasks.push(Task { id, name: name.trim().to_string() });
println!("Task added successfully!");
}
2 => {
if tasks.is_empty() {
println!("No tasks yet.");
} else {
println!("Tasks:");
for task in &tasks {
println!(" ID: {} Name: {}", task.id, task.name);
}
}
}
3 => {
println!("Enter task ID to delete: ");
let mut id = String::new();
stdin().read_line(&mut id).unwrap();
let id: u32 = match id.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Invalid ID. Please enter a valid number.");
continue;
}
};
let before = tasks.len();
tasks.retain(|task| task.id != id);
if tasks.len() < before {
println!("Task deleted successfully!");
} else {
println!("No task found with that ID.");
}
}
4 => {
println!("Exiting...");
break;
}
_ => {
println!("Invalid choice. Please enter 1, 2, 3, or 4.");
}
}
}
}
Code Breakdown
Struct definition
struct Task {
id: u32,
name: String,
}
A Task holds a numeric id and a String name. Rust structs are value types — no hidden allocation or runtime overhead.
Handling user input
stdin().read_line(&mut variable).unwrap();
read_line appends input (including the newline) into the mutable string. Calling .trim() afterward strips the trailing newline before parsing or storing.
Adding tasks
let id = tasks.len() as u32 + 1;
tasks.push(Task { id, name: name.trim().to_string() });
The ID is derived from the current vector length, so it increments automatically. to_string() converts the &str slice into an owned String that the struct can hold.
Viewing tasks
for task in &tasks {
println!(" ID: {} Name: {}", task.id, task.name);
}
Iterating with &tasks borrows the vector immutably — no ownership is moved, so the data remains usable afterward.
Deleting tasks
tasks.retain(|task| task.id != id);
retain keeps only the elements for which the closure returns true. It's the idiomatic Rust way to filter a vector in place without allocating a new one.
Handling invalid input
let choice: u32 = match choice.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Invalid input. Please enter a number.");
continue;
}
};
parse() returns a Result. Matching on it explicitly forces you to handle the error case — there's no silent failure.
Running the App
cargo run
Cargo compiles and runs in one step. Try adding a few tasks, viewing them, and deleting by ID.
What You Learned
| Concept | Where it appeared |
|---|---|
| Structs |
Task definition |
| Vectors |
Vec<Task> for storing tasks |
| Ownership and borrowing |
&tasks in the view loop |
| Pattern matching |
match on menu choice and parse result |
| Error handling |
Result from .parse()
|
| In-place filtering |
retain for deletion |
What to Build Next
This app stores tasks in memory only — they disappear when the process exits. Some natural next steps:
* File persistence — serialize tasks to a JSON or CSV file using serde_json
* Update support — add a menu option to rename a task by ID
* Better IDs — use a UUID crate instead of a sequential counter so IDs stay stable after deletion
Top comments (0)