Skip to content

12. Capstone: Command-Line Task Tracker

Goal

Build a real, useful program that ties together everything in the beginner track: variables, strings, conditionals, loops, vectors, functions, multiple source files, files, and error handling.

You'll build todo — a command-line task tracker that saves tasks to a text file so they persist between runs.

Requirements

$ ./todo add "Buy milk"
Added task 1: Buy milk

$ ./todo add "Write report" --priority high
Added task 2: Write report [high]

$ ./todo list
  1. [ ] Buy milk
  2. [ ] Write report [high]

$ ./todo done 1
Completed task 1: Buy milk

$ ./todo list --all
  1. [x] Buy milk
  2. [ ] Write report [high]

$ ./todo remove 1
Removed task 1

$ ./todo list --all
  2. [ ] Write report [high]

Functional requirements

  1. add TEXT [--priority low|normal|high] — append a task. Auto-assign the next integer id.
  2. list [--all] — show tasks. By default hide completed ones; --all shows everything.
  3. done ID — mark a task complete.
  4. remove ID — delete a task.
  5. Tasks persist in tasks.txt in the current directory, one per line: id<TAB>done<TAB>priority<TAB>text.
  6. Unknown commands, missing or non-numeric ids, and a missing or corrupt data file must produce a friendly message and a non-zero exit code — never a crash.

Suggested structure

Split into four files.

todo/
├── task.hpp       # the Task struct
├── storage.hpp    # load/save declarations
├── storage.cpp    # load/save definitions
└── todo.cpp       # main + commands

task.hpp

#pragma once

#include <string>

struct Task {
    int id{};
    std::string text;
    std::string priority{"normal"};   // "low", "normal" or "high"
    bool done{false};
};

A struct groups related values into one type. The {} after each member gives it a default value. (Classes get the full treatment in the intermediate track.)

storage.hpp

#pragma once

#include <filesystem>
#include <vector>

#include "task.hpp"

inline const std::filesystem::path data_file{"tasks.txt"};

// Return every saved task, or an empty list if there's no (valid) file.
std::vector<Task> load_tasks();

void save_tasks(const std::vector<Task>& tasks);

storage.cpp

#include "storage.hpp"

#include <fstream>
#include <iostream>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>

namespace {

// One task per line: id<TAB>done<TAB>priority<TAB>text
std::optional<Task> parse_line(const std::string& line) {
    std::istringstream in{line};
    Task task;
    std::string id, done;
    if (!std::getline(in, id, '\t') || !std::getline(in, done, '\t') ||
        !std::getline(in, task.priority, '\t') || !std::getline(in, task.text)) {
        return std::nullopt;
    }
    try {
        task.id = std::stoi(id);
    } catch (const std::exception&) {
        return std::nullopt;
    }
    task.done = (done == "1");
    return task;
}

}  // namespace

std::vector<Task> load_tasks() {
    std::vector<Task> tasks;
    std::ifstream in{data_file};
    if (!in) {
        return tasks;   // no file yet: start empty
    }
    std::string line;
    int line_no = 0;
    while (std::getline(in, line)) {
        ++line_no;
        if (auto task = parse_line(line)) {
            tasks.push_back(*task);
        } else {
            std::cerr << "Warning: skipping corrupt line " << line_no << " in "
                      << data_file.string() << '\n';
        }
    }
    return tasks;
}

void save_tasks(const std::vector<Task>& tasks) {
    std::ofstream out{data_file};
    if (!out) {
        throw std::runtime_error("cannot write " + data_file.string());
    }
    for (const auto& t : tasks) {
        out << t.id << '\t' << (t.done ? 1 : 0) << '\t' << t.priority << '\t' << t.text
            << '\n';
    }
}

todo.cpp

// Command-line task tracker.
#include <algorithm>
#include <charconv>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>

#include "storage.hpp"

namespace {

using Args = std::vector<std::string_view>;

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;
}

Task* find_task(std::vector<Task>& tasks, int id) {
    auto it = std::ranges::find(tasks, id, &Task::id);
    return it == tasks.end() ? nullptr : &*it;
}

std::optional<int> parse_id(std::string_view s) {
    int value{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) {
        return std::nullopt;
    }
    return value;
}

std::string tag(const Task& t) {
    return t.priority == "normal" ? "" : " [" + t.priority + "]";
}

int cmd_add(const Args& args) {
    if (args.empty()) {
        std::cerr << "usage: todo add TEXT [--priority low|normal|high]\n";
        return 2;
    }
    Task task;
    task.text = std::string{args[0]};
    if (args.size() == 3 && args[1] == "--priority") {
        if (args[2] != "low" && args[2] != "normal" && args[2] != "high") {
            std::cerr << "priority must be low, normal or high\n";
            return 2;
        }
        task.priority = std::string{args[2]};
    } else if (args.size() != 1) {
        std::cerr << "usage: todo add TEXT [--priority low|normal|high]\n";
        return 2;
    }
    auto tasks = load_tasks();
    task.id = next_id(tasks);
    tasks.push_back(task);
    save_tasks(tasks);
    std::cout << "Added task " << task.id << ": " << task.text << tag(task) << '\n';
    return 0;
}

