A complete inventory management CLI needs four things: a model for the records, a repository that owns them and persists them, a command loop that turns user input into operations on the repository, and the surrounding structure — tests, a build file, error handling and documentation — that makes it something other than a script. Here is all four, built up in the order a real project would build them.
The model
Everything else depends on the record type, so it comes first. An inventory record needs an identifier, a name, a quantity and a price, and it needs to be able to say whether it is valid.
#include
#include
struct Item {
std::string sku;
std::string name;
int quantity = 0;
std::int64_t price_cents = 0;
bool is_valid() const {
if (sku.empty() || name.empty()) {
return false;
}
if (quantity < 0 || price_cents < 0) {
return false;
}
return true;
}
std::int64_t total_cents() const {
return static_cast(quantity) * price_cents;
}
};The price is stored in whole cents as a 64-bit integer. That single decision removes a whole category of rounding bug, and the .total_cents() helper makes the multiplication happen in a wide enough type to be safe.
The validity check lives with the data, because it is a rule about the data. A caller cannot forget it, and the repository can use it as a gate.
The repository
The repository owns the collection, performs the operations, and knows how to save and load itself.
#include
#include
#include
#include
#include
#include
class Inventory {
public:
bool add(const Item& item, std::string& error) {
if (!item.is_valid()) {
error = "the item is not valid";
return false;
}
if (find(item.sku) != nullptr) {
error = "an item with that SKU already exists";
return false;
}
items_.push_back(item);
return true;
}
bool remove(const std::string& sku) {
auto it = std::find_if(items_.begin(), items_.end(),
[&sku](const Item& item) { return item.sku == sku; });
if (it == items_.end()) {
return false;
}
items_.erase(it);
return true;
}
const Item* find(const std::string& sku) const {
for (const Item& item : items_) {
if (item.sku == sku) {
return &item;
}
}
return nullptr;
}
std::vector sorted_by_name() const {
std::vector copy = items_;
std::sort(copy.begin(), copy.end(),
[](const Item& left, const Item& right) { return left.name < right.name; });
return copy;
}
std::int64_t total_value_cents() const {
std::int64_t total = 0;
for (const Item& item : items_) {
total += item.total_cents();
}
return total;
}
std::size_t size() const { return items_.size(); }
const std::vector& items() const { return items_; }
bool save(const std::string& path, std::string& error) const {
std::ofstream file(path);
if (!file) {
error = "could not open " + path + " for writing";
return false;
}
for (const Item& item : items_) {
file << item.sku << '|' << item.name << '|' << item.quantity
<< '|' << item.price_cents << '\n';
}
if (!file) {
error = "could not write to " + path;
return false;
}
return true;
}
bool load(const std::string& path, std::string& error) {
std::ifstream file(path);
if (!file) {
error = "could not open " + path + " for reading";
return false;
}
items_.clear();
std::string line;
while (std::getline(file, line)) {
Item item = parse_line(line);
if (item.is_valid()) {
items_.push_back(item);
}
}
return true;
}
private:
static Item parse_line(const std::string& line) {
Item item;
std::size_t first = line.find('|');
std::size_t second = line.find('|', first == std::string::npos ? 0 : first + 1);
std::size_t third = line.find('|', second == std::string::npos ? 0 : second + 1);
if (first == std::string::npos || second == std::string::npos || third == std::string::npos) {
return item;
}
item.sku = line.substr(0, first);
item.name = line.substr(first + 1, second - first - 1);
item.quantity = std::atoi(line.c_str() + second + 1);
item.price_cents = std::atoll(line.c_str() + third + 1);
return item;
}
std::vector items_;
};Four design decisions are worth naming. The repository returns a bool and fills an error string rather than throwing, because a failed command is an ordinary outcome rather than an exceptional one. The .find() method returns a pointer, which may be nullptr, and the caller must check it. The .sorted_by_name() method returns a copy so that the caller cannot accidentally reorder the internal storage. And .load() skips malformed lines rather than refusing the whole file, which is what you want when importing real data.
The command loop
The loop turns a line of text into a command and an action. It is the part of the program that deals with the outside world, so it is where validation lives.
#include
#include
#include
#include
std::vector split_words(const std::string& line) {
std::vector words;
std::istringstream stream(line);
std::string word;
while (stream >> word) {
words.push_back(word);
}
return words;
}
int main() {
Inventory inventory;
std::string error;
inventory.load("inventory.csv", error);
std::string line;
while (std::cout << "> " && std::getline(std::cin, line)) {
std::vector words = split_words(line);
if (words.empty()) {
continue;
}
const std::string& command = words[0];
if (command == "quit" || command == "exit") {
break;
}
if (command == "add") {
if (words.size() < 4) {
std::cout << "usage: add \n";
continue;
}
Item item;
item.sku = words[1];
item.name = words[2];
item.quantity = std::atoi(words[3].c_str());
if (inventory.add(item, error)) {
std::cout << "added " << item.sku << '\n';
} else {
std::cout << "error: " << error << '\n';
}
continue;
}
if (command == "list") {
for (const Item& item : inventory.sorted_by_name()) {
std::cout << item.sku << ' ' << item.name << ' ' << item.quantity << '\n';
}
continue;
}
if (command == "remove") {
if (words.size() < 2) {
std::cout << "usage: remove \n";
continue;
}
std::cout << (inventory.remove(words[1]) ? "removed\n" : "not found\n");
continue;
}
if (command == "save") {
std::cout << (inventory.save("inventory.csv", error) ? "saved\n" : error) << '\n';
continue;
}
std::cout << "unknown command: " << command << '\n';
}
return 0;
}The loop reads a line, splits it into words, and dispatches on the first word. Every command checks its own argument count and reports a usage message rather than reading past the end of the array. Unknown commands produce a message and the loop continues, so a typo never ends the session.
Testing the core
The repository is where the interesting behaviour lives, and it is easy to test because it does not touch the terminal.
#include
#include
int main() {
int passed = 0;
// A valid item can be added.
{
Inventory inventory;
std::string error;
Item item{"A-100", "Apples", 5, 250};
assert(inventory.add(item, error));
assert(inventory.size() == 1);
++passed;
}
// A duplicate SKU is rejected.
{
Inventory inventory;
std::string error;
Item item{"A-100", "Apples", 5, 250};
assert(inventory.add(item, error));
assert(!inventory.add(item, error));
++passed;
}
// An invalid item is rejected.
{
Inventory inventory;
std::string error;
Item item{"", "Apples", 5, 250};
assert(!inventory.add(item, error));
++passed;
}
// Removing reports whether anything was removed.
{
Inventory inventory;
std::string error;
Item item{"A-100", "Apples", 5, 250};
inventory.add(item, error);
assert(inventory.remove("A-100"));
assert(!inventory.remove("A-100"));
++passed;
}
// The total value is the sum of the line totals.
{
Inventory inventory;
std::string error;
inventory.add(Item{"A-100", "Apples", 5, 250}, error);
inventory.add(Item{"B-200", "Bread", 2, 300}, error);
assert(inventory.total_value_cents() == 1850);
++passed;
}
std::cout << passed << " tests passed\n";
return 0;
}The output shows all five tests passing:
5 tests passedNote that the tests never touch the terminal, and each one sets up its own inventory. The only shared thing is the code under test, which is exactly what you want.
The build and the project layout
The program splits cleanly into a library and an executable, and the tests link against the library.
inventory/
├── CMakeLists.txt
├── include/
│ └── inventory.hpp
├── src/
│ ├── inventory.cpp
│ └── main.cpp
├── tests/
│ ├── CMakeLists.txt
│ └── test_inventory.cpp
└── build/The top-level build file describes the two targets and the tests.
cmake_minimum_required(VERSION 3.16)
project(inventory LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(inventory_core STATIC src/inventory.cpp)
target_include_directories(inventory_core PUBLIC include)
add_executable(inventory_cli src/main.cpp)
target_link_libraries(inventory_cli PRIVATE inventory_core)
enable_testing()
add_subdirectory(tests)And the workflow is three commands.
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failureWhat a complete program still needs
The code above is a working tool, but a program people will actually use needs a few more things, and each of them is small.
Usage text. A program that takes commands should be able to explain itself. A help command that prints the available commands costs five lines.
Input validation that is honest. The loop above accepts a quantity of "-5" and passes it to std::atoi, which returns -5, which the validity check then rejects. That works, but it happens by accident rather than by design. Parsing the number with a helper that returns an std::optional makes the rule explicit.
A non-zero exit status. The loop always returns zero. A program that could not save its data on the way out should return one.
A log. Every command that changes the inventory should leave a trace, which is what makes the tool usable by two people at once.
Documentation. A short README that says what the file format is, how to build it and what each command does.
The checklist for the capstone
- The model — a plain struct with the data, and the validity rules next to it.
- The repository — owns the collection, returns a status and an error message, and never throws for an ordinary failure.
- The commands — a loop that parses a line, validates the argument count and dispatches.
- The persistence — a simple text format, written and read through the repository.
- The tests — one behaviour per test, no terminal, no files where you can avoid them.
- The build — a library target, an executable target and a test target in CMake.
- The polish — usage text, honest exit codes and a written format specification.
Quick reference
- Keep the model free of behaviour that needs the outside world.
- The repository owns the data and reports failures with a status and a message.
- The command loop validates the argument count before it indexes anything.
- Return copies from sorting methods so the internal order cannot be changed by accident.
- Test the repository, not the terminal; inject the outside world where you can.
- Split the build into a library, an executable and tests, and let CMake wire them together.
Try it yourself: build the four pieces in order — the model, the repository, the command loop and the tests — and get the tests passing before you write the loop. Then add the three missing features the capstone lists: a help command, an optional-based parser for the quantity and a non-zero exit status when the save fails. Finish by adding a log file that records every change, and check that reloading the saved file reproduces the same inventory.