Skip to content
C++

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.

ModuleConceptHow it's used here
Module 2Variables & Typesstd::string for name/phone/email, char for menu choice
Module 3Control Flowwhile loop for the main menu, if/else for dispatch
Module 4Functionsone function per operation keeps each piece testable
Module 5Structs & ClassesContact struct groups related fields into one type
Module 6STL Containersvector<Contact> for storage, string for text
Module 6File I/Oload on startup, save on exit via std::fstream
Module 7Referencesconst 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.

Sign in to track progress