Help improve SovranCode?

Google Analytics can measure anonymous usage after you choose Allow. Essential account and progress features work either way.

SovranCode
C: Foundations for Systems Programming Project: Command-Line Inventory Manager
This device
Course contentsProject: Command-Line Inventory Manager · 15 titles

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
C: Foundations for Systems Programming15 complete lessons

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
PREVIOUS LESSONBuild Tools and Makefiles
COURSE COMPLETEC: Foundations for Systems Programming
Capstone project 600 min

Project: Command-Line Inventory Manager

Build a terminal application that manages real inventory records safely. This capstone connects the course: a command parser calls a small public API; the inventory owns dynamic memory; a CSV module persists data; every failure path has a cleanup rule; tests verify the modules independently; and Make reproduces the build.

Definition of done

Users can add, list, find, adjust, remove, import, and export inventory. Invalid input never corrupts existing records. A failed save never replaces the last known-good data file. The project builds warning-free, passes tests under sanitizers, and has a documented command interface.

Product brief and command contract

inventory add    --sku SKU --name NAME --quantity N --price CENTS
inventory list   [--low-stock N]
inventory find   --sku SKU
inventory adjust --sku SKU --delta N
inventory remove --sku SKU
inventory import --file inventory.csv
inventory export --file inventory.csv

# Example
inventory add --sku BK-001 --name "C Programming" --quantity 12 --price 2499

Keep money in integer cents, not double. A SKU is a unique stable identifier; names are display text. Quantity and price have explicit ranges. Every command returns 0 on success, 2 for usage/input errors, and 1 for operational failures such as I/O or allocation. Print user-facing errors to stderr; print successful report data to stdout.

ADD

Unique SKU

Reject duplicates before changing the collection. On allocation failure, leave the collection unchanged.

ADJUST

Never negative

Check signed addition for overflow and reject a quantity below zero.

IMPORT

All-or-nothing

Parse into a temporary collection, validate every row, then replace live data only after success.

Architecture: narrow public APIs and clear ownership

inventory/
├── include/
│   ├── inventory.h     # record collection API
│   ├── csv.h           # import/export contract
│   └── parse.h         # strict argument conversion helpers
├── src/
│   ├── main.c          # command dispatch and exit codes
│   ├── inventory.c     # dynamic collection, search, invariants
│   ├── csv.c           # file parsing and atomic save
│   └── parse.c         # string → checked integer/price conversion
├── tests/              # module and CLI integration tests
└── Makefile

main.c owns process-level orchestration only. inventory.c owns the dynamically allocated item array. csv.c borrows a collection to export and transfers a freshly loaded collection only on successful import. Keep file parsing out of the data structure module; that makes the core collection testable without files.

Data model and collection invariants

enum { SKU_CAPACITY = 32, NAME_CAPACITY = 96 };

struct Item {
  char sku[SKU_CAPACITY];
  char name[NAME_CAPACITY];
  int quantity;
  long price_cents;
};

struct Inventory {
  struct Item *items;
  size_t length;
  size_t capacity;
};

/* Invariants:
   0 <= length <= capacity
   items == NULL exactly when capacity == 0
   every stored SKU is non-empty and unique
   every quantity >= 0; every price_cents >= 0 */

Initialize with struct Inventory inventory = {0};. Any mutating public function either preserves all invariants and returns success, or leaves the inventory exactly as it was and returns failure. That transaction-like rule makes caller behavior simple and prevents partial imports or half-grown arrays.

Implement the core API before the CLI

/* inventory.h */
int inventory_add(struct Inventory *inventory, const struct Item *item);
const struct Item *inventory_find(const struct Inventory *inventory, const char *sku);
int inventory_adjust(struct Inventory *inventory, const char *sku, int delta);
int inventory_remove(struct Inventory *inventory, const char *sku);
void inventory_destroy(struct Inventory *inventory);

/* Return convention: 1 success, 0 expected failure.
   Callers decide how to describe the error to users. */
