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.
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.
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.
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.
#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;
}
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.
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.next_id does, so removed IDs are never recycled.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.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.
#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
What it expects
add Ship the release
done 1
listWhat it returns
#1 [x] Ship the release
#2 [ ] Write the docs6 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.
g++ -std=c++20 -o tasks main.cpp CliTaskManager.cpp && ./tasks add Ship the releaseTasks 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.
$ ./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 docsnext_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.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.// 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.' }
}
}
- Add a “remove” subcommand. Wire up the already-written
remove_tasktoargv. (Teaches: extending a subcommand dispatcher.) - Add due dates. A new field on
Task, and a way to sort by it. (Teaches: extending a struct that already has a defaultedoperator==— does it still work correctly?) - Support
--jsonoutput. An alternateformat_listthat emits JSON instead of plain text. (Teaches: a second serialization format alongside the existing one.)
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.