cp1-lab worked example · a contact manager in C

Worked example project — a command-line contact manager in C

This page walks through one complete, compilable C program from an empty file to a working tool. It is the kind of moderately-complex program the syllabus asks each group to build for the Group Project (20% of the grade): a command-line contact / inventory manager that stores records of people, lets you add, list, search and delete them, and saves everything to a CSV file on disk so the data survives between runs.

Although small, it deliberately exercises the heart of the course: struct records, typedef, pointers, dynamic memory (malloc / realloc / free) backed by a growable array, file I/O for save & load, careful input validation, and a menu-driven main loop. Every snippet below is real C — copy them in order into one file and it compiles and runs. The companion interactive demos animate the underlying ideas; the course outline maps each one to a session.

language
C (C11)
lines of code
~260
build
gcc
data store
dynamic array
persistence
CSV file
maps to
Modules 0–4

build & run (one translation unit, no external libraries)

# compile with all warnings on — treat them as errors while learning
gcc -std=c11 -Wall -Wextra -O2 -o contacts contacts.c

# run it
./contacts

# (optional) check for leaks & invalid memory use under valgrind
valgrind --leak-check=full ./contacts

Sessions / modules this project exercises:

1 · Design — the data model

Before writing code we decide what a record is and where records live. A contact is a small bundle of related fields, which is exactly what a struct is for (see the structs & memory layout demo). The whole collection lives in a dynamic array: a single malloc'd block of Contact values plus two integers — how many slots are used and how many are allocated. When it fills up we realloc to a bigger block. This gives us O(1) append and contiguous, cache-friendly storage, in contrast to the pointer chasing of a linked list.