int cmd_list(const Args& args) {
    bool show_all = !args.empty() && args[0] == "--all";
    int shown = 0;
    for (const auto& t : load_tasks()) {
        if (t.done && !show_all) {
            continue;
        }
        std::cout << "  " << t.id << ". [" << (t.done ? 'x' : ' ') << "] " << t.text
                  << tag(t) << '\n';
        ++shown;
    }
    if (shown == 0) {
        std::cout << "No tasks.\n";
    }
    return 0;
}

int cmd_done(const Args& args) {
    auto id = args.size() == 1 ? parse_id(args[0]) : std::nullopt;
    if (!id) {
        std::cerr << "usage: todo done ID\n";
        return 2;
    }
    auto tasks = load_tasks();
    Task* task = find_task(tasks, *id);
    if (task == nullptr) {
        std::cerr << "No task with id " << *id << '\n';
        return 1;
    }
    task->done = true;
    save_tasks(tasks);
    std::cout << "Completed task " << task->id << ": " << task->text << '\n';
    return 0;
}

int cmd_remove(const Args& args) {
    auto id = args.size() == 1 ? parse_id(args[0]) : std::nullopt;
    if (!id) {
        std::cerr << "usage: todo remove ID\n";
        return 2;
    }
    auto tasks = load_tasks();
    auto removed = std::erase_if(tasks, [&](const Task& t) { return t.id == *id; });
    if (removed == 0) {
        std::cerr << "No task with id " << *id << '\n';
        return 1;
    }
    save_tasks(tasks);
    std::cout << "Removed task " << *id << '\n';
    return 0;
}

void print_usage() {
    std::cerr << "usage: todo <command> [args]\n"
                 "commands:\n"
                 "  add TEXT [--priority low|normal|high]\n"
                 "  list [--all]\n"
                 "  done ID\n"
                 "  remove ID\n";
}

}  // namespace

int main(int argc, char* argv[]) {
    if (argc < 2) {
        print_usage();
        return 2;
    }
    std::string_view command = argv[1];
    Args args(argv + 2, argv + argc);

    try {
        if (command == "add") return cmd_add(args);
        if (command == "list") return cmd_list(args);
        if (command == "done") return cmd_done(args);
        if (command == "remove") return cmd_remove(args);
    } catch (const std::exception& e) {
        std::cerr << "error: " << e.what() << '\n';
        return 1;
    }
    std::cerr << "unknown command: " << command << '\n';
    print_usage();
    return 2;
}

Build and run

g++ -std=c++20 -Wall -Wextra -Wpedantic todo.cpp storage.cpp -o todo
./todo add "Buy milk"

Things to notice

  • std::ranges::find(tasks, id, &Task::id) — the third argument is a projection: search by each task's id member.
  • find_task returns a pointer (Task*) so it can say "not found" with nullptr. You'll learn pointers properly in the next track; here it's just "a handle to an element, or nothing".
  • std::erase_if removes matching elements and returns how many it removed.
  • Commands return exit codes: 0 success, 1 runtime failure, 2 usage error — a Unix convention scripts can check.
  • The anonymous namespace keeps helpers private to their .cpp file.
  • try/catch in main turns any unexpected exception (e.g. a read-only disk) into a clean error message.

Build it in stages

  1. Hardcode first. Make list print a fixed std::vector<Task>. No files yet.
  2. Add storage. Implement save_tasks / load_tasks. Run add twice, then open tasks.txt in an editor.
  3. Add the other commands one at a time, testing each.
  4. Break it on purpose: delete tasks.txt, add a garbage line to it, run done 999, done abc, done with no id, and an unknown command. Every case must print a clear message.
  5. Polish: sort list output by priority (high first); print a summary line like 3 open, 2 done.

Stretch goals

  • todo edit ID "new text".
  • Due dates: --due 2026-12-31 stored as a fifth column, and a todo overdue command (use std::chrono::year_month_day).
  • Colour output (green for done) using ANSI escape codes like "\033[32m".
  • Write the data file atomically: save to tasks.txt.tmp, then std::filesystem::rename it over the original so a crash mid-write can't corrupt your data.
  • Set up a CMakeLists.txt for it (preview of the CMake lesson).

Self-check

  • [ ] Explain every line of todo.cpp and storage.cpp to someone else.
  • [ ] Explain why load_tasks returns by value and save_tasks takes a const&.
  • [ ] Add a new command (e.g. edit) without looking anything up.
  • [ ] Handle a missing/corrupt data file without a crash.
  • [ ] Explain what would break if storage.hpp contained the body of load_tasks and two .cpp files included it.

When those are true, move on to Intermediate → Pointers & references.