C++ PROGRAMMING · 75 MIN STUDY

Capstone: build a tested library catalogue CLI

Build a small but complete C++17 command-line application with multiple source files, safe input, STL containers, file persistence, CMake, and repeatable tests.

Project brief and prerequisites

This capstone assumes you have completed functions, structs/classes, vectors or maps, file I/O, exception handling, CMake, and CTest. Build a local library catalogue that can add a book, search by ID, list books, mark a book available or borrowed, and save records to disk. It is a learning project, not a production lending system.

Requirements and design

  • Each book has a positive integer ID, title, and borrowed flag.
  • IDs are unique. Titles cannot be empty or contain the chosen file delimiter.
  • The application loads a data file at startup and saves after successful mutations.
  • Malformed records are reported; they must not silently become valid records.
  • Business rules are testable without terminal input.

Prerequisites: classes, maps, vectors, exceptions, file I/O, CMake, and unit testing. Review Classes, Maps and sets, File I/O, CMake builds, and CTest first. The capstone uses C++17.

Project structure

library-cli/
  CMakeLists.txt
  include/library.hpp
  src/library.cpp
  src/main.cpp
  tests/library_tests.cpp

1. Domain interface — include/library.hpp

#pragma once
#include <map>
#include <string>
#include <vector>
struct Book { int id; std::string title; bool borrowed; };
class Library {
public:
    bool add(Book book);
    bool borrow(int id);
    bool give_back(int id);
    const Book* find(int id) const;
    std::vector<Book> list() const;
    bool load(const std::string& path, std::string& error);
    bool save(const std::string& path, std::string& error) const;
private:
    std::map<int, Book> books_;
};

The map enforces one record per ID and keeps listing ordered by ID. A pointer returned by find is non-owning and should not be retained across operations that erase or replace the corresponding element. This interface does not expose mutable access to internal records.

2. Implement the rules — src/library.cpp

#include "library.hpp"
#include <charconv>
#include <fstream>
#include <sstream>
#include <system_error>
#include <utility>

bool Library::add(Book book) {
    if (book.id <= 0 || book.title.empty() ||
        book.title.find('|') != std::string::npos ||
        book.title.find('\r') != std::string::npos ||
        book.title.find('\n') != std::string::npos) return false;
    return books_.emplace(book.id, std::move(book)).second;
}
bool Library::borrow(int id) {
    auto it = books_.find(id);
    if (it == books_.end() || it->second.borrowed) return false;
    it->second.borrowed = true; return true;
}
bool Library::give_back(int id) {
    auto it = books_.find(id);
    if (it == books_.end() || !it->second.borrowed) return false;
    it->second.borrowed = false; return true;
}
const Book* Library::find(int id) const {
    auto it = books_.find(id);
    return it == books_.end() ? nullptr : &it->second;
}
std::vector<Book> Library::list() const {
    std::vector<Book> result;
    for (const auto& entry : books_) result.push_back(entry.second);
    return result;
}
bool Library::load(const std::string& path, std::string& error) {
    std::ifstream in(path);
    if (!in) { error = "Could not open data file"; return false; }
    std::map<int, Book> loaded;
    std::string line;
    std::size_t line_no = 0;
    while (std::getline(in, line)) {
        ++line_no;
        std::istringstream row(line);
        std::string id_text, title, status;
        if (!std::getline(row, id_text, '|') || !std::getline(row, title, '|') || !std::getline(row, status)) {
            error = "Malformed record at line " + std::to_string(line_no); return false;
        }
        int id{};
        const char* first = id_text.data();
        const char* last = first + id_text.size();
        const auto parsed = std::from_chars(first, last, id);
        if (parsed.ec != std::errc{} || parsed.ptr != last) {
            error = "Invalid ID at line " + std::to_string(line_no);
            return false;
        }
        if (id <= 0 || title.empty() ||
            title.find('|') != std::string::npos ||
            title.find('\r') != std::string::npos ||
            (status != "0" && status != "1") || loaded.count(id)) {
            error = "Invalid or duplicate record at line " + std::to_string(line_no); return false;
        }
        loaded.emplace(id, Book{id, title, status == "1"});
    }
    if (in.bad()) { error = "Read error"; return false; }
    books_ = std::move(loaded); return true;
}
bool Library::save(const std::string& path, std::string& error) const {
    std::ofstream out(path, std::ios::trunc);
    if (!out) { error = "Could not open data file for writing"; return false; }
    for (const auto& entry : books_) out << entry.second.id << '|' << entry.second.title << '|' << (entry.second.borrowed ? 1 : 0) << '\n';
    out.flush();
    if (!out) { error = "Write or flush failed"; return false; }
    out.close();
    if (out.fail()) { error = "Close failed"; return false; }
    return true;
}

