Build a CLI App in Rust: Hands-On Capstone Project

Lesson 12: Building a Command-Line App

Introduction

Congratulations — you’ve made it to the final lesson! 🎉 Over the past 11 lessons you’ve learned Rust’s core concepts: variables, control flow, functions, ownership, structs, enums, collections, error handling, modules, and traits. Now it’s time to put it all together and build something real.

In this lesson you’ll build a command-line Todo list app step by step. By the end you’ll have a working Rust program you can run, extend, and be proud of.

Setting Up the Project

Start by creating a new Cargo project in your terminal:

# In your terminal:
cargo new todo_cli
cd todo_cli

Open src/main.rs — that’s where all your code will live. Cargo also creates a Cargo.toml for you:

[package]
name = "todo_cli"
version = "0.1.0"
edition = "2021"

[dependencies]

Step 1: The Todo Struct

First, define a Todo struct to represent a single task. Use #[derive(Debug)] so you can print it easily during development.

#[derive(Debug)]
struct Todo {
    id: u32,
    title: String,
    done: bool,
}

impl Todo {
    fn new(id: u32, title: &str) -> Self {
        Todo {
            id,
            title: String::from(title),
            done: false,
        }
    }
}

fn main() {
    let task = Todo::new(1, "Learn Rust");
    println!("{:?}", task);
}
  • id: a unique number for each task.
  • title: the task description.
  • done: whether the task is complete.
  • Todo::new is an associated function that creates a new task with done: false.

Step 2: Adding Methods

Now add methods to mark a task complete and to format it for display:

impl Todo {
    fn new(id: u32, title: &str) -> Self {
        Todo { id, title: String::from(title), done: false }
    }

    fn complete(&mut self) {
        self.done = true;
    }

    fn display(&self) -> String {
        let status = if self.done { "[x]" } else { "[ ]" };
        format!("{} {} - {}", status, self.id, self.title)
    }
}

fn main() {
    let mut task = Todo::new(1, "Learn Rust");
    println!("{}", task.display());
    task.complete();
    println!("{}", task.display());
}
  • complete(&mut self) takes a mutable reference — it modifies the struct in place.
  • display(&self) takes an immutable reference — it only reads the data.
  • format! works like println! but returns a String instead of printing.

Step 3: Managing Multiple Todos

Use a Vec to hold all your tasks and build helper functions to add, complete, and list them:

fn list_tasks(tasks: &[Todo]) {
    if tasks.is_empty() {
        println!("No tasks yet!");
    } else {
        for task in tasks {
            println!("{}", task.display());
        }
    }
}

fn find_and_complete(tasks: &mut [Todo], id: u32) {
    for task in tasks.iter_mut() {
        if task.id == id {
            task.complete();
            println!("Marked task {} as done.", id);
            return;
        }
    }
    println!("Task {} not found.", id);
}

fn main() {
    let mut tasks = Vec::new();
    let mut next_id = 1u32;

    tasks.push(Todo::new(next_id, "Learn Rust"));
    next_id += 1;
    tasks.push(Todo::new(next_id, "Write a trait"));
    next_id += 1;
    tasks.push(Todo::new(next_id, "Build a CLI app"));

    println!("=== Todo List ===");
    list_tasks(&tasks);

    find_and_complete(&mut tasks, 1);
    find_and_complete(&mut tasks, 2);

    println!("\n=== Updated List ===");
    list_tasks(&tasks);
}
  • Vec::new() creates an empty vector — Rust infers the element type from how you use it.
  • &[Todo] (a slice) lets functions read the list without owning it.
  • &mut [Todo] lets functions modify elements without owning the vector.
  • .iter_mut() gives mutable references to each element.

Step 4: Commands as an Enum

A real CLI app responds to different commands. Use an enum to represent each possible action:

#[derive(Debug)]
enum Command {
    Add(String),
    Complete(u32),
    List,
    Quit,
}

fn describe_command(cmd: &Command) -> String {
    match cmd {
        Command::Add(title) => format!("Add task: {}", title),
        Command::Complete(id) => format!("Complete task {}", id),
        Command::List => String::from("List all tasks"),
        Command::Quit => String::from("Quit the app"),
    }
}

fn main() {
    let commands = vec![
        Command::Add(String::from("Write tests")),
        Command::Complete(1),
        Command::List,
        Command::Quit,
    ];

    for cmd in &commands {
        println!("{}", describe_command(cmd));
    }
}
  • Command::Add(String) carries the task title as data.
  • Command::Complete(u32) carries the task ID to mark done.
  • match exhaustively handles every variant — Rust won’t compile if you miss one.