the record and the store (top of contacts.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define NAME_LEN  64
#define EMAIL_LEN 96
#define LINE_LEN  256

/* One contact record. Fixed-size char arrays keep each record a flat,
   copyable value — no inner pointers to free, which simplifies memory. */
typedef struct {
    int  id;                 /* unique, assigned on insert      */
    char name[NAME_LEN];      /* "Ada Lovelace"                  */
    char email[EMAIL_LEN];     /* "ada@analytical.engine"         */
    int  age;
} Contact;

/* The dynamic store: a growable array of Contact values.
   `count` is how many are in use; `capacity` is how many fit before
   we must grow. `items` is the malloc'd block (NULL when empty). */
typedef struct {
    Contact *items;
    size_t   count;
    size_t   capacity;
    int      next_id;           /* monotonically increasing id source */
} Book;

Module breakdown. The program is organised into small single-purpose functions, grouped by responsibility — the same decomposition the syllabus asks groups to plan and divide among members:

store
book_init, book_reserve, book_add, book_free — own the dynamic array and its growth.
queries
book_find, book_list, book_delete — search and mutate by id.
persistence
book_save, book_load — serialise to / from a CSV file.
input
read_line, read_int — validated, overflow-safe console input.
ui
menu + main — the interactive loop that ties it together.

2 · Step-by-step implementation

We build bottom-up: first the store and its memory management, then queries, then validated input, then file I/O, and finally the menu loop that calls everything.

step 1

The dynamic array: init, grow, append, free

The store starts empty (items == NULL). book_reserve is the one place that calls realloc: it doubles the capacity (starting at 4) only when the array is full, so a run of n appends costs O(n) total. We always assign realloc's result to a temporary first — if it returns NULL the original block is still valid and we have not leaked it. This is the dynamic-memory pattern from Module 2, Session 6.

contacts.c — store

void book_init(Book *b) {
    b->items    = NULL;
    b->count    = 0;
    b->capacity = 0;
    b->next_id  = 1;
}

/* Ensure room for at least `need` items. Returns 1 on success, 0 on
   allocation failure (leaving the existing buffer untouched). */
int book_reserve(Book *b, size_t need) {
    if (need <= b->capacity) return 1;          /* already big enough */

    size_t new_cap = b->capacity ? b->capacity : 4;
    while (new_cap < need) new_cap *= 2;          /* grow geometrically */

    /* realloc into a TEMP so we don't lose the old pointer on failure */
    Contact *tmp = realloc(b->items, new_cap * sizeof(Contact));
    if (tmp == NULL) {
        fprintf(stderr, "out of memory (wanted %zu slots)\n", new_cap);
        return 0;
    }
    b->items    = tmp;
    b->capacity = new_cap;
    return 1;
}

/* Append a copy of `c`, assigning it the next free id.
   Returns the new id, or -1 if the store could not grow. */
int book_add(Book *b, Contact c) {
    if (!book_reserve(b, b->count + 1)) return -1;
    c.id = b->next_id++;
    b->items[b->count++] = c;                     /* struct copy into the slot */
    return c.id;
}

/* Release the whole block and reset to the empty state. Calling
   book_free twice is safe because we NULL the pointer afterwards. */
void book_free(Book *b) {
    free(b->items);
    b->items    = NULL;
    b->count    = 0;
    b->capacity = 0;
}
step 2

Queries: find, list, delete

book_find returns a pointer to the matching record (or NULL), so the caller can read or edit it in place — a direct use of the pointers & memory idea that a pointer is just an address into the array. book_delete removes by id while keeping the array contiguous: it shifts the tail down with memmove rather than leaving a hole. Order is not preserved guarantees here — we keep it simple and stable by shifting.

contacts.c — queries

/* Linear scan for an id. Returns a pointer INTO the array (do not free
   it) or NULL if absent. See the linear-vs-binary search demo. */
Contact *book_find(Book *b, int id) {
    for (size_t i = 0; i < b->count; i++)
        if (b->items[i].id == id)
            return &b->items[i];
    return NULL;
}

void book_list(const Book *b) {
    if (b->count == 0) { puts("  (no contacts yet)"); return; }
    printf("  %-4s %-22s %-28s %s\n", "ID", "NAME", "EMAIL", "AGE");
    for (size_t i = 0; i < b->count; i++) {
        const Contact *c = &b->items[i];
        printf("  %-4d %-22s %-28s %d\n", c->id, c->name, c->email, c->age);
    }
}

/* Delete by id. Returns 1 if a record was removed, else 0. Closes the
   gap with memmove so the array stays contiguous (no tombstones). */
int book_delete(Book *b, int id) {
    for (size_t i = 0; i < b->count; i++) {
        if (b->items[i].id == id) {
            size_t tail = b->count - i - 1;       /* items after i */
            memmove(&b->items[i], &b->items[i + 1],
                    tail * sizeof(Contact));
            b->count--;
            return 1;
        }
    }
    return 0;
}
step 3

Safe console input (validation)

The classic beginner bug is mixing scanf("%d") with fgets and leaving a stray newline in the buffer. We avoid it by reading whole lines with fgets and parsing them ourselves. read_line also strips the trailing '\n' and rejects empty input; read_int uses strtol so we can detect non-numeric junk and out-of-range values instead of silently accepting them.

contacts.c — input

/* Read a trimmed, non-empty line into `buf` (size `cap`). Returns 1 on
   success, 0 on EOF. Extra characters beyond the buffer are discarded. */
int read_line(const char *prompt, char *buf, size_t cap) {
    printf("%s", prompt);
    if (fgets(buf, (int)cap, stdin) == NULL) return 0;   /* EOF / error */

    size_t len = strlen(buf);
    if (len > 0 && buf[len - 1] == '\n') {
        buf[len - 1] = '\0';                       /* chop the newline */
    } else {
        int ch;                                    /* line too long: flush rest */
        while ((ch = getchar()) != '\n' && ch != EOF) { }
    }
    return buf[0] != '\0';                        /* reject empty input */
}

/* Read an integer in [lo, hi]. Re-prompts until valid or EOF (-> def). */
int read_int(const char *prompt, int lo, int hi, int def) {
    char buf[LINE_LEN];
    while (read_line(prompt, buf, sizeof buf)) {
        char *end;
        long v = strtol(buf, &end, 10);
        if (end != buf && *end == '\0' && v >= lo && v <= hi)
            return (int)v;
        printf("  please enter a whole number %d..%d\n", lo, hi);
    }
    return def;                                    /* EOF: fall back to default */
}
step 4

Persistence: save & load a CSV file

This is Module 3, Session 8 in action — opening files, checking the FILE* for NULL, and always fclose-ing. We pick a simple line format: one record per line, fields separated by commas. Saving is a straightforward loop; loading rebuilds the store with the same book_add path so growth and ids are handled for free. Note we re-derive next_id so saved ids are not reused.

contacts.c — persistence

/* Write the whole book to `path` as CSV. Returns 1 on success.
   NOTE: this simple format assumes names/emails contain no commas. */
int book_save(const Book *b, const char *path) {
    FILE *f = fopen(path, "w");
    if (f == NULL) { perror(path); return 0; }   /* check the handle! */

    for (size_t i = 0; i < b->count; i++) {
        const Contact *c = &b->items[i];
        fprintf(f, "%d,%s,%s,%d\n", c->id, c->name, c->email, c->age);
    }
    fclose(f);                                    /* release the OS resource */
    return 1;
}

/* Load records from `path` into an already-initialised book. A missing
   file is not an error (first run) — returns 1 with an empty book. */
int book_load(Book *b, const char *path) {
    FILE *f = fopen(path, "r");
    if (f == NULL) return 1;                     /* no file yet: fine */

    char line[LINE_LEN];
    int max_id = 0;
    while (fgets(line, sizeof line, f)) {
        Contact c = {0};
        /* %[^,] reads up to the next comma; widths guard the buffers */
        int n = sscanf(line, "%d,%63[^,],%95[^,],%d",
                       &c.id, c.name, c.email, &c.age);
        if (n == 4) {
            book_reserve(b, b->count + 1);
            b->items[b->count++] = c;
            if (c.id > max_id) max_id = c.id;
        }
    }
    fclose(f);
    b->next_id = max_id + 1;                       /* don't reuse loaded ids */
    return 1;
}
step 5

The menu loop & main

Finally the menu loop: print options, read a choice with the validated read_int, dispatch with a switch, and repeat until the user quits. main owns the single Book on the stack, loads any saved data at start, and — crucially — book_save + book_free on the way out so there are no leaks and the data persists.

contacts.c — ui + main

#define DB_PATH "contacts.csv"

void do_add(Book *b) {
    Contact c = {0};
    if (!read_line("  name : ", c.name,  sizeof c.name))  return;
    if (!read_line("  email: ", c.email, sizeof c.email)) return;
    c.age = read_int("  age  : ", 0, 150, 0);
    int id = book_add(b, c);
    if (id < 0) puts("  could not add (out of memory)");
    else        printf("  added contact #%d\n", id);
}

void do_find(Book *b) {
    int id = read_int("  id to find: ", 1, 1000000, -1);
    Contact *c = book_find(b, id);
    if (c) printf("  #%d  %s <%s>  age %d\n", c->id, c->name, c->email, c->age);
    else   puts("  no contact with that id");
}

void menu(void) {
    puts("\n=== contact manager ===");
    puts("  1) add     2) list    3) find");
    puts("  4) delete  5) save    0) quit");
}

int main(void) {
    Book book;
    book_init(&book);
    book_load(&book, DB_PATH);
    printf("loaded %zu contact(s) from %s\n", book.count, DB_PATH);

    int running = 1;
    while (running) {
        menu();
        int choice = read_int("  > ", 0, 5, 0);
        switch (choice) {
            case 1: do_add(&book); break;
            case 2: book_list(&book); break;
            case 3: do_find(&book); break;
            case 4: {
                int id = read_int("  id to delete: ", 1, 1000000, -1);
                puts(book_delete(&book, id) ? "  deleted" : "  not found");
                break;
            }
            case 5:
                if (book_save(&book, DB_PATH))
                    printf("  saved %zu contact(s)\n", book.count);
                break;
            case 0: running = 0; break;
        }
    }

    book_save(&book, DB_PATH);                     /* persist on exit  */
    book_free(&book);                              /* free the buffer  */
    puts("bye.");
    return 0;
}
Compiles clean. With gcc -std=c11 -Wall -Wextra the five steps above form a complete program with no warnings. Paste them into contacts.c in the order shown (store → queries → input → persistence → ui/main).

3 · Results — a sample run

A full terminal session: we start with no data file, add two contacts, list them, look one up, delete one, save, and quit. On the second run the saved contact is loaded back automatically — proving persistence works.

$ gcc -std=c11 -Wall -Wextra -O2 -o contacts contacts.c
$ ./contacts
loaded 0 contact(s) from contacts.csv

=== contact manager ===
  1) add     2) list    3) find
  4) delete  5) save    0) quit
  > 1
  name : Ada Lovelace
  email: ada@analytical.engine
  age  : 36
  added contact #1

