A Complete Project – Command-Line Task Manager
12 min read
Chapter 12: A Complete Project – Command-Line Task Manager
Everything in this book has been building toward this chapter. We will write a complete, working command-line task manager from scratch — a program you can actually use. It reads and writes tasks to a file, supports adding, listing, completing, and deleting tasks, and handles errors correctly.
The program uses almost everything from the previous eleven chapters: classes, smart pointers, RAII, the standard library, error handling, std::format, and a clean command parser. It does not require any third-party libraries. It compiles with g++ -std=c++20.
By the end you will have a program you can build on, a mental map of how a real C++ project is structured, and practice with the complete flow from design to working code.
12.1 What we will build
The program is called tasks. It has four commands:
tasks add "Buy groceries"
tasks list
tasks done 3
tasks remove 2
Tasks are stored in a JSON-like plain-text file (tasks.json) in the current directory. Each task has an ID, a description, a creation date, and a completion status.
The program will:
- Load the task file on startup (creating it if it does not exist)
- Execute the requested command
- Save the updated task list back to the file
- Print a helpful error message if the command is invalid or the file cannot be read
Here is an example session:
$ tasks add "Write chapter 12"
Added task #1: Write chapter 12
$ tasks add "Review exercises"
Added task #2: Review exercises
$ tasks list
[ ] #1 Write chapter 12
[ ] #2 Review exercises
$ tasks done 1
Marked task #1 as done: Write chapter 12
$ tasks list
[x] #1 Write chapter 12
[ ] #2 Review exercises
$ tasks remove 1
Removed task #1: Write chapter 12
$ tasks list
[ ] #2 Review exercises
12.2 The data model
A task is an object with an ID, description, done flag, and creation time. We keep it simple:
// task.h
#pragma once
#include <string>
#include <ctime>
struct Task {
int id;
std::string description;
bool done{false};
std::time_t createdAt{0};
Task() = default;
Task(int id, std::string desc)
: id{id}, description{std::move(desc)}, done{false},
createdAt{std::time(nullptr)} {}
};
A TaskList manages the collection:
// tasklist.h
#pragma once
#include "task.h"
#include <vector>
#include <optional>
#include <string>
class TaskList {
public:
// Returns the new task's ID
int add(const std::string& description);
// Returns true if found and marked done
bool markDone(int id);
// Returns true if found and removed
bool remove(int id);
// Returns the task with the given ID, or nullopt
std::optional<Task> find(int id) const;
const std::vector<Task>& all() const { return tasks_; }
bool empty() const { return tasks_.empty(); }
private:
std::vector<Task> tasks_;
int nextId_{1};
void updateNextId();
};
// tasklist.cpp
#include "tasklist.h"
#include <algorithm>
int TaskList::add(const std::string& description) {
int id = nextId_++;
tasks_.emplace_back(id, description);
return id;
}
bool TaskList::markDone(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;
it->done = true;
return true;
}
bool TaskList::remove(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::optional<Task> TaskList::find(int id) const {
auto it = std::find_if(tasks_.begin(), tasks_.end(),
[id](const Task& t) { return t.id == id; });
if (it == tasks_.end()) return std::nullopt;
return *it;
}
12.3 File I/O and serialization
We store tasks in a simple human-readable format — one task per line:
1|0|1716700000|Write chapter 12
2|0|1716700100|Review exercises
Fields: id|done|createdAt|description separated by |. This is not JSON (which would require a parser), but it is readable and parseable with the standard library.
// storage.h
#pragma once
#include "tasklist.h"
#include <string>
#include <expected> // C++23 — or use optional + error handling
enum class StorageError {
FileNotFound,
ParseError,
WriteError
};
std::string storageErrorMessage(StorageError e);
// Load tasks from file. Returns empty TaskList if file does not exist.
std::expected<TaskList, StorageError> loadTasks(const std::string& path);
// Save tasks to file.
std::expected<void, StorageError> saveTasks(const TaskList& list, const std::string& path);
// storage.cpp
#include "storage.h"
#include <fstream>
#include <sstream>
#include <format>
std::string storageErrorMessage(StorageError e) {
switch (e) {
case StorageError::FileNotFound: return "File not found";
case StorageError::ParseError: return "Parse error in task file";
case StorageError::WriteError: return "Could not write task file";
}
return "Unknown error";
}
std::expected<TaskList, StorageError> loadTasks(const std::string& path) {
std::ifstream file{path};
if (!file.is_open()) {
// File does not exist yet — return an empty list (not an error)
return TaskList{};
}
TaskList list;
std::string line;
int lineNumber = 0;
while (std::getline(file, line)) {
++lineNumber;
if (line.empty()) continue;
// Parse: id|done|createdAt|description
std::istringstream ss{line};
std::string idStr, doneStr, timeStr, desc;
if (!std::getline(ss, idStr, '|') ||
!std::getline(ss, doneStr, '|') ||
!std::getline(ss, timeStr, '|') ||
!std::getline(ss, desc)) {
return std::unexpected{StorageError::ParseError};
}
try {
Task t;
t.id = std::stoi(idStr);
t.done = (doneStr == "1");
t.createdAt = static_cast<std::time_t>(std::stol(timeStr));
t.description = desc;
list.addRaw(t); // add with existing ID (see below)
} catch (...) {
return std::unexpected{StorageError::ParseError};
}
}
return list;
}
std::expected<void, StorageError> saveTasks(const TaskList& list, const std::string& path) {
std::ofstream file{path};
if (!file.is_open()) {
return std::unexpected{StorageError::WriteError};
}
for (const auto& task : list.all()) {
file << task.id << '|'
<< (task.done ? 1 : 0) << '|'
<< task.createdAt << '|'
<< task.description << '\n';
}
if (!file) {
return std::unexpected{StorageError::WriteError};
}
return {}; // success: std::expected<void, E> with no value
}
We need to add an addRaw method to TaskList for loading (to preserve existing IDs):
// In tasklist.h, add:
void addRaw(const Task& task);
// In tasklist.cpp, add:
void TaskList::addRaw(const Task& task) {
tasks_.push_back(task);
if (task.id >= nextId_) nextId_ = task.id + 1;
}
12.4 Command parsing
The command parser reads argc/argv and dispatches to the right action:
// commands.h
#pragma once
#include "tasklist.h"
#include <string>
#include <vector>
#include <optional>
enum class Command {
Add, List, Done, Remove, Help, Unknown
};
struct ParsedCommand {
Command command{Command::Unknown};
std::string description; // for Add
std::optional<int> taskId; // for Done/Remove
std::string rawInput; // for error messages
};
ParsedCommand parseArgs(int argc, char* argv[]);
int runCommand(const ParsedCommand& cmd, TaskList& list);
// commands.cpp
#include "commands.h"
#include <iostream>
#include <format>
#include <stdexcept>
ParsedCommand parseArgs(int argc, char* argv[]) {
ParsedCommand result;
if (argc < 2) {
result.command = Command::Help;
return result;
}
std::string verb{argv[1]};
result.rawInput = verb;
if (verb == "add") {
if (argc < 3) {
result.command = Command::Unknown;
return result;
}
result.command = Command::Add;
result.description = argv[2];
} else if (verb == "list" || verb == "ls") {
result.command = Command::List;
} else if (verb == "done") {
if (argc < 3) {
result.command = Command::Unknown;
return result;
}
result.command = Command::Done;
try {
result.taskId = std::stoi(argv[2]);
} catch (...) {
result.command = Command::Unknown;
}
} else if (verb == "remove" || verb == "rm") {
if (argc < 3) {
result.command = Command::Unknown;
return result;
}
result.command = Command::Remove;
try {
result.taskId = std::stoi(argv[2]);
} catch (...) {
result.command = Command::Unknown;
}
} else if (verb == "help" || verb == "--help" || verb == "-h") {
result.command = Command::Help;
} else {
result.command = Command::Unknown;
}
return result;
}
int runCommand(const ParsedCommand& cmd, TaskList& list) {
switch (cmd.command) {
case Command::Add: {
int id = list.add(cmd.description);
std::cout << std::format("Added task #{}: {}\n", id, cmd.description);
return 0;
}
case Command::List: {
if (list.empty()) {
std::cout << "No tasks. Add one with: tasks add \"description\"\n";
return 0;
}
for (const auto& task : list.all()) {
std::string check = task.done ? "x" : " ";
std::cout << std::format("[{}] #{:<3} {}\n",
check, task.id, task.description);
}
return 0;
}
case Command::Done: {
int id = cmd.taskId.value();
auto task = list.find(id);
if (!task) {
std::cerr << std::format("Task #{} not found\n", id);
return 1;
}
list.markDone(id);
std::cout << std::format("Marked task #{} as done: {}\n",
id, task->description);
return 0;
}
case Command::Remove: {
int id = cmd.taskId.value();
auto task = list.find(id);
if (!task) {
std::cerr << std::format("Task #{} not found\n", id);
return 1;
}
list.remove(id);
std::cout << std::format("Removed task #{}: {}\n", id, task->description);
return 0;
}
case Command::Help:
case Command::Unknown:
std::cout <<
"tasks — a simple task manager\n\n"
"Usage:\n"
" tasks add \"description\" Add a new task\n"
" tasks list List all tasks\n"
" tasks done <id> Mark a task as done\n"
" tasks remove <id> Remove a task\n"
" tasks help Show this help\n";
return cmd.command == Command::Help ? 0 : 1;
}
return 0;
}
12.5 Putting it all together
The main function ties everything together:
// main.cpp
#include "tasklist.h"
#include "storage.h"
#include "commands.h"
#include <iostream>
#include <string>
int main(int argc, char* argv[]) {
const std::string taskFile = "tasks.json";
// Load existing tasks (or start fresh)
auto loadResult = loadTasks(taskFile);
if (!loadResult) {
std::cerr << "Error loading tasks: "
<< storageErrorMessage(loadResult.error()) << "\n";
return 1;
}
TaskList list = std::move(loadResult.value());
// Parse and execute the command
auto cmd = parseArgs(argc, argv);
int exitCode = runCommand(cmd, list);
// Save if the command modified the list
if (cmd.command == Command::Add ||
cmd.command == Command::Done ||
cmd.command == Command::Remove) {
auto saveResult = saveTasks(list, taskFile);
if (!saveResult) {
std::cerr << "Error saving tasks: "
<< storageErrorMessage(saveResult.error()) << "\n";
return 1;
}
}
return exitCode;
}
12.6 Building with CMake
The complete project CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(tasks)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(tasks
main.cpp
tasklist.cpp
storage.cpp
commands.cpp
)
target_compile_options(tasks PRIVATE -Wall -Wextra)
Build and run:
mkdir build && cd build
cmake ..
cmake --build .
./tasks add "Learn C++ properly"
./tasks add "Finish chapter 12 exercises"
./tasks list
./tasks done 1
./tasks list
12.7 Adding tests
Real projects have tests. Here is a minimal test file using assertions — no test framework required:
// test_tasklist.cpp
#include "tasklist.h"
#include <cassert>
#include <iostream>
void testAddAndFind() {
TaskList list;
int id = list.add("Buy milk");
assert(id == 1);
assert(!list.empty());
auto task = list.find(1);
assert(task.has_value());
assert(task->description == "Buy milk");
assert(!task->done);
std::cout << "testAddAndFind: PASS\n";
}
void testMarkDone() {
TaskList list;
int id = list.add("Walk the dog");
assert(list.markDone(id));
assert(list.find(id)->done);
assert(!list.markDone(999)); // nonexistent ID
std::cout << "testMarkDone: PASS\n";
}
void testRemove() {
TaskList list;
list.add("Task A");
int id = list.add("Task B");
list.add("Task C");
assert(list.all().size() == 3);
assert(list.remove(id));
assert(list.all().size() == 2);
assert(!list.find(id).has_value());
assert(!list.remove(999));
std::cout << "testRemove: PASS\n";
}
void testIdsAreUnique() {
TaskList list;
int id1 = list.add("First");
int id2 = list.add("Second");
int id3 = list.add("Third");
assert(id1 != id2 && id2 != id3 && id1 != id3);
std::cout << "testIdsAreUnique: PASS\n";
}
int main() {
testAddAndFind();
testMarkDone();
testRemove();
testIdsAreUnique();
std::cout << "\nAll tests passed!\n";
return 0;
}
Add to CMakeLists.txt:
add_executable(test_tasks
test_tasklist.cpp
tasklist.cpp
)
target_compile_options(test_tasks PRIVATE -Wall -Wextra)
Run: ./test_tasks
For a real project, you would use a proper test framework (Catch2, GoogleTest, or doctest). But the pattern — write a small function that asserts expected behavior, call it from main — is always the same.
12.8 Timing and what to expect
The program is small and fast — every command runs in well under one millisecond on modern hardware. The bottleneck is disk I/O (reading and writing the task file), not computation.
To measure:
time ./tasks list
You will see something like real 0m0.003s — three milliseconds, dominated by program startup and file I/O.
For a larger task list (add a few hundred tasks with a script), you will observe that the performance is still dominated by file I/O, not by the in-memory operations. This is typical for CLI tools.
12.9 Exercises
Easy
-
Add a
tasks countcommand that prints the total number of tasks and the number completed. -
Add a
--allflag totasks listthat shows both pending and completed tasks, and make the defaultlistshow only pending tasks.
Medium
-
Add a
tasks search <keyword>command that lists all tasks whose description contains the keyword (case-insensitive). -
Add a due date field to the
Taskstruct. Modifytasks addto accept an optional--due YYYY-MM-DDargument, and modifytasks listto show overdue tasks with a warning marker.
Stretch
-
Add color output: pending tasks in white, completed tasks in dim/grey, overdue tasks in red. Use ANSI escape codes (
\033[31mfor red,\033[0mto reset). Detect whether stdout is a terminal (isatty(1)) and disable colors when piping to a file. -
Replace the plain-text storage format with proper JSON. Use a single-header JSON library (nlohmann/json) or implement a minimal JSON writer/reader yourself (arrays of objects with four fields).
12.9 Common mistakes
-
Not handling the empty task file case. A fresh installation has no file. The program must not crash when
tasks listis called and the file does not exist. -
Assuming
argcandargvare always correct.argv[1]does not exist ifargc < 2. Always check before accessing. -
Using
intfor task IDs and then serializing without range checking.std::stoithrows on overflow. Wrap it in a try-catch or usestd::from_chars(which does not throw). -
Not flushing output before error conditions. If stdout is buffered and the program exits with an error, some output may be lost. Use
std::cout.flush()orstd::endl(which flushes) before returning non-zero from main. -
Forgetting to save after modifications. The
mainfunction must save after any command that modifies the list. Missing this means changes are lost silently. -
Treating the task file as always valid. File formats drift. If a user edits the file manually and makes a mistake, the parser must produce a useful error message, not crash.
12.10 Summary
We built a complete command-line task manager from scratch. The project demonstrates:
- A clean data model (
Task,TaskList) with a clear separation from persistence (storage.cpp). - RAII file handling via
std::fstream— the file is closed automatically. std::optional<Task>for find operations that might not find anything.std::expected<T, E>(C++23) for file operations that might fail with a reason.std::formatfor all output.- A command parser that separates argument parsing from command execution.
- Minimal tests using
assertto verify the data model. - CMake for building a multi-file project.
This structure — separate files for the data model, persistence, and command logic — scales up to much larger programs. The patterns are the same whether you have four files or four hundred.
You have now read every chapter. The full picture of modern C++ is:
- Types, functions, and control flow (Chapters 3–4)
- Classes, ownership, and lifetime (Chapters 5–6)
- Generic programming and the standard library (Chapters 7–8)
- Robust error handling (Chapter 9)
- Concurrent execution (Chapter 10)
- Modern idioms that tie it together (Chapter 11)
- A complete project that uses all of it (Chapter 12)
Where you go from here is up to you. Appendix E has suggestions.
All code for this chapter is in the GitHub repository:
<TODO: book code repository URL>/tree/main/chapter12