Capstone 2: saving tasks

A tracker that forgets its tasks between runs is a demonstration. The storage layer is the one that turns the model of lesson 142 into a file and back, and it is the layer where the data can be wrong.

The format is one task per line and four fields separated by semicolons. It is not JSON, and that is deliberate: a line-based format is what a person can edit and what grep can read, and it fits the half of the course that already covered files in lesson 069.

The format is also the contract with the user: a line a person can edit is a feature, and a corrupt line is the price. The loader policy decides whether that price is a warning or a stopped program.

Writing the file

save opens an ofstream and writes one line per task: the id, the state as a word, the due date and the title. The title is last because it is the only field that may contain a semicolon, and a reader that stops after three separators keeps the rest.

The program saves two tasks and the cat command shows the file. The state is written as open or done rather than as a number, so the file reads as data rather than as an encoding.

The stream checks its own state: after the loop the function could test out for a write error. The capstone keeps the check at the open, where the common failure is a missing directory.

#include <fstream> #include <iostream> #include <stdexcept> #include <string> #include <vector> struct task { int id; std::string title; bool done; std::string due; }; void save(const std::string& path, const std::vector<task>& tasks) { std::ofstream out{path}; if (!out) { throw std::runtime_error("cannot open " + path); } for (const task& item : tasks) { out << item.id << ';' << (item.done ? "done" : "open") << ';'; out << item.due << ';' << item.title << '\n'; } } int main() { std::vector<task> tasks{ {1, "write the parser", false, "2026-12-01"}, {2, "review the writer", true, "2026-11-20"}, }; save("tasks.txt", tasks); std::cout << "saved " << tasks.size() << '\n'; }

g++ -std=c++23 -Wall -Wextra save.cpp -o task_save
./task_save
cat tasks.txt

saved 2
1;open;2026-12-01;write the parser
2;done;2026-11-20;review the writer

Reading it back

load reads the file line by line with std::getline and splits each line on the semicolons. The first three fields are the id, the state and the date; the rest of the line is the title.

The program writes the file, removes two tasks and loads them again. The print is 2 tasks and then the titles, so the round trip preserved everything the tracker stores.

The split stops after three separators, which is what lets a title contain one. A general split on every semicolon would truncate the title, and that is the kind of decision a format makes once and then keeps.

#include <fstream> #include <iostream> #include <stdexcept> #include <string> #include <vector> struct task { int id; std::string title; bool done; std::string due; }; std::vector<task> load(const std::string& path) { std::ifstream in{path}; if (!in) { throw std::runtime_error("cannot open " + path); } std::vector<task> tasks; std::string line; while (std::getline(in, line)) { std::size_t first = line.find(';'); std::size_t second = line.find(';', first + 1); std::size_t third = line.find(';', second + 1); if (first == std::string::npos || second == std::string::npos || third == std::string::npos) { continue; } task item; item.id = std::stoi(line.substr(0, first)); item.done = line.substr(first + 1, second - first - 1) == "done"; item.due = line.substr(second + 1, third - second - 1); item.title = line.substr(third + 1); tasks.push_back(item); } return tasks; } int main() { { std::ofstream out{"tasks.txt"}; out << "1;open;2026-12-01;write the parser\n"; out << "2;done;2026-11-20;review the writer\n"; } std::vector<task> tasks = load("tasks.txt"); std::cout << tasks.size() << " tasks\n"; for (const task& item : tasks) { std::cout << item.title << '\n'; } }

g++ -std=c++23 -Wall -Wextra load.cpp -o task_load
./task_load

2 tasks
write the parser
review the writer

The line that is skipped

A file that a person edits will contain a broken line sooner or later. The loader counts the lines it cannot parse and keeps the good ones, and the count is the warning the caller prints.

The program writes three lines, one of them without a date, and loads the file. The output is skipped 1, then 2 tasks, then the two titles: one bad line does not lose the file.

Refusing the whole file is the other valid policy, and it is the right one for a database. For a hand-edited task list the partial read is better, because the alternative is that the user cannot start the program to fix the file.

#include <fstream> #include <iostream> #include <string> #include <vector> struct task { int id; std::string title; bool done; std::string due; }; std::vector<task> load(const std::string& path, int& skipped) { std::ifstream in{path}; std::vector<task> tasks; std::string line; while (std::getline(in, line)) { std::size_t first = line.find(';'); std::size_t second = line.find(';', first + 1); std::size_t third = line.find(';', second + 1); if (first == std::string::npos || second == std::string::npos || third == std::string::npos) { ++skipped; continue; } task item; item.id = std::stoi(line.substr(0, first)); item.done = line.substr(first + 1, second - first - 1) == "done"; item.due = line.substr(second + 1, third - second - 1); item.title = line.substr(third + 1); tasks.push_back(item); } return tasks; } int main() { { std::ofstream out{"tasks.txt"}; out << "1;open;2026-12-01;write the parser\n"; out << "2;broken line without fields\n"; out << "3;done;2026-11-20;review the writer\n"; } int skipped = 0; std::vector<task> tasks = load("tasks.txt", skipped); std::cout << "skipped " << skipped << '\n'; std::cout << tasks.size() << " tasks\n"; for (const task& item : tasks) { std::cout << item.title << '\n'; } }

