PEAKIQ - Software Solutions & Digital Innovation Peakiq Software Development

Peakiq Blog

Building a Simple CLI To-Do App in Rust

Step-by-step guide to building a Rust CLI To-Do application using Cargo. Learn CRUD operations, struct design, and command-line interaction in Rust.

Editorial5 min read836 words
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

ConceptWhere it appeared
StructsTask definition
VectorsVec<Task> for storing tasks
Ownership and borrowing&tasks in the view loop
Pattern matchingmatch on menu choice and parse result
Error handlingResult from .parse()
In-place filteringretain 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