Mini-Project: Contact Book CLI
The best way to consolidate new knowledge is to use it all at once in a program that does something genuinely useful. In this project you will build a command-line contact book: a program that lets you add contacts, list them, search by name, remove one, and save everything to disk so the data persists between runs. The finished program is under 200 lines long, but it touches every major topic from Modules 1–7 — variables, control flow, functions, structs, vectors, file I/O, and references. Build it step by step alongside these notes, compile after each step, and you will have a real C++ program to show for it.
Concepts applied in this project
This project deliberately uses nothing outside the beginner path — no templates, no exceptions, no dynamic allocation, no lambdas. If any row below feels unfamiliar, review that module before continuing.
| Module | Concept | How it's used here |
|---|---|---|
| Module 2 | Variables & Types | std::string for name/phone/email, char for menu choice |
| Module 3 | Control Flow | while loop for the main menu, if/else for dispatch |
| Module 4 | Functions | one function per operation keeps each piece testable |
| Module 5 | Structs & Classes | Contact struct groups related fields into one type |
| Module 6 | STL Containers | vector<Contact> for storage, string for text |
| Module 6 | File I/O | load on startup, save on exit via std::fstream |
| Module 7 | References | const Contact& in range-for avoids copying; vector& to modify in place |
Step 1 — Model your data with a struct
Before writing a single line of logic, decide what a "contact" is. A contact has a name, a phone number, and an email address — three strings that belong together. Rather than storing three separate vectors and keeping them in sync, group them into a single struct. A struct is just a named collection of fields; defining one costs nothing at runtime, but it makes every function signature and every loop body dramatically easier to read.
struct Contact {
std::string name;
std::string phone;
std::string email;
};
// All contacts live in one vector — easy to pass around, easy to iterate:
std::vector<Contact> contacts;
// Access fields with the dot operator, just like any struct:
Contact alice{"Alice Lam", "555-0100", "alice@example.com"};
std::cout << alice.name << " — " << alice.phone << '\n';The whole program revolves around this vector<Contact>. Every function either reads from it, modifies it, or both. Passing the vector by reference to helper functions — rather than by value — means there is only ever one copy of the data in memory, and changes made inside a function are immediately visible everywhere else.
Step 2 — Choose a file format
Before writing the I/O code, settle on a format. CSV (comma-separated values) is the simplest choice: one contact per line, fields separated by commas. It is human-readable, trivial to write, and easy to parse with std::string::find and substr. A contacts file looks like this:
Alice Lam,555-0100,alice@example.com Bob Nair,555-0200,bob@example.com Carol Wu,555-0300,carol@example.com
The assumption is that names, phone numbers, and email addresses never contain a comma — a reasonable restriction for a learning project. A production application would use proper CSV quoting or a different delimiter; for now, keep it simple.
Step 3 — Save and load functions
Write the two I/O functions first, before any interactive code. This lets you verify that data survives a round-trip (write → read → compare) independently of the rest of the program. Both functions use the RAII stream pattern: construct the stream with a filename, check that it opened, do the work, and let the destructor close it.
void save(const std::vector<Contact>& contacts, const std::string& filename)
{
std::ofstream out{filename};
for (const Contact& c : contacts)
out << c.name << ',' << c.phone << ',' << c.email << '\n';
}
std::vector<Contact> load(const std::string& filename)
{
std::vector<Contact> contacts;
std::ifstream in{filename};
std::string line;
while (std::getline(in, line)) {
auto p1 = line.find(','); // position of first comma
auto p2 = line.find(',', p1 + 1); // position of second comma
if (p1 == std::string::npos || p2 == std::string::npos)
continue; // skip malformed lines
Contact c;
c.name = line.substr(0, p1);
c.phone = line.substr(p1 + 1, p2 - p1 - 1);
c.email = line.substr(p2 + 1);
contacts.push_back(c);
}
return contacts; // empty if file doesn't exist yet
}The load function returns an empty vector if the file does not exist — which is fine for a first run. Notice that std::string::find returns std::string::npos (a large sentinel value) when the substring is not found; checking for that handles corrupted lines gracefully instead of crashing.
Step 4 — The four operations
Each menu option gets its own function. Keeping each operation in its own function has two benefits: you can test each one independently, and the main loop stays short and readable. All four functions receive the vector by reference so they can add, remove, or read the shared data.
void add_contact(std::vector<Contact>& contacts)
{
Contact c;
std::cout << "Name: "; std::getline(std::cin, c.name);
std::cout << "Phone: "; std::getline(std::cin, c.phone);
std::cout << "Email: "; std::getline(std::cin, c.email);
contacts.push_back(c);
std::cout << "Contact added.\n";
}
void list_contacts(const std::vector<Contact>& contacts)
{
if (contacts.empty()) { std::cout << "No contacts.\n"; return; }
for (std::size_t i = 0; i < contacts.size(); ++i) {
const Contact& c = contacts[i];
std::cout << i + 1 << ". " << c.name
<< " | " << c.phone
<< " | " << c.email << '\n';
}
}
void find_contact(const std::vector<Contact>& contacts)
{
std::cout << "Search: ";
std::string query;
std::getline(std::cin, query);
bool found = false;
for (const Contact& c : contacts) {
if (c.name.find(query) != std::string::npos) {
std::cout << c.name << " | " << c.phone << " | " << c.email << '\n';
found = true;
}
}
if (!found) std::cout << "No matches for \"" << query << "\"\n";
}
void remove_contact(std::vector<Contact>& contacts)
{
list_contacts(contacts);
if (contacts.empty()) return;
std::cout << "Remove # (1-" << contacts.size() << "): ";
std::size_t n;
std::cin >> n;
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
if (n >= 1 && n <= contacts.size()) {
contacts.erase(contacts.begin() + static_cast<std::ptrdiff_t>(n - 1));
std::cout << "Removed.\n";
} else {
std::cout << "Invalid number.\n";
}
}The remove_contact function mixes >> (to read the number) with getline (to read strings in other functions). After reading with >>, the newline character is still in the buffer; the cin.ignore(…) call discards it so the next getline call works correctly. This is the same mixing pitfall covered in the I/O lesson.
Step 5 — The main loop
With the four operations written, the main function is almost trivial. It loads the contacts at startup, runs the menu loop until the user quits, and saves on exit. The entire user-facing experience lives in this loop; every other concern has already been delegated to a function.
int main()
{
const std::string filename = "contacts.csv";
std::vector<Contact> contacts = load(filename);
std::cout << "Loaded " << contacts.size() << " contact(s).\n\n";
char choice = ' ';
while (choice != 'q') {
std::cout << "[a]dd [l]ist [f]ind [r]emove [q]uit > ";
std::cin >> choice;
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
if (choice == 'a') add_contact(contacts);
else if (choice == 'l') list_contacts(contacts);
else if (choice == 'f') find_contact(contacts);
else if (choice == 'r') remove_contact(contacts);
else if (choice != 'q') std::cout << "Unknown command.\n";
}
save(contacts, filename);
std::cout << "Saved " << contacts.size() << " contact(s). Goodbye.\n";
}Complete program
Paste this into a file called contacts.cpp, compile with g++ -std=c++17 -Wall -o contacts contacts.cpp, and run with ./contacts. A contacts.csv file will be created in the same directory.
#include <iostream>
#include <fstream>
#include <vector>
#include <string>
#include <limits>
struct Contact {
std::string name;
std::string phone;
std::string email;
};
void save(const std::vector<Contact>& contacts, const std::string& filename)
{
std::ofstream out{filename};
for (const Contact& c : contacts)
out << c.name << ',' << c.phone << ',' << c.email << '\n';
}
std::vector<Contact> load(const std::string& filename)
{
std::vector<Contact> contacts;
std::ifstream in{filename};
std::string line;
while (std::getline(in, line)) {
auto p1 = line.find(',');
auto p2 = line.find(',', p1 + 1);
if (p1 == std::string::npos || p2 == std::string::npos) continue;
Contact c;
c.name = line.substr(0, p1);
c.phone = line.substr(p1 + 1, p2 - p1 - 1);
c.email = line.substr(p2 + 1);
contacts.push_back(c);
}
return contacts;
}
void add_contact(std::vector<Contact>& contacts)
{
Contact c;
std::cout << "Name: "; std::getline(std::cin, c.name);
std::cout << "Phone: "; std::getline(std::cin, c.phone);
std::cout << "Email: "; std::getline(std::cin, c.email);
contacts.push_back(c);
std::cout << "Contact added.\n";
}
void list_contacts(const std::vector<Contact>& contacts)
{
if (contacts.empty()) { std::cout << "No contacts.\n"; return; }
for (std::size_t i = 0; i < contacts.size(); ++i) {
const Contact& c = contacts[i];
std::cout << i + 1 << ". " << c.name
<< " | " << c.phone
<< " | " << c.email << '\n';
}
}
void find_contact(const std::vector<Contact>& contacts)
{
std::cout << "Search: ";
std::string query;
std::getline(std::cin, query);
bool found = false;
for (const Contact& c : contacts) {
if (c.name.find(query) != std::string::npos) {
std::cout << c.name << " | " << c.phone << " | " << c.email << '\n';
found = true;
}
}
if (!found) std::cout << "No matches for \"" << query << "\"\n";
}
void remove_contact(std::vector<Contact>& contacts)
{
list_contacts(contacts);
if (contacts.empty()) return;
std::cout << "Remove # (1-" << contacts.size() << "): ";
std::size_t n;
std::cin >> n;
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
if (n >= 1 && n <= contacts.size()) {
contacts.erase(contacts.begin() + static_cast<std::ptrdiff_t>(n - 1));
std::cout << "Removed.\n";
} else {
std::cout << "Invalid number.\n";
}
}
int main()
{
const std::string filename = "contacts.csv";
std::vector<Contact> contacts = load(filename);
std::cout << "Loaded " << contacts.size() << " contact(s).\n\n";
char choice = ' ';
while (choice != 'q') {
std::cout << "[a]dd [l]ist [f]ind [r]emove [q]uit > ";
std::cin >> choice;
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
if (choice == 'a') add_contact(contacts);
else if (choice == 'l') list_contacts(contacts);
else if (choice == 'f') find_contact(contacts);
else if (choice == 'r') remove_contact(contacts);
else if (choice != 'q') std::cout << "Unknown command.\n";
}
save(contacts, filename);
std::cout << "Saved " << contacts.size() << " contact(s). Goodbye.\n";
}Sample run
Loaded 0 contact(s). [a]dd [l]ist [f]ind [r]emove [q]uit > a Name: Alice Lam Phone: 555-0100 Email: alice@example.com Contact added. [a]dd [l]ist [f]ind [r]emove [q]uit > a Name: Bob Nair Phone: 555-0200 Email: bob@example.com Contact added. [a]dd [l]ist [f]ind [r]emove [q]uit > l 1. Alice Lam | 555-0100 | alice@example.com 2. Bob Nair | 555-0200 | bob@example.com [a]dd [l]ist [f]ind [r]emove [q]uit > f Search: Bob Bob Nair | 555-0200 | bob@example.com [a]dd [l]ist [f]ind [r]emove [q]uit > q Saved 2 contact(s). Goodbye.
Design decisions worth noticing
One function per operation
Each of add, list, find, remove, save, load does exactly one thing. If you need to change how search works, you only touch find_contact. This is the single-responsibility principle at its most basic.
Vector passed by reference, not by value
Passing a vector by value copies all its contents — expensive and pointless when you want to modify the original. Passing by const& means read-only; passing by & means read-write. Functions that only read (list, find) take const&; functions that modify (add, remove) take &.
Load returns an empty vector on missing file
On the first run the contacts.csv file does not exist. Rather than treating this as an error, load() returns an empty vector and the program continues normally. The file is created when the user quits and save() is called.
Save on exit, not after every change
For a simple learning project, saving once at the end is sufficient. A production application would save after every modification, or use a database, to avoid data loss if the program crashes mid-session.
cin.ignore() after >> to clear the newline
After std::cin >> choice reads one character, the newline the user pressed stays in the buffer. The next getline() call would then immediately return an empty string without waiting for input. cin.ignore() discards that leftover newline before any getline() is called.
Challenges — extend the project
Once the base program compiles and runs, try these extensions. Each one exercises a specific skill and can be done independently:
Case-insensitive search
Hint: Convert both the query and each contact name to lowercase before comparing. std::transform with ::tolower works.
Edit an existing contact
Hint: Add an [e]dit option that shows the list, asks for a number, then prompts for new values. Overwrite the fields in place — contacts[n-1].name = new_name;
Sort alphabetically by name
Hint: Call std::sort(contacts.begin(), contacts.end(), [](const Contact& a, const Contact& b){ return a.name < b.name; }); after loading. This is your first lambda — it just tells sort how to compare two contacts.
Duplicate detection
Hint: In add_contact, scan the vector before pushing. If any contact already has the same name, warn the user and ask whether to proceed.
Multiple search fields
Hint: Extend find_contact to match against phone and email too, not just name. The same npos check works for all three fields.