int inventory_add(struct Inventory *inventory, const struct Item *item) {
  if (inventory == NULL || item == NULL || item->sku[0] == '\0') return 0;
  if (inventory_find(inventory, item->sku) != NULL) return 0;
  if (item->quantity < 0 || item->price_cents < 0) return 0;

  if (inventory->length == inventory->capacity) {
    size_t next = inventory->capacity == 0 ? 8 : inventory->capacity * 2;
    if (next < inventory->capacity || next > SIZE_MAX / sizeof *inventory->items) return 0;
    struct Item *grown = realloc(inventory->items, next * sizeof *grown);
    if (grown == NULL) return 0;
    inventory->items = grown;
    inventory->capacity = next;
  }
  inventory->items[inventory->length++] = *item;
  return 1;
}

This API copies the item value into collection-owned storage, so the caller retains no ownership obligation for strings embedded in the fixed-size structure. Validate string capacity before creating the item; never use unbounded strcpy.

Parse input strictly, then validate the domain

atoi cannot distinguish invalid input from zero and provides no overflow signal. Parse numbers with strtol, require complete consumption, check errno, then validate your domain range. Keep that logic in one helper so every command uses the same policy.

int parse_nonnegative_int(const char *text, int *out) {
  char *end = NULL;
  errno = 0;
  long value = strtol(text, &end, 10);
  if (errno == ERANGE || end == text || *end != '\0') return 0;
  if (value < 0 || value > INT_MAX) return 0;
  *out = (int)value;
  return 1;
}

Separate syntax from domain rules: -3 is syntactically a number but is invalid for quantity; a 200-character name is text but invalid for a NAME_CAPACITY field. Write a validator that reports which contract failed, then do not mutate inventory until every command argument is valid.

CSV persistence without corrupting the live collection

Use a documented simple CSV subset for this project: UTF-8 text, one item per line, fields sku,name,quantity,price_cents, no quoted commas. Reject a row with the wrong field count, empty SKU, too-long text, duplicate SKU, invalid integer, or trailing junk. A richer CSV grammar is a separate project; pretending a split-on-comma parser supports it is a data bug.

# inventory.csv
BK-001,C Programming,12,2499
KB-002,Mechanical Keyboard,4,7999

int csv_load(const char *path, struct Inventory *out) {
  struct Inventory temporary = {0};
  /* open → read bounded lines → parse → inventory_add(&temporary, ...) */
  /* on any error: inventory_destroy(&temporary); return 0; */
  /* only after all rows are valid: inventory_destroy(out); *out = temporary; */
}

For export, write to a temporary file in the same directory, check every write and close, then rename it to the destination only after the temporary output is complete. This protects a previously valid inventory from a crash or full disk during save. Platform-specific atomic-rename details belong behind a small persistence API and must be documented for the target system.

Test the contracts, not just happy-path commands

# Unit tests for inventory.c
- add first item; find it; collection length becomes 1
- reject duplicate SKU; length and existing item remain unchanged
- grow past initial capacity; all earlier items remain intact
- reject negative result from adjust; quantity remains unchanged
- remove first, middle, and last item; then destroy twice safely

# CSV tests
- round-trip a valid temporary file
- malformed field count, too-long name, duplicate SKU, invalid number
- failed import leaves the live collection unchanged

# CLI tests
- wrong option combination exits 2 and writes usage to stderr
- successful list writes stable report text to stdout

Run module tests in a sanitizer configuration. Add boundary values: empty inventory, capacity zero, exactly maximum name length, one byte too long, INT_MAX, and a value that would overflow an adjustment. Every fixed bug gets a test with the smallest reproducing input.

Step 1: design command dispatch before parsing options

First identify the command word, then send the remaining arguments to exactly one command handler. This prevents an ever-growing main function where every flag is meaningful in every command. Each handler receives only its own argument slice and returns an exit status.

enum ExitCode { EXIT_OK = 0, EXIT_OPERATION = 1, EXIT_USAGE = 2 };

static int command_add(int argc, char *argv[], struct Inventory *inventory);
static int command_list(int argc, char *argv[], const struct Inventory *inventory);
static int command_adjust(int argc, char *argv[], struct Inventory *inventory);