Step 5: Reading User Input

To make it interactive, use std::io to read lines from the terminal. Combine this with trim() and parse() to handle real input:

use std::io;
use std::io::Write;

fn read_line(prompt: &str) -> String {
    print!("{}", prompt);
    io::stdout().flush().unwrap();
    let mut input = String::new();
    io::stdin().read_line(&mut input).unwrap();
    String::from(input.trim())
}

fn main() {
    println!("=== Todo CLI ===");
    let task_name = read_line("Enter a task: ");
    println!("You entered: {}", task_name);
}
  • io::stdout().flush() ensures the prompt appears before the user types.
  • .trim() removes the trailing newline that read_line includes.
  • .unwrap() panics on error — fine for a small CLI app, but in production you’d handle errors properly.

Step 6: The Complete App

Here’s the full todo app — copy this into src/main.rs and run it with cargo run:

use std::io;
use std::io::Write;

#[derive(Debug)]
struct Todo {
    id: u32,
    title: String,
    done: bool,
}

impl Todo {
    fn new(id: u32, title: &str) -> Self {
        Todo { id, title: String::from(title), done: false }
    }
    fn complete(&mut self) { self.done = true; }
    fn display(&self) -> String {
        let s = if self.done { "[x]" } else { "[ ]" };
        format!("{} {} - {}", s, self.id, self.title)
    }
}

fn read_line(prompt: &str) -> String {
    print!("{}", prompt);
    io::stdout().flush().unwrap();
    let mut buf = String::new();
    io::stdin().read_line(&mut buf).unwrap();
    String::from(buf.trim())
}

fn main() {
    let mut tasks = Vec::new();
    let mut next_id = 1u32;
    println!("=== Todo CLI ===  (commands: add / done / list / quit)");
    loop {
        let input = read_line("> ");
        if input.starts_with("add ") {
            let title = &input[4..];
            tasks.push(Todo::new(next_id, title));
            next_id += 1;
            println!("Added!");
        } else if input.starts_with("done ") {
            let num_str = &input[5..];
            if let Ok(id) = num_str.parse() {
                let mut found = false;
                for t in tasks.iter_mut() {
                    if t.id == id { t.complete(); found = true; }
                }
                if found { println!("Done!"); } else { println!("Not found."); }
            }
        } else if input == "list" {
            if tasks.is_empty() { println!("No tasks yet!"); }
            for t in &tasks { println!("{}", t.display()); }
        } else if input == "quit" {
            println!("Goodbye!"); break;
        } else {
            println!("Unknown command.");
        }
    }
}

Run it with cargo run. Try typing add Learn Rust, then list, then done 1, then list again.

Try It Yourself

Practice in the Rust Playground — no local setup needed:

Task 1: Add priority levels to todos

// Task 1: Extend the Todo app with priority levels.
// Add a Priority enum with High, Medium, and Low variants.
// Update the Todo struct to include a priority field.
// Update display() to show the priority.

#[derive(Debug)]
enum Priority {
    // TODO: add High, Medium, Low variants
}

#[derive(Debug)]
struct Todo {
    id: u32,
    title: String,
    done: bool,
    // TODO: add a priority field of type Priority
}

impl Todo {
    fn new(id: u32, title: &str, priority: Priority) -> Self {
        Todo {
            id,
            title: String::from(title),
            done: false,
            // TODO: store priority
        }
    }

    fn display(&self) -> String {
        let status = if self.done { "[x]" } else { "[ ]" };
        // TODO: include the priority in the output
        format!("{} {} - {}", status, self.id, self.title)
    }
}

fn main() {
    let mut tasks = Vec::new();
    tasks.push(Todo::new(1, "Fix critical bug", Priority::High));
    tasks.push(Todo::new(2, "Write docs", Priority::Medium));
    tasks.push(Todo::new(3, "Refactor tests", Priority::Low));

    for task in &tasks {
        println!("{}", task.display());
    }
}

▶ Task 1: Try yourself: Add priority levels to todos

Task 2: Filter todos by status

// Task 2: Add filtering to the Todo app.
// Implement two functions:
//   pending_tasks(tasks: &[Todo]) -> Vec<&Todo>
//   completed_tasks(tasks: &[Todo]) -> Vec<&Todo>
// Each returns only the todos matching that status.

#[derive(Debug)]
struct Todo {
    id: u32,
    title: String,
    done: bool,
}