g++ -std=c++23 -Wall -Wextra corrupt.cpp -o task_corrupt
./task_corrupt

skipped 1
2 tasks
write the parser
review the writer

The file that is not there

Opening a file that does not exist is the first error every storage layer meets. The stream reports it through its bool conversion, and the loader turns it into an exception with the path in the message.

The program asks for a file that was never written and catches the exception. The print is cannot open missing.txt, which is the message a user needs to fix the problem.

The alternative is to return an empty list, which loses the difference between an empty tracker and a wrong path. Lesson 066 covered the stream states behind this check.

#include <fstream> #include <iostream> #include <stdexcept> #include <string> #include <vector> struct task { int id; std::string title; bool done; std::string due; }; std::vector<task> load(const std::string& path) { std::ifstream in{path}; if (!in) { throw std::runtime_error("cannot open " + path); } return {}; } int main() { try { load("missing.txt"); } catch (const std::runtime_error& error) { std::cout << error.what() << '\n'; } }

g++ -std=c++23 -Wall -Wextra missing.cpp -o task_missing
./task_missing

cannot open missing.txt

Exercises

  1. Escape a semicolon in the title when saving and unescape it when loading.
  2. Reject a line whose date is not in the year-month-day form and count it as skipped.
  3. Save to a temporary file and rename it over the old one, so a crash cannot truncate the list.

Questions

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

  1. Why is the title the last field of a line?

    Answer

    Because it is the only field that may contain a semicolon

    The reader takes the rest of the line as the title, so a semicolon inside it is not a separator.

  2. How is the state written to the file?

    Answer

    As the words open and done

    The word is readable in the file and the loader compares it with the same string.

  3. What does the loader do with a line that has too few fields?

    Answer

    It counts it and skips it

    The count is the warning, and the good tasks stay available.

  4. What does this program print? #include <fstream> #include <iostream> #include <string> int main() { { std::ofstream out{"tasks.txt"}; out << "1;open;2026-12-01;write the parser\n"; out << "2;done;2026-11-20;review the writer\n"; } std::cout << "saved 2\n"; std::ifstream in{"tasks.txt"}; std::string line; while (std::getline(in, line)) std::cout << line << '\n'; }

    Answer

    saved 2 / 1;open;2026-12-01;write the parser / 2;done;2026-11-20;review the writer

    The writer saves two lines, and the reader prints them back in order.

  5. What does this program print? #include <fstream> #include <iostream> #include <sstream> #include <string> int main() { { std::ofstream out{"tasks.txt"}; out << "1;open;2026-12-01;write the parser\n2;done;2026-11-20;review the writer\n"; } std::ifstream in{"tasks.txt"}; std::string line; int count = 0; while (std::getline(in, line)) ++count; std::cout << count << " tasks\n"; in.clear(); in.seekg(0); while (std::getline(in, line)) { std::size_t last = line.rfind(';'); std::cout << line.substr(last + 1) << '\n'; } }

    Answer

    2 tasks / write the parser / review the writer

    Two lines are read, and the title is the part after the last semicolon.

  6. What does this program print? #include <fstream> #include <iostream> #include <iostream> #include <string> int main() { { std::ofstream out{"tasks.txt"}; out << "1;open;2026-12-01;write the parser\n2;broken line\n3;done;2026-11-20;review the writer\n"; } std::ifstream in{"tasks.txt"}; std::string line; int skipped = 0, good = 0; while (std::getline(in, line)) { if (line.find(';') == std::string::npos || line.find(';', line.find(';') + 1) == std::string::npos || line.find(';', line.find(';', line.find(';') + 1) + 1) == std::string::npos) { ++skipped; continue; } ++good; } std::cout << "skipped " << skipped << " / " << good << " tasks"; }

    Answer

    skipped 1 / 2 tasks

    One line lacks the separators and is counted, and the other two load.

  7. What does this program print? #include <fstream> #include <iostream> #include <stdexcept> #include <string> int main() { try { std::ifstream in{"missing.txt"}; if (!in) throw std::runtime_error("cannot open missing.txt"); } catch (const std::runtime_error& error) { std::cout << error.what(); } }

    Answer

    cannot open missing.txt

    The stream reports the missing file, and the catch prints the path.

  8. Why is returning an empty list for a missing file a bad interface?

    Answer

    It hides the difference between an empty tracker and a wrong path

    The caller cannot tell a new user from a mistyped file name.

  9. What is the risk of the partial read policy?

    Answer

    The user may not notice the skipped lines

    The count has to be printed, or the data disappears quietly.

  10. Which stream call reads one line?

    Answer

    std::getline

    getline reads up to the newline and keeps the rest of the line in a string.

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