This simple text format is deliberately constrained: the title cannot contain | or a newline. A real application should use a properly escaped or structured serialization format and safer atomic file replacement. Loading uses a temporary map, so a malformed file does not partially overwrite the current in-memory catalogue.

3. Terminal interface — src/main.cpp

#include "library.hpp"
#include <charconv>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include <system_error>
bool parse_id(const std::string& text, int& id) {
    auto r = std::from_chars(text.data(), text.data() + text.size(), id);
    return r.ec == std::errc{} && r.ptr == text.data() + text.size();
}
int main(int argc, char* argv[]) {
    const std::string file = argc > 1 ? argv[1] : "library.txt";
    Library library; std::string error;
    std::error_code file_error;
    const bool file_exists = std::filesystem::exists(file, file_error);
    if (file_error) { std::cerr << file_error.message() << '\n'; return 1; }
    if (file_exists && !library.load(file, error)) {
        std::cerr << error << '\n';
        return 1;
    }
    for (;;) {
        std::cout << "\n1 Add  2 List  3 Search  4 Borrow  5 Return  0 Exit\nChoice: ";
        std::string line; if (!std::getline(std::cin, line)) break;
        int choice{}; if (!parse_id(line, choice)) { std::cout << "Enter a whole-number choice.\n"; continue; }
        if (choice == 0) break;
        if (choice == 1) {
            std::string id_line, title; int id{};
            std::cout << "ID: "; if (!std::getline(std::cin, id_line)) break;
            std::cout << "Title: "; if (!std::getline(std::cin, title)) break;
            if (!parse_id(id_line, id) || !library.add(Book{id, title, false})) std::cout << "Invalid or duplicate book.\n";
            else if (!library.save(file, error)) { std::cerr << error << '\n'; return 1; }
        } else if (choice == 2) {
            for (const auto& book : library.list()) std::cout << book.id << " | " << book.title << " | " << (book.borrowed ? "borrowed" : "available") << '\n';
        } else if (choice == 3 || choice == 4 || choice == 5) {
            std::string id_line; int id{}; std::cout << "ID: ";
            if (!std::getline(std::cin, id_line)) break;
            if (!parse_id(id_line, id)) { std::cout << "Invalid ID.\n"; continue; }
            if (choice == 3) { const Book* b = library.find(id); if (b) std::cout << b->title << '\n'; else std::cout << "Not found.\n"; }
            else { bool changed = choice == 4 ? library.borrow(id) : library.give_back(id); if (!changed) std::cout << "Operation not allowed.\n"; else if (!library.save(file, error)) { std::cerr << error << '\n'; return 1; } }
        } else std::cout << "Unknown choice.\n";
    }
    return 0;
}

Include correction: because main.cpp opens an std::ifstream, add #include <fstream> with the other headers. The snippet is otherwise C++17.

