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_cliOpen 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::newis an associated function that creates a new task withdone: 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 likeprintln!but returns aStringinstead 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.matchexhaustively 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 thatread_lineincludes..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::writeandstd::fs::read_to_stringto persist tasks between runs. - Command-line arguments — use
std::env::args()to accept tasks directly:cargo run -- add "Buy milk". - Timestamps — add a
created_atfield using a simple string or a crate likechrono. - Priority levels — add a
Priorityenum (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+breakfor an interactive REPL-style interface. - 🦀 Applying ownership, borrowing, and
matchin 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 withbevy.
Welcome to the Rust community! 🦀
Previous: Lesson 11: Traits and Generics | Course Index