← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · C++ Project

CLI Task Manager

A sturdier command-line task manager with permanent, never-reused task IDs. The same core design as the basic-tier to-do-list, now driven from argv subcommands instead of an interactive menu.

🧠 Teaches how to think spoonfed, every age Last verified:

1 The Problem

We want a real command-line tool: tasks add "Buy milk", tasks list, tasks done 2 — commands and arguments, just like git or npm. It teaches argparse, the proper way to build CLI tools that feel professional, not like a toy menu.

Where this shows up: every developer tool — git, docker, npm, pip. Building proper CLIs with subcommands and arguments is a core skill for automation, dev tools, and scripts others will use.

2 How to Think About It

The one design decision that matters here is unchanged from the basic-tier to-do-list: tasks are identified by a permanent ID, not by their position in the vector, so removing a task never changes what a saved ID points to. What is new is how commands arrive.

The plan — in plain English
1. Load tasks (each with an ID) from disk into a std::vector<Task>. → 2. Read the subcommand from argv: add, list, or done. → 3. Apply it, generating a fresh, never-reused ID for a new task. → 4. Save before exiting.

add

list

done

Command line input

argparse parses it

Which subcommand?

Add task

Show tasks

Mark task done

Save to file

3 The Build — explained part by part

Here is the complete manager, split into a header, the testable logic, and a tiny main.cpp that wires it to real argv. C++ has no built-in subcommand parser either (nothing like Rust’s clap or Python’s argparse) — comparing argv[1] against each subcommand by hand, using std::string’s ==, is the normal way to do this without a dependency.

C++CliTaskManager.hpp / CliTaskManager.cpp / main.cpp
#pragma once
#include <string>
#include <vector>

struct Task {
    int id;
    bool done;
    std::string text;

    // C++20 lets the compiler generate a member-wise comparison for us --
    // no hand-written operator== needed the way the basic-tier to-do-list
    // project had to write one by hand.
    bool operator==(const Task &) const = default;
};

// One more than the highest id ever assigned -- never reused, even after a
// task is removed, the same guarantee a real database's auto-increment
// primary key gives you.
int next_id(const std::vector<Task> &tasks);

// Appends a new, not-done task with the next available id.
void add_task(std::vector<Task> &tasks, const std::string &text);

// Marks the task with the given id done, searching by id (not vector
// position). Returns true if found.
bool mark_done(std::vector<Task> &tasks, int id);

// Removes the task with the given id. Returns true if found.
bool remove_task(std::vector<Task> &tasks, int id);

// Renders "#id [x] text" (or "[ ]" if not done), one per line.
std::string format_list(const std::vector<Task> &tasks);

// Hand-rolled "id|done|text" line format, one task per line.
std::string serialize(const std::vector<Task> &tasks);
std::vector<Task> deserialize(const std::string &data);

// Loads/saves tasks from/to a real file using the format above. load_tasks
// returns an empty list if the file does not exist yet.
std::vector<Task> load_tasks(const std::string &path);
void save_tasks(const std::string &path, const std::vector<Task> &tasks);

#include "CliTaskManager.hpp"
#include <algorithm>
#include <fstream>
#include <sstream>

int next_id(const std::vector<Task> &tasks) {
    int max_id = 0;
    for (const auto &t : tasks) max_id = std::max(max_id, t.id);
    return max_id + 1;
}

void add_task(std::vector<Task> &tasks, const std::string &text) {
    tasks.push_back(Task{next_id(tasks), false, text});
}

bool mark_done(std::vector<Task> &tasks, int id) {
    for (auto &t : tasks) {
        if (t.id == id) {
            t.done = true;
            return true;
        }
    }
    return false;
}

bool remove_task(std::vector<Task> &tasks, int id) {
    auto it = std::find_if(tasks.begin(), tasks.end(),
                            [id](const Task &t) { return t.id == id; });
    if (it == tasks.end()) return false;
    tasks.erase(it);
    return true;
}

std::string format_list(const std::vector<Task> &tasks) {
    std::ostringstream out;
    for (const auto &t : tasks) {
        out << "#" << t.id << " [" << (t.done ? "x" : " ") << "] " << t.text << "\n";
    }
    return out.str();
}

std::string serialize(const std::vector<Task> &tasks) {
    std::ostringstream out;
    for (const auto &t : tasks) {
        out << t.id << '|' << (t.done ? 1 : 0) << '|' << t.text << '\n';
    }
    return out.str();
}

std::vector<Task> deserialize(const std::string &data) {
    std::vector<Task> tasks;
    std::istringstream in(data);
    std::string line;
    while (std::getline(in, line)) {
        if (line.empty()) continue;
        auto p1 = line.find('|');
        if (p1 == std::string::npos) continue;
        auto p2 = line.find('|', p1 + 1);
        if (p2 == std::string::npos) continue;
        int id = std::stoi(line.substr(0, p1));
        bool done = line.substr(p1 + 1, p2 - p1 - 1) == "1";
        std::string text = line.substr(p2 + 1);
        tasks.push_back(Task{id, done, text});
    }
    return tasks;
}