4. Build configuration — CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(library_cli LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_library(library src/library.cpp)
target_include_directories(library PUBLIC include)
add_executable(library_cli src/main.cpp)
target_link_libraries(library_cli PRIVATE library)
enable_testing()
add_executable(library_tests tests/library_tests.cpp)
target_link_libraries(library_tests PRIVATE library)
add_test(NAME library_tests COMMAND library_tests)

5. Tests — tests/library_tests.cpp

#include "library.hpp"
#include <filesystem>
#include <fstream>
#include <iostream>
#include <system_error>

int main() {
    bool passed = true;
    auto check = [&passed](bool condition, const char* name) {
        if (!condition) {
            std::cerr << "FAILED: " << name << '\n';
            passed = false;
        }
    };

    Library lib;
    check(lib.add(Book{1, "C++ Primer", false}), "add valid book");
    check(!lib.add(Book{1, "Duplicate", false}), "reject duplicate ID");
    check(!lib.add(Book{0, "Bad ID", false}), "reject non-positive ID");
    check(lib.borrow(1), "borrow available book");
    check(!lib.borrow(1), "reject second borrow");
    check(lib.give_back(1), "return borrowed book");
    check(!lib.give_back(1), "reject return when not borrowed");
    check(lib.find(1) != nullptr, "find existing book");
    check(lib.find(99) == nullptr, "missing book returns null");

    const auto temp_path = std::filesystem::temp_directory_path() / "openlearn-library-capstone-test.txt";
    const std::string temp_file = temp_path.string();
    std::string error;
    check(lib.save(temp_file, error), "save catalogue");
    Library loaded;
    check(loaded.load(temp_file, error), "load saved catalogue");
    const Book* saved = loaded.find(1);
    check(saved != nullptr && saved->title == "C++ Primer" && !saved->borrowed,
          "persistence round trip");

    {
        std::ofstream malformed(temp_file, std::ios::trunc);
        malformed << "not-an-id|Bad record|0\n";
    }
    Library unchanged;
    check(unchanged.add(Book{9, "Keep this record", false}), "prepare existing state");
    check(!unchanged.load(temp_file, error), "reject malformed saved record");
    check(unchanged.find(9) != nullptr, "failed load preserves existing state");

    std::error_code cleanup_error;
    std::filesystem::remove(temp_path, cleanup_error);
    return passed ? 0 : 1;
}

These checks remain active in Debug and Release builds. Unlike the standard assert macro, they are not removed when NDEBUG is defined. They exercise both the domain rules and persistence round trips without depending on terminal input. The malformed-file test verifies that a failed load does not partially replace the existing in-memory catalogue.

Build and run

cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
./build/library_cli

On Windows, the executable may be named library_cli.exe. The exact path depends on the generator and configuration.

Complexity and design review

  • add, find, borrow, and give_back use std::map: O(log n) lookup/insertion.
  • list copies n records: O(n) time and O(n) additional space.
  • File load and save are O(n) in the number of records, excluding variable-length text costs.
  • The file format is not safe for arbitrary titles; it is a deliberately limited teaching format.

Manual acceptance checklist

  1. Add two books with different IDs and list them.
  2. Try a duplicate ID, zero ID, empty title, and title containing |.
  3. Borrow an available book, then try borrowing it again.
  4. Return a borrowed book, then try returning it again.
  5. Restart the program and confirm saved records reload.
  6. Corrupt one line of the data file and confirm loading reports an error rather than silently accepting it.
  7. Run CTest and confirm tests pass.

Limitations and next steps

This program is a single-user local demonstration. It has no authentication, concurrent access protection, backups, transaction log, atomic save/replace, Unicode normalization, or robust serialization. Do not use it to manage real library circulation. Extend it by adding update/delete, file-format escaping, persistence tests, and a dedicated test framework.

Practice

  1. Why does Library::load parse into a temporary map?
  2. Why is the map private?
  3. Why should the menu not contain all business rules?
Show reasoned answers
  1. So a malformed file cannot leave the live object half-updated.
  2. To protect invariants and make all changes pass through validated operations.
  3. Separating interaction from domain logic allows repeatable unit tests and reuse.

Summary

You have a multi-file C++17 program with a domain model, input boundary, file persistence, build configuration, and tests. The important result is not the menu: it is the separation of responsibilities and the ability to test business rules independently.

References