impl Todo {
    fn new(id: u32, title: &str) -> Self {
        Todo { id, title: String::from(title), done: false }
    }
    fn complete(&mut self) { self.done = true; }
    fn display(&self) -> String {
        let s = if self.done { "[x]" } else { "[ ]" };
        format!("{} {} - {}", s, self.id, self.title)
    }
}

// TODO: implement pending_tasks — returns todos where done == false
fn pending_tasks(tasks: &[Todo]) -> Vec<&Todo> {
    todo!()
}

// TODO: implement completed_tasks — returns todos where done == true
fn completed_tasks(tasks: &[Todo]) -> Vec<&Todo> {
    todo!()
}

fn main() {
    let mut tasks = Vec::new();
    tasks.push(Todo::new(1, "Learn Rust"));
    tasks.push(Todo::new(2, "Write a trait"));
    tasks.push(Todo::new(3, "Build a CLI app"));
    tasks[0].complete();
    tasks[2].complete();

    println!("=== Pending ===");
    for t in pending_tasks(&tasks) { println!("{}", t.display()); }

    println!("=== Completed ===");
    for t in completed_tasks(&tasks) { println!("{}", t.display()); }
}

▶ Task 2: Try yourself: Filter todos by status

▶ Mini-challenge: Try yourself: Add a count command

// Mini-challenge: Add a "count" command to the Todo CLI.
// When the user types "count", print:
//   Total: N  Pending: P  Done: D
// where N, P, D are the counts.

use std::io;
use std::io::Write;

#[derive(Debug)]
struct Todo {
    id: u32,
    title: String,
    done: bool,
}

impl Todo {
    fn new(id: u32, title: &str) -> Self {
        Todo { id, title: String::from(title), done: false }
    }
    fn complete(&mut self) { self.done = true; }
    fn display(&self) -> String {
        let s = if self.done { "[x]" } else { "[ ]" };
        format!("{} {} - {}", s, self.id, self.title)
    }
}

fn read_line(prompt: &str) -> String {
    print!("{}", prompt);
    io::stdout().flush().unwrap();
    let mut buf = String::new();
    io::stdin().read_line(&mut buf).unwrap();
    String::from(buf.trim())
}

fn main() {
    let mut tasks = Vec::new();
    let mut next_id = 1u32;
    println!("=== Todo CLI ===  (commands: add / done / list / count / quit)");
    loop {
        let input = read_line("> ");
        if input.starts_with("add ") {
            tasks.push(Todo::new(next_id, &input[4..]));
            next_id += 1;
            println!("Added!");
        } else if input.starts_with("done ") {
            let num_str = &input[5..];
            if let Ok(id) = num_str.parse() {
                for t in tasks.iter_mut() {
                    if t.id == id { t.complete(); }
                }
                println!("Done!");
            }
        } else if input == "list" {
            for t in &tasks { println!("{}", t.display()); }
        } else if input == "count" {
            // TODO: print "Total: N  Pending: P  Done: D"
            println!("count not implemented yet");
        } else if input == "quit" {
            println!("Goodbye!"); break;
        } else {
            println!("Unknown command.");
        }
    }
}

Going Further

You’ve built a working CLI app — here are some ideas to extend it:

  • Save to a file — use std::fs::write and std::fs::read_to_string to persist tasks between runs.
  • Command-line arguments — use std::env::args() to accept tasks directly: cargo run -- add "Buy milk".
  • Timestamps — add a created_at field using a simple string or a crate like chrono.
  • Priority levels — add a Priority enum (High, Medium, Low) and sort tasks by priority.
  • Explore clap — the most popular Rust crate for building robust CLI apps.

What You’ve Learned

  • 🦀 How to plan and build a real Rust project from scratch.
  • 🦀 Combining structs, enums, and impl blocks in a complete app.
  • 🦀 Reading user input with std::io.
  • 🦀 Using loop + break for an interactive REPL-style interface.
  • 🦀 Applying ownership, borrowing, and match in a real context.
  • 🦀 Running and testing your app with cargo run.

🎉 Course Complete!

You’ve completed Rust Essentials! You now know the core concepts that make Rust unique and powerful. From here, keep building — every project you write deepens your understanding.

Some next steps to consider:

  • The Rust Book — the official, free, comprehensive reference.
  • Rustlings — small exercises to sharpen your skills.
  • Rust by Practice — hands-on coding challenges.
  • Build something you care about — a CLI tool, a web server with axum, or a game with bevy.

Welcome to the Rust community! 🦀

Previous: Lesson 11: Traits and Generics | Course Index

Leave a Comment

Your email address will not be published. Required fields are marked *