=== contact manager ===
  ...
  > 1
  name : Alan Turing
  email: alan@bombe.uk
  age  : 41
  added contact #2

=== contact manager ===
  ...
  > 2
  ID   NAME                   EMAIL                        AGE
  1    Ada Lovelace           ada@analytical.engine        36
  2    Alan Turing            alan@bombe.uk                41

  > 3
  id to find: 2
  #2  Alan Turing <alan@bombe.uk>  age 41

  > 4
  id to delete: 1
  deleted

  > 5
  saved 1 contact(s)

  > 0
bye.

# second run — data survived on disk:
$ ./contacts
loaded 1 contact(s) from contacts.csv
  > 2
  ID   NAME                   EMAIL                        AGE
  2    Alan Turing            alan@bombe.uk                41

# the CSV file on disk:
$ cat contacts.csv
2,Alan Turing,alan@bombe.uk,41

4 · Memory-safety notes

Most "mysterious" C bugs are memory bugs. This project follows a handful of rules that, applied consistently, eliminate the common ones — the discipline emphasised across Module 2.

Known simplification. The CSV format assumes names and emails contain no commas or newlines. A production version would quote/escape fields (see Extensions). This is exactly the kind of limitation the syllabus asks groups to document.

5 · Mapping to learning outcomes

How each part of the project lands on the syllabus modules and the matching interactive demo:

Project featureSyllabus module / sessionDemo
menu loop, printf/scanf, control flowModule 0 — C basics & I/O (S1–2) variable tracing, loops
book_find returns a pointer into the arrayModule 2 — Pointers (S5) pointers & memory
malloc / realloc / free, growable bufferModule 2 — Dynamic Memory (S6) pointers & memory
Contact & Book structs, typedefModule 2 — Structures & Unions (S7) structs & memory layout
book_save / book_load CSV, fopen/fcloseModule 3 — File I/O (S8)
contiguous store vs. node-based storeModule 4 — Linked Lists (S13) linked lists
linear search in book_findalgorithms / Module 0 linear vs binary search
Module 0 · S1–2 Module 2 · S5–7 Module 3 · S8 Module 4 · S13 full course program →

6 · Extensions

Natural next steps that reuse the same data model and reinforce later course topics — good candidates for the group project's "moderate complexity":

7 · References