DEV Community

Cover image for Building a Simple CLI To-Do App in Rust Using Cargo
PEAKIQ
PEAKIQ

Posted on • Originally published at peakiq.in

Building a Simple CLI To-Do App in Rust Using Cargo

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
Enter fullscreen mode Exit fullscreen mode

Once installed, create a new project and open it:

cargo new rust_todo
cd rust_todo
Enter fullscreen mode Exit fullscreen mode

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.");
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Code Breakdown

Struct definition

struct Task {
    id: u32,
    name: String,
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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() });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
    }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)