Build a Contact Book CLI in Rust
Learn Rust ownership and borrowing by building a CLI contact book app.
Introduction
30 Second Summary
Think about how your phone's contacts app stores names, numbers, and emails. Every time you add or delete a contact, the app needs to manage that data safely without accidentally losing or duplicating it.
In this project, you will build a command-line contact book application in Rust. Along the way, you'll learn Rust's ownership system, the language feature that prevents memory bugs at compile time without needing a garbage collector.
What You'll Build
A CLI contact book that lets you add, list, search, and delete contacts through an interactive menu, with each operation designed to teach a different aspect of Rust's ownership model.
By the end of this project, you'll have:
- A complete Rust binary project built with Cargo.
- Functions that demonstrate ownership transfer, shared borrowing, and mutable borrowing.
- An interactive terminal loop with pattern matching for user commands.
- Secret Mission: Add an edit feature that juggles mutable and immutable borrows in the same function.
Are there any prerequisites?
You need basic programming experience in any language. If you understand variables, functions, and loops, you're ready to start.
Set Up Rust and Create Your Project
Every Rust project is managed by Cargo, Rust's built-in package manager and build system. Cargo handles compiling your code, managing dependencies, and running your program in a single tool.
In this step, you'll confirm your Rust installation is working and scaffold a brand new Cargo project for your contact book.
In this step, get ready to:
- Verify Rust and Cargo are available on your machine.
- Scaffold a new Cargo project for your contact book.
- Run the project to confirm it compiles and executes successfully.
Verify your Rust installation
With Rust already installed, let's confirm the toolchain is working correctly before creating your project.
- Confirm the Rust compiler is available by running this command:
rustc --version
- You should see a version number like 1.96.0 or later printed in your terminal.
- Confirm Cargo is also available by running this command:
cargo --version
- You should see a version number printed, confirming Cargo is ready.
Command not found?
If your terminal does not recognize rustc or cargo, your PATH may not have updated yet. Try restarting your terminal completely (close and reopen it), or run:
source $HOME/.cargo/env
Then try the version commands again.
Ask for help: Why isn't my terminal finding rustc or cargo after installing Rust?
Create your Cargo project
Cargo can scaffold an entire project structure for you with a single command. This gives you a ready-to-compile starting point with all the configuration Rust needs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your new Cargo project by running this command:
cargo new contact-book
- You should see a message that says Creating binary (application) `contact-book` package.
- Confirm the project folder was created by running:
ls
- You should see contact-book listed in the output.
What did cargo new create?
Cargo scaffolded a complete project structure inside the contact-book folder:
- Cargo.toml is your project's configuration file. It stores the project name, version, and any dependencies you add later.
- src/main.rs is where your Rust code lives. It starts with a simple "Hello, world!" program.
- Move into your project and run it by running these commands:
cd contact-book
cargo run
- You should see Cargo compile your project and then print Hello, world! in your terminal.
What does cargo run do?
cargo run does two things in one step: it compiles your Rust source code into a binary, then immediately runs that binary. You'll see a "Compiling" message followed by your program's output.
Not seeing Hello, world?
Make sure you ran cd contact-book before cargo run. Cargo needs to be inside a project directory to find Cargo.toml.
If you see an error about a missing manifest, run pwd to check your current directory. It should end with /contact-book.
Ask for help: Why is cargo run failing in my contact-book project?
✔️ Awesome, I've got everything!
Great. Double check you saw Hello, world! printed, then you're good to move on.
ⓧ I'd like to double check the full code
Here's what your src/main.rs should look like. This is the default code generated by cargo new.
fn main() {
println!("Hello, world!");
}
Your Rust project is ready to go. Next up, you'll define the Contact struct and discover what happens when ownership of data moves into it.
Define Your Contact Struct and Understand Ownership
Your Rust project compiles and runs, which means the toolchain is working. Now it is time to build the core data structure for your contact book.
In Rust, data structures called structs group related fields together. The key difference from other languages is that a Rust struct owns its data. When a struct goes out of scope, Rust automatically frees the memory for all the data it owns.
In this step, get ready to:
- Define a Contact struct that owns its data.
- Encounter your first ownership error and understand what it protects against.
- Compile and run your struct-based code successfully.
Create the Contact struct
Your contact book needs a way to represent a single contact. In Rust, a struct with String fields owns that data on the heap. The struct is responsible for the memory, and Rust cleans it up automatically when the struct is no longer needed.
- Replace the entire contents of src/main.rs with the following code:
use std::io;
// Define a struct that owns its String data
struct Contact {
name: String,
phone: String,
email: String,
}
// Implement a constructor function for Contact
impl Contact {
fn new(name: String, phone: String, email: String) -> Contact {
Contact { name, phone, email }
}
}
fn main() {
}
What does this code do?
The Contact struct has three fields, each of type String. String in Rust is an owned, heap-allocated string. The struct takes ownership of all three strings when it is created.
The impl block adds a new function that takes three String arguments and returns a Contact. The use std::io; line imports the I/O module you will need in a later step.
- Save src/main.rs.
- Confirm it compiles by running:
cargo build
You should see a "Compiling contact-book" message with no errors.
Seeing a compile error?
Check that the struct fields use String (capital S), not string or str. Rust is case-sensitive and String is a specific type.
Make sure the curly braces in the impl block are balanced and the fn new function is inside impl Contact { }.
help me fix a compile error in my Contact struct
Experience your first move error
Rust's ownership system has one core rule: each value has exactly one owner. When you pass a value to a function, ownership moves and the original variable becomes invalid. This prevents an entire class of memory bugs at compile time.
- Replace the empty fn main() {} block with this code that intentionally causes a move error:
fn main() {
// Create a String variable
let name = String::from("Alice");
// Pass 'name' into Contact::new, which MOVES ownership
let contact = Contact::new(
name,
String::from("555-1234"),
String::from("alice@example.com"),
);
// Try to use 'name' again after it was moved
println!("Name was: {}", name);
}
- Save src/main.rs and attempt a build by running:
cargo build
The build fails. You should see a compiler error about a moved value. This is Rust telling you that name was already moved into the Contact and cannot be used again.
What is the compiler protecting you from?
When you passed name into Contact::new(), ownership of that String transferred to the Contact struct. The original name variable no longer points to valid data.
In C or C++, using that variable after the move would be a use-after-free bug that causes crashes or security vulnerabilities. Rust catches this at compile time so the bug can never reach production.
Not seeing the error?
If the code compiles without an error, check that you have the println!("Name was: {}", name); line after the Contact::new() call. The error only appears when you try to use name after it has been moved.
help me trigger the Rust ownership move error
Fix the error and run successfully
The fix is straightforward. Instead of reusing the moved variable, access the data through the struct that now owns it. The Contact struct owns the name, so you read it via contact.name.
- Replace the entire fn main() function with this corrected version:
fn main() {
// Create a contact by passing owned Strings directly
let contact = Contact::new(
String::from("Alice"),
String::from("555-1234"),
String::from("alice@example.com"),
);
// Access data through the struct that owns it
println!("{} | {} | {}", contact.name, contact.phone, contact.email);
}
Why does this fix work?
Instead of creating a name variable and then moving it, you pass String::from("Alice") directly into the constructor. The Contact struct takes ownership immediately, and you access the data later through contact.name.
This is the core mental model: once data moves into a struct, you talk to the struct to get it back. No dangling references, no double frees, no data races.
- Save src/main.rs and run your project:
cargo run
You should see the output: Alice | 555-1234 | alice@example.com.
Seeing a different error now?
Make sure you replaced the entire fn main() { ... } block, including removing the old println! line that referenced the standalone name variable. The fixed version should have no reference to a standalone name variable.
help me fix my Contact struct code so it compiles
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs and that cargo run prints the contact details.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at the end of this step:
use std::io;
struct Contact {
name: String,
phone: String,
email: String,
}
impl Contact {
fn new(name: String, phone: String, email: String) -> Contact {
Contact { name, phone, email }
}
}
fn main() {
let contact = Contact::new(
String::from("Alice"),
String::from("555-1234"),
String::from("alice@example.com"),
);
println!("{} | {} | {}", contact.name, contact.phone, contact.email);
}
You have defined your first struct and experienced Rust's ownership rules first-hand. Next up, you will build functions that borrow the contact list instead of owning it.
Build the Contact List with Borrowing
Your Contact struct owns its data. Now you need functions that can read and modify a list of contacts without taking ownership of the entire list.
This is where borrowing comes in. Instead of handing ownership to a function, you lend it a reference. In this step, you'll write functions that borrow the contact list in two ways: mutably (to add contacts) and immutably (to display them).
In this step, get ready to:
- Write functions that borrow the contact list mutably and immutably.
- Trigger a borrow-checker error and understand what the compiler prevents.
- Test the full add-and-list workflow with multiple contacts.
Create functions that borrow the contact list
You need three new functions: a helper that reads user input from the terminal, a function that adds a contact to the list, and a function that prints all contacts.
- In src/main.rs, find the fn main() function.
- Add the following read_input helper function directly above fn main():
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
io::Write::flush(&mut io::stdout()).unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
What does this code do?
This helper prints a prompt, flushes stdout (so the prompt appears before waiting for input), reads a line from the terminal, and trims the newline character off the end.
Notice the parameter type &str. The & means this function borrows the string slice rather than taking ownership of it. The caller keeps its data.
- Save src/main.rs.
- Confirm it compiles by running:
cargo build
- You should see a successful compilation. You may also see a warning about an unused function. That is expected.
Seeing a compile error?
Make sure the use std::io; import is still at the top of your file. The read_input function depends on it for io::Write and io::stdin().
Help me fix a compile error in my read_input function
Now add the add_contact function. This is where mutable borrowing comes in.
- Add the following function above read_input, below the closing } of the impl Contact block:
fn add_contact(contacts: &mut Vec<Contact>) {
let name = read_input("Enter name: ");
let phone = read_input("Enter phone: ");
let email = read_input("Enter email: ");
let contact = Contact::new(name, phone, email);
contacts.push(contact);
println!("Contact added!");
}
What does this code do?
The key detail is the parameter type: &mut Vec<Contact>. This is a mutable borrow of the vector. It gives add_contact exclusive write access to the vector so it can push a new contact.
The function does not own the vector. When it finishes, the caller still has its vector. The &mut just means "I am borrowing this with permission to modify it."
- Save src/main.rs and run:
cargo build
- You should see a successful compilation with unused-function warnings. No errors.
Getting an error about read_input not found?
Make sure read_input is defined somewhere in the same file. In Rust, function order does not matter within a file, but both functions must exist. Check that you did not accidentally nest add_contact inside another function.
Help me fix a compile error in my add_contact function
Now add the list_contacts function. This one borrows the vector immutably because it only needs to read.
- Add the following function between add_contact and read_input:
fn list_contacts(contacts: &Vec<Contact>) {
if contacts.is_empty() {
println!("No contacts yet.");
return;
}
println!("\n--- Contacts ---");
for (i, contact) in contacts.iter().enumerate() {
println!(
"{}. {} | {} | {}",
i + 1,
contact.name,
contact.phone,
contact.email
);
}
println!("----------------\n");
}
What does this code do?
This function takes &Vec<Contact>. The & without mut means this is a shared, read-only borrow. The function can look at the data but cannot change it.
It iterates over each contact using .iter().enumerate() to get both an index and a reference to each contact. No data is copied or moved.
Why two kinds of borrow?
The Rust compiler enforces a simple rule: at any given time, you can have either one mutable reference OR any number of immutable references to the same data.
This prevents data races and unexpected mutations. add_contact needs &mut because it modifies the vector. list_contacts only needs & because it just reads.
- Save src/main.rs and run:
cargo build
- You should see a successful compilation. The unused-function warnings confirm that your three new functions exist but are not called from main() yet.
Seeing a syntax error in list_contacts?
Double-check that the println! macro inside the for loop has a comma after the format string and after each argument. Rust macros are sensitive to trailing commas and missing parentheses.
Help me fix a syntax error in my list_contacts function
Experience a borrowing conflict
Before wiring everything up correctly, let's intentionally break the borrowing rules so you can see what the compiler protects you from.
- Replace your current fn main() with this intentionally broken version:
fn main() {
let mut contacts: Vec<Contact> = Vec::new();
add_contact(&mut contacts);
// Take an immutable borrow
let snapshot = &contacts;
// Try to mutably borrow while immutable borrow is active
add_contact(&mut contacts);
// Use the immutable borrow after the mutable one
list_contacts(snapshot);
}
- Save the file and run:
cargo build
- You should see a compiler error. The error mentions that contacts cannot be borrowed as mutable because it is also borrowed as immutable.
What is the compiler telling you?
The line let snapshot = &contacts creates an immutable reference. Then add_contact(&mut contacts) tries to take a mutable reference to the same vector.
Rust refuses because if add_contact modifies the vector (pushing a new element could reallocate memory), the snapshot reference might point to freed memory. The compiler catches this at compile time instead of letting it crash at runtime.
The fix is simple: make sure the immutable borrow ends before the mutable borrow begins. In our case, just call the functions sequentially without holding onto an intermediate reference.
Test your functions
Now fix the borrow conflict by replacing your broken main() with the correct version that calls each function sequentially.
- Replace the entire fn main() function with this correct version:
fn main() {
let mut contacts: Vec<Contact> = Vec::new();
add_contact(&mut contacts);
add_contact(&mut contacts);
list_contacts(&contacts);
}
Why does this version work?
Each function call borrows and then releases contacts before the next call begins. The first add_contact takes a mutable borrow, finishes, and gives it back. The second add_contact does the same. Then list_contacts takes an immutable borrow. No borrows overlap, so the compiler is happy.
- Save src/main.rs and run the program:
cargo run
- The program prompts you to enter a name, phone, and email twice (for two contacts). After you enter both, it prints a numbered list of your contacts.
- Enter test data like Alice, 555-1234, alice@example.com for the first contact and Bob, 555-5678, bob@example.com for the second.
What should I see?
After entering both contacts, the output should look like:
--- Contacts ---
1. Alice | 555-1234 | alice@example.com
2. Bob | 555-5678 | bob@example.com
----------------
Program compiles but panics at runtime?
If you see a panic mentioning unwrap(), check that your terminal supports standard input. Some embedded terminals in certain editors may not handle stdin correctly. Try running cargo run from your system terminal instead.
Help me debug a panic in my Rust contact book
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/main.rs before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at this point. Compare your file against this:
use std::io;
struct Contact {
name: String,
phone: String,
email: String,
}
impl Contact {
fn new(name: String, phone: String, email: String) -> Contact {
Contact { name, phone, email }
}
}
fn add_contact(contacts: &mut Vec<Contact>) {
let name = read_input("Enter name: ");
let phone = read_input("Enter phone: ");
let email = read_input("Enter email: ");
let contact = Contact::new(name, phone, email);
contacts.push(contact);
println!("Contact added!");
}
fn list_contacts(contacts: &Vec<Contact>) {
if contacts.is_empty() {
println!("No contacts yet.");
return;
}
println!("\n--- Contacts ---");
for (i, contact) in contacts.iter().enumerate() {
println!(
"{}. {} | {} | {}",
i + 1,
contact.name,
contact.phone,
contact.email
);
}
println!("----------------\n");
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
io::Write::flush(&mut io::stdout()).unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
fn main() {
let mut contacts: Vec<Contact> = Vec::new();
add_contact(&mut contacts);
add_contact(&mut contacts);
list_contacts(&contacts);
}
Your contact book can now add and display contacts using Rust's borrowing system. Next up, you'll add a search feature that introduces string slices and returned references.
Add Search with String Slices
You can already add and list contacts. Now you need a way to find a specific person without scrolling through the whole list.
Searching requires a different kind of borrowing than what you have used so far. Instead of borrowing the entire Vec, your search function will work with a slice of the vector and return references to matching contacts. This introduces &str (string slices) and lifetime annotations.
In this step, get ready to:
- Write a search function that uses string slices and lifetime annotations.
- Understand the difference between String (owned) and &str (borrowed slice).
- Test search by adding several contacts and filtering by name.
Write a search function using &str
Your search function needs to look through existing contacts without taking ownership of any data. It receives a borrowed slice of the contacts vector and a borrowed string for the search term, then returns references to any matches it finds.
- In src/main.rs, find the list_contacts function. Add the following search_contacts function directly below it (before read_input):
fn search_contacts<'a>(contacts: &'a [Contact], term: &str) -> Vec<&'a Contact> {
contacts
.iter()
.filter(|c| c.name.to_lowercase().contains(&term.to_lowercase()))
.collect()
}
What does this code do?
This function introduces two new concepts: slices and lifetimes.
- &'a [Contact] is a slice. It borrows a view into the contacts vector without taking ownership. A slice is lighter than borrowing the whole Vec because it works with any contiguous sequence of contacts.
- &str is a borrowed string slice for the search term. The function only needs to read the term, not own it.
- The 'a lifetime annotation tells the compiler that the returned references (Vec<&'a Contact>) live exactly as long as the input contacts slice. This guarantee prevents dangling pointers.
- .filter() keeps only contacts whose lowercased name contains the lowercased search term, making the search case-insensitive.
- Save src/main.rs.
- Run cargo build in your terminal. You should see the project compile with no errors.
Seeing a lifetime error?
Make sure the 'a appears in three places: after the function name in angle brackets (<'a>), on the contacts parameter (&'a [Contact]), and on the return type (Vec<&'a Contact>). If any one is missing, the compiler cannot verify that the references are safe.
help me fix a lifetime annotation error in my search_contacts function
Understand the difference between String and &str
Your Contact struct uses String for its fields, but your search function accepts &str. These two types look similar but have very different ownership semantics.
What is the difference between String and &str?
String is an owned, heap-allocated string. The struct or variable holding it is responsible for freeing its memory. You can grow it, shrink it, and pass ownership of it to another variable or function.
&str is a borrowed view into a String (or a string literal). It cannot outlive the String it points to. It is lightweight and perfect for read-only operations like searching.
Your Contact struct owns its data with String fields because it needs to live independently. Your search function takes &str because it only needs to peek at the data temporarily.
To see why this distinction matters, try a quick experiment. Temporarily add this broken function anywhere in your file:
fn broken_return() -> &str {
let s = String::from("hello");
&s
}
- Run cargo build and observe the compiler error.
What is the compiler telling you?
The variable s is created inside the function and gets dropped when the function ends. Returning &s would create a dangling reference pointing to freed memory. The compiler refuses to allow this.
This is exactly why your search_contacts function works differently. It returns references to contacts that live in the Vec owned by main(). The lifetime 'a proves to the compiler that the data the references point to will still exist when the caller uses them.
- Delete the broken_return function from your file. It was only for demonstration.
Test search with several contacts
Now wire up the search function in main() so you can see it work end-to-end.
- In src/main.rs, find your main() function and replace its contents with the following code:
fn main() {
let mut contacts: Vec<Contact> = Vec::new();
add_contact(&mut contacts);
add_contact(&mut contacts);
add_contact(&mut contacts);
list_contacts(&contacts);
let term = read_input("Search for: ");
let results = search_contacts(&contacts, &term);
if results.is_empty() {
println!("No matches found.");
} else {
for contact in results {
println!("{} | {} | {}", contact.name, contact.phone, contact.email);
}
}
}
What does this code do?
- Three calls to add_contact give you enough data to test searching.
- list_contacts(&contacts) shows all contacts first so you know what is in the list.
- search_contacts(&contacts, &term) borrows the vector as a slice and the search term as &str. Neither the vector nor the term are moved.
- The results are Vec<&Contact> (references into the original vector). You can print them without copying any data.
- Save src/main.rs.
- Run cargo run in your terminal.
- Enter three contacts when prompted (use different names like Alice, Bob, Alicia).
- When the search prompt appears, type a partial name (e.g. ali). You should see both Alice and Alicia in the results.
No matches found even though you entered a matching name?
The search is case-insensitive, so capitalization should not matter. Double-check that you typed part of the name you entered earlier. If you see a compile error instead of a prompt, make sure the search_contacts function is defined before main() and that you deleted the experimental broken_return function.
help me debug why my search_contacts function is not finding matches
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs and that cargo run compiles and runs successfully.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs at the end of this step:
use std::io;
struct Contact {
name: String,
phone: String,
email: String,
}
impl Contact {
fn new(name: String, phone: String, email: String) -> Contact {
Contact { name, phone, email }
}
}
fn add_contact(contacts: &mut Vec<Contact>) {
let name = read_input("Enter name: ");
let phone = read_input("Enter phone: ");
let email = read_input("Enter email: ");
let contact = Contact::new(name, phone, email);
contacts.push(contact);
println!("Contact added!");
}
fn list_contacts(contacts: &Vec<Contact>) {
if contacts.is_empty() {
println!("No contacts yet.");
return;
}
println!("\n--- Contacts ---");
for (i, contact) in contacts.iter().enumerate() {
println!(
"{}. {} | {} | {}",
i + 1,
contact.name,
contact.phone,
contact.email
);
}
println!("----------------\n");
}
fn search_contacts<'a>(contacts: &'a [Contact], term: &str) -> Vec<&'a Contact> {
contacts
.iter()
.filter(|c| c.name.to_lowercase().contains(&term.to_lowercase()))
.collect()
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
io::Write::flush(&mut io::stdout()).unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
fn main() {
let mut contacts: Vec<Contact> = Vec::new();
add_contact(&mut contacts);
add_contact(&mut contacts);
add_contact(&mut contacts);
list_contacts(&contacts);
let term = read_input("Search for: ");
let results = search_contacts(&contacts, &term);
if results.is_empty() {
println!("No matches found.");
} else {
for contact in results {
println!("{} | {} | {}", contact.name, contact.phone, contact.email);
}
}
}
Your contact book can now search through its data without copying or moving anything. The compiler verified that every reference your search returns is safe. Next up, you will build the interactive menu loop and add a delete feature that requires mutable access to the list.
Build the Interactive Loop and Delete Feature
Your contact book can add, list, and search contacts. Right now though, your main() function is a hard-coded test script that runs once and exits.
A real CLI application runs continuously, waiting for user commands. In this step, you'll replace your test code with an interactive loop that presents a menu, reads the user's choice, and dispatches to the correct function using Rust's match expression.
In this step, get ready to:
- Write a delete function that requires mutable access to the contact list.
- Build an interactive loop with a numbered menu.
- Wire all your functions together into a complete CLI application.
Plan the interactive loop
Your finished CLI will use a loop {} that runs forever until the user types "5" to quit. Inside the loop, you'll print a numbered menu and use a match expression to call the right function based on the user's input.
What is pattern matching in Rust?
The match expression compares a value against a series of patterns and runs the code for the first match. It is exhaustive, meaning the compiler forces you to handle every possible case. The _ pattern acts as a catch-all for anything not explicitly listed.
Before you build the loop, you need one more function: delete. Deleting a contact is what ties &mut back to a real-world action. You cannot remove an item from a list unless you have exclusive write access to it.
Implement delete with mutable access
- In src/main.rs, add the following delete_contact function above the read_input function:
fn delete_contact(contacts: &mut Vec<Contact>, name: &str) {
// Store the count before removal to check if anything changed
let before = contacts.len();
// Keep only contacts whose name doesn't match (case-insensitive)
contacts.retain(|c| c.name.to_lowercase() != name.to_lowercase());
let after = contacts.len();
if before == after {
println!("No contact found with that name.");
} else {
println!("Contact deleted!");
}
}
What does this code do?
This function takes &mut Vec<Contact> because removing elements modifies the vector. The compiler guarantees no other code is reading the vector while this function runs.
The .retain() method keeps only elements where the closure returns true. By comparing lengths before and after, you can tell the user whether the delete actually found a match.
- Save src/main.rs.
Why does delete need &mut but search only needs &?
Search reads data without changing it, so a shared reference (&) is enough. Delete removes elements from the vector, which is a structural modification. Rust's borrowing rules enforce that only one piece of code can modify data at a time, preventing bugs where something reads stale data mid-deletion.
Seeing a type mismatch error?
Make sure the function signature matches exactly: contacts: &mut Vec<Contact> and name: &str. If you accidentally wrote name: String instead of name: &str, the compiler will complain when you try to pass a borrowed reference later.
Help me debug my delete_contact function
Wire it all together and test
Now you'll replace the test code in main() with the interactive loop. This connects every function you've built across the project into one working CLI.
- In src/main.rs, find the entire fn main() function and replace it with the following:
fn main() {
let mut contacts: Vec<Contact> = Vec::new();
println!("=== Rust Contact Book ===\n");
// Run forever until the user chooses to quit
loop {
println!("1. Add contact");
println!("2. List contacts");
println!("3. Search contacts");
println!("4. Delete contact");
println!("5. Quit");
let choice = read_input("Choose an option: ");
// Dispatch to the correct function based on user input
match choice.as_str() {
"1" => add_contact(&mut contacts),
"2" => list_contacts(&contacts),
"3" => {
let term = read_input("Search for: ");
let results = search_contacts(&contacts, &term);
if results.is_empty() {
println!("No matches found.");
} else {
println!("\n--- Search Results ---");
for contact in results {
println!(
"{} | {} | {}",
contact.name, contact.phone, contact.email
);
}
println!("---------------------\n");
}
}
"4" => {
let name = read_input("Name to delete: ");
delete_contact(&mut contacts, &name);
}
"5" => {
println!("Goodbye!");
break;
}
_ => println!("Invalid option, try again."),
}
println!();
}
}
What does this code do?
The loop {} block repeats forever. Each iteration prints the menu, reads a choice, and uses match to call the appropriate function. Typing "5" triggers break, which exits the loop and ends the program.
Notice how ownership flows through the match arms. Options 1 and 4 pass &mut contacts because they modify the vector. Option 2 passes &contacts for read-only access. Option 3 borrows immutably via the slice reference inside search_contacts. The compiler checks every one of these at compile time.
- Save src/main.rs.
- Run your application by running:
cargo run
- You should see the menu appear with options 1 through 5.
- Type 1 to add a contact, fill in the prompts, then type 2 to list contacts and confirm your entry appears.
- Type 3 to search, 4 to delete, and 5 to quit.
Menu prints but input does nothing?
Make sure you're calling .as_str() on the choice variable inside the match. Without it, you're matching on a String rather than a &str, and the string literal patterns won't match.
Also confirm that read_input calls .trim() before returning. Without trimming, the newline character from pressing Enter stays in the string, so "1\n" never matches "1".
Help me fix my interactive loop
✔️ Awesome, I've got everything!
Great. Double check you've saved src/main.rs and that cargo run launches the interactive menu.
ⓧ I'd like to double check the full code
Here is the complete src/main.rs file at this point:
use std::io;
struct Contact {
name: String,
phone: String,
email: String,
}
impl Contact {
fn new(name: String, phone: String, email: String) -> Contact {
Contact { name, phone, email }
}
}
fn add_contact(contacts: &mut Vec<Contact>) {
let name = read_input("Enter name: ");
let phone = read_input("Enter phone: ");
let email = read_input("Enter email: ");
let contact = Contact::new(name, phone, email);
contacts.push(contact);
println!("Contact added!");
}
fn list_contacts(contacts: &Vec<Contact>) {
if contacts.is_empty() {
println!("No contacts yet.");
return;
}
println!("\n--- Contacts ---");
for (i, contact) in contacts.iter().enumerate() {
println!(
"{}. {} | {} | {}",
i + 1,
contact.name,
contact.phone,
contact.email
);
}
println!("----------------\n");
}
fn search_contacts<'a>(contacts: &'a [Contact], term: &str) -> Vec<&'a Contact> {
contacts
.iter()
.filter(|c| c.name.to_lowercase().contains(&term.to_lowercase()))
.collect()
}
fn delete_contact(contacts: &mut Vec<Contact>, name: &str) {
let before = contacts.len();
contacts.retain(|c| c.name.to_lowercase() != name.to_lowercase());
let after = contacts.len();
if before == after {
println!("No contact found with that name.");
} else {
println!("Contact deleted!");
}
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
io::Write::flush(&mut io::stdout()).unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
fn main() {
let mut contacts: Vec<Contact> = Vec::new();
println!("=== Rust Contact Book ===\n");
loop {
println!("1. Add contact");
println!("2. List contacts");
println!("3. Search contacts");
println!("4. Delete contact");
println!("5. Quit");
let choice = read_input("Choose an option: ");
match choice.as_str() {
"1" => add_contact(&mut contacts),
"2" => list_contacts(&contacts),
"3" => {
let term = read_input("Search for: ");
let results = search_contacts(&contacts, &term);
if results.is_empty() {
println!("No matches found.");
} else {
println!("\n--- Search Results ---");
for contact in results {
println!(
"{} | {} | {}",
contact.name, contact.phone, contact.email
);
}
println!("---------------------\n");
}
}
"4" => {
let name = read_input("Name to delete: ");
delete_contact(&mut contacts, &name);
}
"5" => {
println!("Goodbye!");
break;
}
_ => println!("Invalid option, try again."),
}
println!();
}
}
Secret mission
Add an Edit Feature
Your contact book can add, list, search, and delete. But what about editing an existing contact? This challenge requires you to juggle mutable and immutable borrows in the same function, which is one of the trickiest patterns in Rust.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources for future Rust practice, pause them to come back later, or delete them entirely.
Resources you used:
- The contact-book project folder on your Desktop.
- The Rust toolchain (rustc, cargo, rustup) installed via rustup.
Keep everything running
No action needed. Choose this if you plan to continue building Rust projects or want to revisit the contact book later.
- Your contact-book folder stays on your Desktop, ready for you to extend.
- The Rust toolchain stays installed and available for new projects.
Pause - I'll come back to this later
Shut down any running processes to free up terminal sessions. Your files and toolchain stay intact for when you return.
- If your contact book is still running in a terminal, press Ctrl+C to stop it.
- Close any terminal sessions you opened for the project.
- Everything else stays as-is. The Rust toolchain uses disk space but no background processes.
Delete - I don't want to use this again
Remove all project files and optionally uninstall the Rust toolchain from your system.
- Delete the project folder by running this command:
cd ~/Desktop
rm -rf contact-book
On Windows, use this instead:
cd ~\Desktop
rmdir /s /q contact-book
- To uninstall Rust and all its tools, run:
rustup self uninstall
What does this remove?
Running rustup self uninstall removes rustc, cargo, rustup, and all installed toolchains from your system. It cleans up the ~/.cargo and ~/.rustup directories.
Nice Work!
Nice Work!
You built a working CLI contact book in Rust and battled the borrow checker to understand memory safety!
You've learned how to:
- Build a complete Rust project from scratch using Cargo, including structs, functions, and an interactive loop.
- Apply Rust's ownership and borrowing rules to write memory-safe code, using &T for shared read access and &mut T for exclusive write access.
- Encounter and fix real borrow-checker errors that prevent memory bugs like dangling pointers and data races at compile time.
- Implemented an edit feature using mutable references and the .iter_mut().find() iterator pattern.
Ready to quiz yourself?