int dispatch(int argc, char *argv[], struct Inventory *inventory) {
  if (argc < 2) return usage(stderr);
  const char *command = argv[1];
  if (strcmp(command, "add") == 0) return command_add(argc - 2, argv + 2, inventory);
  if (strcmp(command, "list") == 0) return command_list(argc - 2, argv + 2, inventory);
  if (strcmp(command, "adjust") == 0) return command_adjust(argc - 2, argv + 2, inventory);
  fprintf(stderr, "unknown command: %s\n", command);
  return EXIT_USAGE;
}

A handler must not call exit deep inside library code. Returning an explicit status keeps the handler testable and ensures main can destroy the inventory and close resources once, at one cleanup point.

Step 2: parse options with a complete-argument contract

For a small project, a local linear option parser is clearer than a generic abstraction. Track whether each required option was seen, reject duplicates, reject a missing value, and reject extra positional arguments. Do not accept a partial item and silently default missing fields.

struct AddArguments { const char *sku; const char *name; const char *quantity; const char *price; };

static int parse_add_arguments(int argc, char *argv[], struct AddArguments *out) {
  *out = (struct AddArguments){0};
  for (int index = 0; index < argc; index += 2) {
    if (index + 1 >= argc) return 0; /* flag without a value */
    const char *flag = argv[index];
    const char *value = argv[index + 1];
    if (strcmp(flag, "--sku") == 0 && out->sku == NULL) out->sku = value;
    else if (strcmp(flag, "--name") == 0 && out->name == NULL) out->name = value;
    else if (strcmp(flag, "--quantity") == 0 && out->quantity == NULL) out->quantity = value;
    else if (strcmp(flag, "--price") == 0 && out->price == NULL) out->price = value;
    else return 0; /* unknown or repeated option */
  }
  return out->sku != NULL && out->name != NULL && out->quantity != NULL && out->price != NULL;
}

Keep the raw strings until all flags are present. Then validate lengths, parse numbers, and create a fully valid Item on the stack. Only call inventory_add after that construction succeeds.

Step 3: implement adjustment and removal transactionally

Adjustment needs two distinct checks: the SKU must exist, and the proposed quantity must be representable and non-negative. Removal can keep the collection contiguous by moving the final item into the removed slot. That makes removal O(1), but it deliberately does not preserve list order—document that decision.

int inventory_adjust(struct Inventory *inventory, const char *sku, int delta) {
  if (inventory == NULL || sku == NULL) return 0;
  for (size_t index = 0; index < inventory->length; index++) {
    struct Item *item = &inventory->items[index];
    if (strcmp(item->sku, sku) != 0) continue;
    if ((delta > 0 && item->quantity > INT_MAX - delta) ||
        (delta < 0 && item->quantity < -delta)) return 0;
    item->quantity += delta;
    return 1;
  }
  return 0;
}

int inventory_remove(struct Inventory *inventory, const char *sku) {
  if (inventory == NULL || sku == NULL) return 0;
  for (size_t index = 0; index < inventory->length; index++) {
    if (strcmp(inventory->items[index].sku, sku) != 0) continue;
    inventory->length -= 1;
    inventory->items[index] = inventory->items[inventory->length];
    return 1;
  }
  return 0;
}
Choose ordering explicitly

If users expect stable listing order, use memmove to close the gap instead of the final-item swap, or sort only when printing. Never accidentally promise stable order in one command and break it in another.

Step 4: parse file lines as bounded data

Read into a fixed line buffer that is one byte larger than the largest valid line. Detect a missing newline: it means either an overlong record or a final line that needs deliberate handling. Split exactly three commas for this simplified format, reject additional commas, and validate each resulting field with the same helpers used by the CLI.

enum { CSV_LINE_CAPACITY = SKU_CAPACITY + NAME_CAPACITY + 64 };

static int split_row(char line[], char **sku, char **name, char **quantity, char **price) {
  char *fields[4] = { line, NULL, NULL, NULL };
  size_t field = 0;
  for (char *cursor = line; *cursor != '\0'; cursor++) {
    if (*cursor != ',') continue;
    if (++field == 4) return 0; /* too many fields */
    *cursor = '\0';
    fields[field] = cursor + 1;
  }
  if (field != 3 || fields[3][0] == '\0') return 0;
  *sku = fields[0]; *name = fields[1]; *quantity = fields[2]; *price = fields[3];
  return 1;
}