std::vector<Task> load_tasks(const std::string &path) {
    std::ifstream file(path);
    if (!file) return {};
    std::ostringstream buffer;
    buffer << file.rdbuf();
    return deserialize(buffer.str());
}

void save_tasks(const std::string &path, const std::vector<Task> &tasks) {
    std::ofstream file(path);
    file << serialize(tasks);
}

#include "CliTaskManager.hpp"
#include <iostream>
#include <sstream>

static const std::string FILE_PATH = "tasks.db";

static void print_usage() {
    std::cerr << "Usage: cli_task_manager <add|list|done> [args]\n";
    std::cerr << "  add <text...>   add a new task\n";
    std::cerr << "  list            show all tasks\n";
    std::cerr << "  done <id>       mark task <id> done\n";
}

int main(int argc, char **argv) {
    auto tasks = load_tasks(FILE_PATH);

    if (argc < 2) {
        print_usage();
        return 1;
    }

    std::string cmd = argv[1];
    if (cmd == "add") {
        if (argc < 3) { print_usage(); return 1; }
        std::ostringstream text;
        for (int i = 2; i < argc; i++) {
            text << argv[i];
            if (i + 1 < argc) text << " ";
        }
        add_task(tasks, text.str());
        std::cout << "Added #" << tasks.back().id << ": " << text.str() << "\n";
    } else if (cmd == "list") {
        std::cout << format_list(tasks);
    } else if (cmd == "done") {
        if (argc < 3) { print_usage(); return 1; }
        int id = std::stoi(argv[2]);
        std::cout << (mark_done(tasks, id) ? "Marked #" + std::to_string(id) + " done.\n"
                                            : "No task #" + std::to_string(id) + ".\n");
    } else {
        print_usage();
        return 1;
    }

    save_tasks(FILE_PATH, tasks);
    return 0;
}
⚠ No in-browser playground here
C++ compiles to a real, native binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once a C++17-or-newer compiler like g++ or clang++ is installed.
What each part does — in plain words
bool operator==(const Task &) const = default; — C++20 lets the compiler generate a correct, member-wise equality comparison from the struct’s fields, instead of writing one by hand the way the basic-tier to-do-list’s Task had to. This is a genuinely new C++20 feature, not something available in earlier standards.

int next_id(const std::vector<Task> &tasks) — finds the highest existing ID and adds one, so IDs only ever go up, even after a task with a high ID is removed. The same guarantee a real database’s auto-increment primary key gives you, and here that guarantee is the whole point of the project and is directly tested.

std::vector<Task> tasks — a growable container that manages its own memory, in contrast to C’s static Task tasks[MAX_TASKS] fixed-size array plus a separately tracked count. There is no capacity ceiling to hit and no static keyword needed to dodge a stack-overflow risk.

if (cmd == "add") { ... } else if (cmd == "list") { ... } — the manual subcommand dispatcher, using plain std::string equality. Every comparison here is exactly what a CLI-parsing library is automating underneath, in any language.
Common mistakes — and how to avoid them
✗ Computing the next ID as tasks.size() + 1 instead of scanning for the true maximum — this reuses IDs the moment any task has ever been removed, since size() shrinks back down.
✓ Take the maximum existing ID and add one, as next_id does, so removed IDs are never recycled.
✗ Relying on the default operator== across types whose fields do not all support == meaningfully — the compiler will still generate one for any comparable members, but it is member-wise, not semantic; two tasks with swapped ids but otherwise identical fields would still compare unequal, which is correct here but worth checking for your own structs.
✓ Write operator== by hand instead of = default whenever equality should mean something other than “every field matches.”

4 Test & Prove Each Part

We test the ID-management guarantee directly, since it is the whole point of this project over the basic to-do-list, plus a real round trip through a file on disk, using the C++20 defaulted operator== to compare whole vectors of tasks in one line.

IDs count up and are never reused, even after a task is removed
Marking a task done finds it by ID, not by its vector position
A vector of tasks survives a round trip through serialize and deserialize
Adding to an empty list starts at ID 1
Tasks survive a real save and load through a file in /tmp
format_list marks a done task with [x] and a pending one with [ ]
C++test_CliTaskManager.cpp
#include "CliTaskManager.hpp"
#include <cassert>
#include <cstdio>
#include <iostream>

#define RUN(name) do { name(); std::cout << "PASS: " << #name << "\n"; } while (0)

static void ids_count_up_and_are_never_reused() {
    std::vector<Task> tasks;
    add_task(tasks, "first");
    add_task(tasks, "second");
    assert(tasks[0].id == 1);
    assert(tasks[1].id == 2);
    remove_task(tasks, 1); // simulate removing task 1
    add_task(tasks, "third");
    assert(tasks.back().id == 3); // not reused as 1
}

static void mark_done_finds_by_id_not_position() {
    std::vector<Task> tasks = { {5, false, "A"}, {9, false, "B"} };
    assert(mark_done(tasks, 9));
    assert(tasks[1].done);
    assert(!tasks[0].done);
    assert(!mark_done(tasks, 999));
}

