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
Contactstruct withname,phone, andemailfields. - A
ContactBookstruct wrapping aHashMap<String, Contact>keyed by name. add()andfind()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
implblock. - 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/Noneand unwrapping it safely withif letormatch.HashMapbasics —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 perContactand afind_by_tag()search over them. - Persist the
HashMapto a file (JSON viaserde_json, or a simple|-delimited format like the CLI Task Manager project) so contacts survive between runs. - Return
Result<(), String>fromadd()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 onphoneandemail, not justname.
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.