Before parsing, remove only a final \n and optional preceding \r; do not trim meaningful spaces from names unless your file format says to. Include the filename and one-based line number in diagnostics, for example inventory.csv:14: invalid quantity. That is the difference between a usable import command and a mysterious failure.

Step 5: export through a temporary file and commit once

int csv_save(const char *path, const struct Inventory *inventory) {
  char temporary_path[PATH_MAX];
  if (snprintf(temporary_path, sizeof temporary_path, "%s.tmp", path) >= (int)sizeof temporary_path) return 0;
  FILE *output = fopen(temporary_path, "w");
  if (output == NULL) return 0;

  int ok = 1;
  for (size_t index = 0; index < inventory->length; index++) {
    const struct Item *item = &inventory->items[index];
    if (fprintf(output, "%s,%s,%d,%ld\n", item->sku, item->name,
                item->quantity, item->price_cents) < 0) { ok = 0; break; }
  }
  if (fclose(output) == EOF) ok = 0;
  if (!ok) { remove(temporary_path); return 0; }
  return rename(temporary_path, path) == 0;
}

This example teaches the order of operations, not a universal portability layer. Production-grade durability may require permissions preservation, directory synchronization, collision-resistant temporary names, and platform-specific rename semantics. State what your program guarantees and test failure paths using a directory you can safely make unwritable.

Step 6: main owns the complete lifetime

int main(int argc, char *argv[]) {
  struct Inventory inventory = {0};
  int status = EXIT_OPERATION;

  const char *data_path = getenv("INVENTORY_FILE");
  if (data_path == NULL) data_path = "inventory.csv";
  if (file_exists(data_path) && !csv_load(data_path, &inventory)) {
    fprintf(stderr, "could not load %s\n", data_path);
    goto cleanup;
  }
  status = dispatch(argc, argv, &inventory);
  if (status == EXIT_OK && command_mutated_data(argc, argv) && !csv_save(data_path, &inventory)) {
    fprintf(stderr, "could not save %s\n", data_path);
    status = EXIT_OPERATION;
  }
cleanup:
  inventory_destroy(&inventory);
  return status;
}

Notice the policy decision: load first, dispatch once, save only after a successful mutating command, and destroy on every exit. Your project can choose another policy, but it must be explicit. Test with INVENTORY_FILE pointing at a temporary test path so tests never touch a developer's real data.

Implementation milestones and review gate

  • 01
    Core collection

    Implement initialization, add/find/adjust/remove/destroy with unit tests before touching files or CLI parsing.

  • 02
    Strict parsing

    Build checked conversion helpers and command option validation; test bad input separately.

  • 03
    Persistence

    Add temporary-collection import and safe export; test that failure preserves previous state.

  • 04
    Deliverable quality

    Build warning-free, run sanitizer tests, use Make dependency files, document commands, then package sample data and README.

  • Capstone review gate

    Do not call the project complete if it only works with one perfect command. Demonstrate empty data, duplicate SKUs, malformed files, allocation or I/O failure paths, header-triggered rebuilds, and a clean sanitizer run. The quality of those boundaries is the project.

    Project checklist

    • Every command has a documented input, output, and exit-code contract.
    • The collection owns its memory and mutators preserve invariants on every failure path.
    • Numeric input uses checked conversion; strings have explicit capacity and validation rules.
    • Import is transactional and export avoids replacing good data with partial output.
    • Modules are independently testable; CLI code does not contain collection implementation details.
    • Make builds debug/release/test outputs correctly, tracks headers, and sanitizer tests pass.
    PREVIOUS LESSONBuild Tools and Makefiles
    COURSE COMPLETEReturn to C course
    ON THIS PAGEProject: Command-Line Inventory ManagerCommand contractArchitectureData modelCore APIStrict parsingCSV persistenceTestsMilestonesProject checklist
    Course contents