Capstone 2: a task model

The second capstone is a task tracker for the command line. It has four layers: a model that knows what a task is, a storage that reads and writes a file, a command layer that parses arguments and an error layer that turns failures into messages and exit codes.

This lesson builds the model. It is a struct with an enum, a date and a few functions that refuse invalid changes, which is the same rule as a class in lesson 036: the state is private to the operations that know how to keep it consistent.

The model has no file and no terminal, which is why its tests are the fastest in the project. The layers above add the file and the words, and each one is tested at its own level.

What a task is

A task has an id, a title, a state and a due date. The state is an enum class with two values, open and done, and the date is std::chrono::year_month_day from C++20, which stores a calendar date rather than a count of seconds.

The program builds one task and prints its title, whether it is done, and the year of its due date. The output is write the parser 0 2026: the state compares to open and the date keeps its parts.

The fields are public because the model is a value with a small set of operations. The functions of the next sections are the only ones that change the state, and that convention is what the tests check.

#include <chrono> #include <iostream> #include <string> enum class state { open, done }; struct task { int id; std::string title; state status = state::open; std::chrono::year_month_day due; }; int main() { task item{1, "write the parser", state::open, std::chrono::year{2026} / 12 / 1}; std::cout << item.title << ' ' << (item.status == state::done) << ' '; std::cout << static_cast<int>(item.due.year()) << '\n'; }

g++ -std=c++23 -Wall -Wextra struct.cpp -o task_model
./task_model

write the parser 0 2026

Operations that keep it valid

complete changes the state, and it refuses a task that is already done with a std::logic_error. rename refuses an empty title with a std::invalid_argument. The two exceptions say different things: one is a bug in the caller, the other is bad input.

The program completes a task and tries to rename it to an empty string. The catch prints the message from the model, and the task keeps its old title.

A model that throws is easy to test: the test calls the operation and expects the exception. The command layer of lesson 144 catches the same exceptions and turns them into a message and an exit code.

#include <chrono> #include <iostream> #include <stdexcept> #include <string> enum class state { open, done }; struct task { int id; std::string title; state status = state::open; std::chrono::year_month_day due; }; void complete(task& item) { if (item.status == state::done) { throw std::logic_error("task is already done"); } item.status = state::done; } void rename(task& item, const std::string& title) { if (title.empty()) { throw std::invalid_argument("title is empty"); } item.title = title; } int main() { task item{1, "write the parser", state::open, std::chrono::year{2026} / 12 / 1}; complete(item); try { rename(item, ""); } catch (const std::invalid_argument& error) { std::cout << error.what() << ' '; } std::cout << item.title << ' ' << (item.status == state::done) << '\n'; }

g++ -std=c++23 -Wall -Wextra operations.cpp -o task_operations
./task_operations

title is empty write the parser 1

Days until the due date

year_month_day converts to sys_days, a count of days since the epoch, and the difference of two of them is a days duration. That is the arithmetic the tracker shows next to a task.

The program computes the days between 11 November 2026 and 1 December 2026 and prints 20. The subtraction is exact because both dates are calendar days, with no time zone and no daylight saving.

The same type validates a date: ok() is false for 31 February, and the storage layer of the next lesson uses it to reject a line from the file. Lesson 073 covered the calendar types in full.

#include <chrono> #include <iostream> #include <string> struct task { std::string title; std::chrono::year_month_day due; }; int main() { task item{"write the parser", std::chrono::year{2026} / 12 / 1}; auto today = std::chrono::year{2026} / 11 / 11; auto left = std::chrono::sys_days{item.due} - std::chrono::sys_days{today}; std::cout << left.count() << ' '; std::cout << (item.due.ok() ? "valid" : "invalid") << '\n'; }

g++ -std=c++23 -Wall -Wextra due.cpp -o task_due
./task_due

20 valid

The transition that is not allowed

A task cannot be completed twice, and the model reports the second attempt instead of silently doing nothing. The message names the rule, and the caller decides whether that is a bug or a user mistake.

The program completes the task twice and catches the exception from the second call. The output is error: task is already done, and the state stays done.

