Overview
A contact book is a small enough project that its real subject is not the data itself but how Rust's borrow checker reacts to functions passing that data back and forth. Every contact will live in a `HashMap<String, Contact>` owned by one `ContactBook` struct, and every function that touches it — `add`, `find`, `update`, `remove`, `search` — will have to decide up front whether it needs to *read* the map (`&HashMap`), *change* the map (`&mut HashMap`), or *take ownership* of a value out of it entirely. Getting that decision right, and understanding why the compiler rejects the wrong one, is the actual point of this project.
By the end of this tutorial you will have a working contact book, and — deliberately — you will also have seen a borrow-checker error on purpose: a case where holding an immutable reference into the map and then trying to mutate the map at the same time does not compile. You will fix it, and in doing so you will understand one of the rules that makes Rust programs free of a whole category of bugs (dangling references, iterator invalidation) that are only caught at runtime, if at all, in most other languages.
- A `Contact` struct with `name`, `phone`, and `email` fields.
- A `ContactBook` struct wrapping a `HashMap<String, Contact>` keyed by name.
- `add()` and `find()` methods demonstrating ownership transfer versus borrowing.
- An `update()` method that mutably borrows one entry without touching the rest of the map.
- A deliberately broken snippet showing the classic "immutable + mutable borrow at once" compiler error, and its fix.
- A `search()` method returning borrowed references to every contact matching a substring.
Prerequisites
- Structs and basic methods in an `impl` block.
- Ownership fundamentals — what "moving" a value means, and that a moved-from variable can no longer be used.
- Borrowing — the difference between `&T` (shared/immutable) and `&mut T` (exclusive/mutable) references.
- `Option<T>` — `Some`/`None` and unwrapping it safely with `if let` or `match`.
- `HashMap` basics — `insert()`, `get()`, and iterating with `.values()` or `.iter()`.
Project Structure
Create the project with `cargo new contact_book` and write everything in `src/main.rs`. `Contact` is a plain data struct with no methods of its own. `ContactBook` wraps a single `HashMap<String, Contact>` field and is the only type with methods on it — exactly the same "one owner, one set of methods" shape used by `TaskManager` in the CLI Task Manager project, just backed by a `HashMap` instead of a `Vec` because contacts are looked up by name, not by position.
The map is keyed by `String`, not `&str` — the map has to own its keys for as long as entries exist in it, and a borrowed `&str` tied to some caller's stack frame could not outlive the function call that inserted it. This is the same ownership reasoning that makes `Task.description` in the earlier project an owned `String` rather than a borrowed slice.
Step 1: Define the Contact Struct
Every field is an owned `String`. Deriving `Clone` is what makes it possible, later, to copy a `Contact`'s data out of the map when you need a value that can outlive a borrow into the map — you will see exactly why that matters in Step 5.
// Every field is an owned String: a Contact must be able to outlive// whatever &str a caller used to construct it, since it will be stored// inside a HashMap that may live far longer than that call site.#[derive(Debug, Clone)]struct Contact { name: String, phone: String, email: String,}Step 2: Build a ContactBook Around a HashMap
`ContactBook` wraps one `HashMap<String, Contact>`. Rust's `HashMap` requires its key type to implement `Eq` and `Hash`, both of which `String` already implements, so no extra work is needed to use it as a key. `HashMap::new()`, like `Vec::new()`, allocates nothing until the first entry is inserted.
use std::collections::HashMap;
struct ContactBook { contacts: HashMap<String, Contact>, // Keyed by contact name; String satisfies HashMap's Eq + Hash bound}
impl ContactBook { fn new() -> ContactBook { ContactBook { contacts: HashMap::new() } }}Step 3: Add and Find Contacts
`add()` takes `contact: Contact` by value — ownership of the whole struct moves into the method, and then into the map via `insert()`. `find()` is where borrowing matters: it takes `name: &str` (borrowing the search key, not owning it) and returns `Option<&Contact>` — a borrowed reference to the contact still living inside the map, not a copy of it. That return type is the source of the pitfall in Step 5, so hold onto it.
impl ContactBook { // contact: Contact takes ownership -- the caller's Contact is moved in // here, then moved again into the HashMap by insert(). The caller has // no usable Contact left afterward, by design: the book is now the // sole owner of this data. fn add(&mut self, contact: Contact) { self.contacts.insert(contact.name.clone(), contact); // clone() the key only; the Contact itself still moves whole }
// &self (read-only borrow of the whole book) in, Option<&Contact> (a // borrow tied to that same book) out. No data is copied here at all. fn find(&self, name: &str) -> Option<&Contact> { self.contacts.get(name) }}Why `contact.name.clone()` as the key and not just `contact.name`? Because `self.contacts.insert(key, contact)` needs two separate values — the key `String` and the whole `Contact` (which also contains a `name` field) — and `contact.name` cannot be moved out of `contact` while `contact` itself is about to be moved into `insert()` as its second argument. Cloning the name is a small, deliberate cost that avoids fighting the borrow checker over a few bytes of duplicated text.
Click Run to see what this code prints.
Step 4: Update a Contact With a Mutable Borrow
`update()` uses `get_mut()` instead of `get()`, which returns `Option<&mut Contact>` — an exclusive, mutable borrow of just that one entry, not the whole map. While that `&mut Contact` is alive, the rest of `ContactBook`'s methods are still perfectly usable on other data, because Rust's borrow checker tracks borrows at the granularity of the expression, not "the whole struct is locked." The `Option<String>` parameters let a caller update only the fields they actually want to change.
impl ContactBook { // new_phone/new_email are Option<String>: None means "leave this field // alone," letting a caller update just the phone, just the email, or // both, without three separate methods. fn update(&mut self, name: &str, new_phone: Option<String>, new_email: Option<String>) -> bool { match self.contacts.get_mut(name) { // &mut Contact: exclusive access to this one entry only Some(contact) => { if let Some(phone) = new_phone { contact.phone = phone; // Overwrites the old String; the old value is dropped here } if let Some(email) = new_email { contact.email = email; } true } None => false, // No contact with that name; nothing to update } }}Step 5: An Ownership Pitfall: Borrowing Immutably and Mutably at Once
Here is code that looks reasonable and will not compile. `find()` returns an `Option<&Contact>` — an immutable borrow of `book` that, as far as the compiler is concerned, stays alive for as long as `contact` is still going to be used later in the block. Calling `book.remove(...)` (Step 6) needs a *mutable* borrow of the same `book`, and Rust's one rule for references is simple: you may have either any number of shared `&` borrows, or exactly one exclusive `&mut` borrow, never both at the same time.
// This will NOT compile:if let Some(contact) = book.find("Asha Rao") { book.remove("Asha Rao"); // ERROR: cannot borrow `book` as mutable // because it is also borrowed as immutable println!("Removed: {}", contact.name); // `contact` is still "in use" here}This is not the compiler being overcautious. If it allowed this, `remove()` could drop the `Contact` from the `HashMap` while `contact` still pointed at where that entry used to live — exactly the dangling-pointer bug that crashes or silently corrupts memory in a language without a borrow checker. Rust catches it at compile time instead, before the program ever runs.
The fix is to decide what you actually need from `contact` while the immutable borrow is alive, pull that out as an owned value (with `.clone()`), and let the borrow end before calling anything that needs `&mut book`. Because Rust uses non-lexical lifetimes, a borrow's scope ends at its last use, not at the closing brace of the block it was created in — so simply reordering the code so the mutation happens after the last read is enough.
// Fixed: clone what we need out of the immutable borrow, which ends the// borrow at its last use (Rust's non-lexical lifetimes) -- so by the time// remove() runs, no outstanding &Contact into `book` exists anymore.let removed_name = match book.find("Asha Rao") { Some(contact) => Some(contact.name.clone()), // Clone just the field we need; `contact` isn't used again after this None => None,};
if let Some(name) = removed_name { book.remove(&name); // Safe now: the earlier immutable borrow already ended println!("Removed: {name}");}Step 6: Remove a Contact and Search by Substring
`remove()` uses `HashMap::remove()`, which returns `Option<Contact>` — ownership of the removed value, handed back to the caller (or `None` if the key was not present). `search()` borrows the whole map with `.values()` (an iterator of `&Contact`) and filters with `.contains()`, collecting the surviving borrows into a `Vec<&Contact>`; nothing is cloned, because the caller only needs to read the matches, not keep them past the search call.
impl ContactBook { fn remove(&mut self, name: &str) -> Option<Contact> { self.contacts.remove(name) // Returns the owned Contact that was removed, or None if it wasn't there }
// Returns borrowed references (Vec<&Contact>), not clones -- the caller // only needs to read the matches, so there's no reason to copy every // matching Contact's data just to print it. fn search(&self, query: &str) -> Vec<&Contact> { let query_lower = query.to_lowercase(); self.contacts .values() // Iterator<Item = &Contact> .filter(|c| c.name.to_lowercase().contains(&query_lower)) // Keep only case-insensitive substring matches .collect() }}Step 7: Build the Menu Loop
The menu loop applies the fix from Step 5 directly: the remove branch reads user input for a name, then calls `book.remove(&name)` in one step, with no lingering `find()` borrow held across it. Every other branch follows the same shape as the earlier projects in this course — read a line, trim it, act on it.
use std::io::{self, Write};
fn main() { let mut book = ContactBook::new();
loop { println!("\n===== CONTACT BOOK ====="); println!("1. Add Contact"); println!("2. Find Contact"); println!("3. Update Contact"); println!("4. Remove Contact"); println!("5. Search Contacts"); println!("6. Exit"); print!("Enter your choice: "); io::stdout().flush().unwrap();
let mut choice = String::new(); io::stdin().read_line(&mut choice).expect("failed to read input");
match choice.trim() { "1" => { let name = read_line("Enter name: "); let phone = read_line("Enter phone: "); let email = read_line("Enter email: "); book.add(Contact { name, phone, email }); println!("Contact added."); } "2" => { let name = read_line("Enter name to find: "); match book.find(&name) { Some(c) => println!("{} | {} | {}", c.name, c.phone, c.email), None => println!("No contact with that name."), } } "3" => { let name = read_line("Enter name to update: "); let phone = read_line("Enter new phone (blank to keep): "); let email = read_line("Enter new email (blank to keep): "); let phone = if phone.is_empty() { None } else { Some(phone) }; let email = if email.is_empty() { None } else { Some(email) }; if book.update(&name, phone, email) { println!("Contact updated."); } else { println!("No contact with that name."); } } "4" => { let name = read_line("Enter name to remove: "); match book.remove(&name) { // No earlier find() borrow held across this call -- safe by construction Some(_) => println!("Removed."), None => println!("No contact with that name."), } } "5" => { let query = read_line("Enter search text: "); let results = book.search(&query); if results.is_empty() { println!("No matches."); } else { for c in results { println!("{} | {} | {}", c.name, c.phone, c.email); } } } "6" => { println!("Goodbye!"); break; } _ => println!("Invalid choice, try again."), } }}
// Small helper shared by every menu branch above that needs one line of// text input -- avoids repeating the same print!/flush/read_line/trim// sequence five separate times.fn read_line(prompt: &str) -> String { print!("{prompt}"); io::stdout().flush().unwrap(); let mut input = String::new(); io::stdin().read_line(&mut input).expect("failed to read input"); input.trim().to_string()}Complete Code
Here is the full program assembled in the correct order, ready to save as `src/main.rs` and run with `cargo run`.
use std::collections::HashMap;use std::io::{self, Write};
#[derive(Debug, Clone)]struct Contact { name: String, phone: String, email: String,}
struct ContactBook { contacts: HashMap<String, Contact>,}
impl ContactBook { fn new() -> ContactBook { ContactBook { contacts: HashMap::new() } }
fn add(&mut self, contact: Contact) { self.contacts.insert(contact.name.clone(), contact); }
fn find(&self, name: &str) -> Option<&Contact> { self.contacts.get(name) }
fn update(&mut self, name: &str, new_phone: Option<String>, new_email: Option<String>) -> bool { match self.contacts.get_mut(name) { Some(contact) => { if let Some(phone) = new_phone { contact.phone = phone; } if let Some(email) = new_email { contact.email = email; } true } None => false, } }
fn remove(&mut self, name: &str) -> Option<Contact> { self.contacts.remove(name) }
fn search(&self, query: &str) -> Vec<&Contact> { let query_lower = query.to_lowercase(); self.contacts .values() .filter(|c| c.name.to_lowercase().contains(&query_lower)) .collect() }}
fn read_line(prompt: &str) -> String { print!("{prompt}"); io::stdout().flush().unwrap(); let mut input = String::new(); io::stdin().read_line(&mut input).expect("failed to read input"); input.trim().to_string()}
fn main() { let mut book = ContactBook::new();
loop { println!("\n===== CONTACT BOOK ====="); println!("1. Add Contact"); println!("2. Find Contact"); println!("3. Update Contact"); println!("4. Remove Contact"); println!("5. Search Contacts"); println!("6. Exit"); print!("Enter your choice: "); io::stdout().flush().unwrap();
let mut choice = String::new(); io::stdin().read_line(&mut choice).expect("failed to read input");
match choice.trim() { "1" => { let name = read_line("Enter name: "); let phone = read_line("Enter phone: "); let email = read_line("Enter email: "); book.add(Contact { name, phone, email }); println!("Contact added."); } "2" => { let name = read_line("Enter name to find: "); match book.find(&name) { Some(c) => println!("{} | {} | {}", c.name, c.phone, c.email), None => println!("No contact with that name."), } } "3" => { let name = read_line("Enter name to update: "); let phone = read_line("Enter new phone (blank to keep): "); let email = read_line("Enter new email (blank to keep): "); let phone = if phone.is_empty() { None } else { Some(phone) }; let email = if email.is_empty() { None } else { Some(email) }; if book.update(&name, phone, email) { println!("Contact updated."); } else { println!("No contact with that name."); } } "4" => { let name = read_line("Enter name to remove: "); match book.remove(&name) { Some(_) => println!("Removed."), None => println!("No contact with that name."), } } "5" => { let query = read_line("Enter search text: "); let results = book.search(&query); if results.is_empty() { println!("No matches."); } else { for c in results { println!("{} | {} | {}", c.name, c.phone, c.email); } } } "6" => { println!("Goodbye!"); break; } _ => println!("Invalid choice, try again."), } }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `Vec<String>` of tags per `Contact` and a `find_by_tag()` search over them.
- Persist the `HashMap` to a file (JSON via `serde_json`, or a simple `|`-delimited format like the CLI Task Manager project) so contacts survive between runs.
- Return `Result<(), String>` from `add()` instead of silently overwriting an existing contact with the same name.
- Add a `merge(&mut self, other: ContactBook)` method that takes ownership of another book's contacts and folds them into this one.
- Switch `search()` to also match on `phone` and `email`, not just `name`.
Summary
You built a contact book where every method's signature — `&self`, `&mut self`, or an owned parameter — is a deliberate statement about whether that operation reads, mutates, or takes ownership of data. More importantly, you hit a real borrow-checker error on purpose in Step 5 and fixed it by cloning out exactly the data you needed before mutating, which is the standard escape hatch whenever an immutable borrow and a mutation of the same value would otherwise overlap. That instinct — "what do I actually need to keep, and can I own a small copy of it instead of holding a reference across a mutation" — is one of the most useful habits you can build as a working Rust programmer.