static void round_trips_through_serialize_and_deserialize() {
    std::vector<Task> tasks = { {1, true, "Ship the release"}, {2, false, "Write the docs"} };
    auto back = deserialize(serialize(tasks));
    assert(back == tasks); // the C++20 defaulted operator== at work
}

static void adding_when_empty_starts_at_one() {
    std::vector<Task> tasks;
    add_task(tasks, "only task");
    assert(tasks.size() == 1);
    assert(tasks[0].id == 1);
}

static void saves_and_loads_through_a_real_file() {
    std::vector<Task> tasks = { {1, false, "Buy milk"}, {2, true, "Walk the dog"} };
    const std::string path = "/tmp/cpp_cli_task_manager_test.db";
    save_tasks(path, tasks);
    auto loaded = load_tasks(path);
    assert(loaded == tasks);
    std::remove(path.c_str());
}

static void format_list_marks_done_with_x_and_pending_with_space() {
    std::vector<Task> tasks = { {1, true, "done task"}, {2, false, "pending task"} };
    std::string out = format_list(tasks);
    assert(out.find("#1 [x] done task") != std::string::npos);
    assert(out.find("#2 [ ] pending task") != std::string::npos);
}

int main() {
    RUN(ids_count_up_and_are_never_reused);
    RUN(mark_done_finds_by_id_not_position);
    RUN(round_trips_through_serialize_and_deserialize);
    RUN(adding_when_empty_starts_at_one);
    RUN(saves_and_loads_through_a_real_file);
    RUN(format_list_marks_done_with_x_and_pending_with_space);
    std::cout << "All tests passed.\n";
    return 0;
}

Compile and run with g++ -std=c++20 -Wall -Wextra -Wpedantic -o test_run CliTaskManager.cpp test_CliTaskManager.cpp && ./test_run. The ID-reuse test is the one that actually proves the design decision: it adds two tasks, removes the first, adds a third, and asserts the third gets ID 3 — not 1. main.cpp is left out of this compile line since it has its own main.

5 The Interface

INPUTINPUTcommand-line arguments
What it expects
add Ship the release
done 1
list
OUTPUTOUTPUTtask list / confirmation
What it returns
#1 [x] Ship the release
#2 [ ] Write the docs

6 Run It & Automate It

Save the code as CliTaskManager.hpp / CliTaskManager.cpp / main.cpp and compile it with g++ — that turns your source directly into a native executable for your machine. No separate runtime needed: the compiled binary runs on its own.

Run it locally
g++ -std=c++20 -o tasks main.cpp CliTaskManager.cpp && ./tasks add Ship the release
Tasks persist in tasks.db in the current directory between runs.

A CI tool like Jenkins runs the same compile-then-test-then-check-for-leaks steps automatically whenever the code changes — every line below has a plain explanation.

What you should see when it works
Terminala real run
$ ./tasks add Ship the release
Added #1: Ship the release
$ ./tasks add Write the docs
Added #2: Write the docs
$ ./tasks done 1
Marked #1 done.
$ ./tasks list
#1 [x] Ship the release
#2 [ ] Write the docs
If it breaks — how to fix it
🚨 Added #1: Ship the release Added #1: Write the docs
If a second task also gets #1, next_id is likely reading from a vector that was never actually loaded or saved between runs — check load_tasks/save_tasks are both wired to the same FILE_PATH.
🚨 No task #1.
The id passed to done must match one printed by list exactly — ids are never reused, so an id from a task you already removed will never match again.
GroovyJenkinsfile
// Jenkinsfile — compiles, tests, and checks for leaks on every change.
pipeline {
    agent any

    stages {
        stage('Get the code') {
            // download the latest code
            steps { checkout scm }
        }
        stage('Compile') {
            steps {
                // confirm a compiler is installed
                sh 'g++ --version'
                // compile with strict warnings on
                sh 'g++ -std=c++20 -Wall -Wextra -o app *.cpp'
            }
        }
        stage('Run the tests') {
            steps {
                // prints PASS/FAIL, exits non-zero on failure
                sh './app'
            }
        }
        stage('Check for memory leaks') {
            steps {
                // fails the build on any leak or invalid access
                sh 'valgrind --error-exitcode=1 --leak-check=full ./app'
            }
        }
    }

    post {
        success { echo 'All tests passed, no leaks found.' }
        failure { echo 'A test or Valgrind check failed — see above.' }
    }
}
🎯 Try this next — make it yours
  1. Add a “remove” subcommand. Wire up the already-written remove_task to argv. (Teaches: extending a subcommand dispatcher.)
  2. Add due dates. A new field on Task, and a way to sort by it. (Teaches: extending a struct that already has a defaulted operator== — does it still work correctly?)
  3. Support --json output. An alternate format_list that emits JSON instead of plain text. (Teaches: a second serialization format alongside the existing one.)
What you learned
You learned that C++20’s defaulted operator== replaces a hand-written member-wise comparison, and that the ID-by-value-not-position design from the basic-tier to-do-list scales cleanly to a CLI tool driven by argv. Related: Modern C++ (C++11–C++23), STL Containers.