The alternative, a function that returns a bool, is right for a command that tries an operation and reports it. An exception is right when the caller has already checked and the failure means the code is wrong, which is the case here.

#include <chrono> #include <iostream> #include <stdexcept> #include <string> enum class state { open, done }; struct task { int id; std::string title; state status = state::open; std::chrono::year_month_day due; }; void complete(task& item) { if (item.status == state::done) { throw std::logic_error("task is already done"); } item.status = state::done; } int main() { task item{1, "write the parser", state::open, std::chrono::year{2026} / 12 / 1}; complete(item); try { complete(item); } catch (const std::logic_error& error) { std::cout << "error: " << error.what() << '\n'; } }

g++ -std=c++23 -Wall -Wextra twice.cpp -o task_twice
./task_twice

error: task is already done

Exercises

  1. Add a reopen operation that moves a done task back to open and refuses an open one.
  2. Add a priority enum and sort three tasks by it.
  3. Validate the due date in a function that throws for an invalid date.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does the task model store for the date?

    Answer

    A std::chrono::year_month_day

    The calendar type keeps the year, month and day and knows whether the date exists.

  2. Why does complete throw instead of returning false?

    Answer

    Completing a done task is a caller bug, not user input

    The caller is expected to check the state; the exception marks the violated invariant.

  3. What is the difference between the two exceptions in the model?

    Answer

    logic_error for a violated invariant and invalid_argument for bad input

    The types let the caller tell a programming mistake from a value it can report.

  4. What does this program print? #include <chrono> #include <iostream> #include <string> enum class state { open, done }; struct task { int id; std::string title; state status = state::open; std::chrono::year_month_day due; }; int main() { task item{1, "write the parser", state::open, std::chrono::year{2026} / 12 / 1}; std::cout << item.title << ' ' << (item.status == state::done) << ' ' << static_cast<int>(item.due.year()); }

    Answer

    write the parser 0 2026

    The task is open and the year of the due date is 2026.

  5. What does this program print? #include <chrono> #include <iostream> #include <stdexcept> #include <string> enum class state { open, done }; struct task { int id; std::string title; state status = state::open; }; void complete(task& item) { if (item.status == state::done) throw std::logic_error("task is already done"); item.status = state::done; } void rename(task& item, const std::string& title) { if (title.empty()) throw std::invalid_argument("title is empty"); item.title = title; } int main() { task item{1, "write the parser", state::open}; complete(item); try { rename(item, ""); } catch (const std::invalid_argument& error) { std::cout << error.what() << ' '; } std::cout << item.title << ' ' << (item.status == state::done); }

    Answer

    title is empty write the parser 1

    The rename throws and the title stays, while the completion succeeded.

  6. What does this program print? #include <chrono> #include <iostream> int main() { auto today = std::chrono::year{2026} / 11 / 11; auto due = std::chrono::year{2026} / 12 / 1; auto left = std::chrono::sys_days{due} - std::chrono::sys_days{today}; std::cout << left.count() << ' ' << (due.ok() ? "valid" : "invalid"); }

    Answer

    20 valid

    The difference between the two calendar days is 20, and 1 December 2026 exists.

  7. What does this program print? #include <iostream> #include <stdexcept> #include <string> enum class state { open, done }; struct task { std::string title; state status = state::open; }; void complete(task& item) { if (item.status == state::done) throw std::logic_error("task is already done"); item.status = state::done; } int main() { task item{"write the parser"}; complete(item); try { complete(item); } catch (const std::logic_error& error) { std::cout << "error: " << error.what(); } }

    Answer

    error: task is already done

    The second complete throws, and the catch adds the prefix.

  8. How does the model know a date is valid?

    Answer

    year_month_day::ok() checks the calendar

    The type allows the construction and reports validity with ok().

  9. What kind of type is the task?

    Answer

    A value with public fields and a few operations

    The model is small enough that the fields can be public, and the operations define the rules.

  10. Which layer turns a model exception into a message for the user?

    Answer

    The command layer

    The command layer catches the exception, prints the message and sets the exit code.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image