Boring C++
Boring C++

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

  1. Add a tasks count command that prints the total number of tasks and the number completed.

  2. Add a --all flag to tasks list that shows both pending and completed tasks, and make the default list show only pending tasks.

Medium

  1. Add a tasks search <keyword> command that lists all tasks whose description contains the keyword (case-insensitive).

  2. Add a due date field to the Task struct. Modify tasks add to accept an optional --due YYYY-MM-DD argument, and modify tasks list to show overdue tasks with a warning marker.

Stretch

  1. Add color output: pending tasks in white, completed tasks in dim/grey, overdue tasks in red. Use ANSI escape codes (\033[31m for red, \033[0m to reset). Detect whether stdout is a terminal (isatty(1)) and disable colors when piping to a file.

  2. 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 list is called and the file does not exist.

  • Assuming argc and argv are always correct. argv[1] does not exist if argc < 2. Always check before accessing.

  • Using int for task IDs and then serializing without range checking. std::stoi throws on overflow. Wrap it in a try-catch or use std::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() or std::endl (which flushes) before returning non-zero from main.

  • Forgetting to save after modifications. The main function 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::format for all output.
  • A command parser that separates argument parsing from command execution.
  • Minimal tests using assert to 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

Boring C++

Modern C++ Mastery for Programmers Who Prefer Clarity Over Cleverness.

View book →