1 The Problem
We want a to-do list you can actually use: add tasks, see them numbered, remove the ones you finish, and — crucially — have them saved to a file so they survive after you close the program. It teaches lists, a menu loop, and saving data to disk.
2 How to Think About It
Every operation works on a collection of tasks. The design questions are how that collection grows, how a task is found (by id, never by position), and how it survives between runs.
3 The Build — explained part by part
Here is the complete to-do list. The header/source split mirrors the earlier projects; what is new here is a struct with its own operator==, and file I/O done entirely through RAII streams.
#pragma once
#include <string>
#include <vector>
struct Task {
int id;
bool done;
std::string text;
bool operator==(const Task &other) const {
return id == other.id && done == other.done && text == other.text;
}
};
// One more than the highest id ever assigned -- never reused, even after a
// task is removed, the same guarantee a real database's auto-increment
// primary key gives you.
int next_id(const std::vector<Task> &tasks);
// Appends a new, not-done task with the next available id.
void add_task(std::vector<Task> &tasks, const std::string &text);
// Marks the task with the given id done, searching by id (not vector
// position). Returns true if found.
bool mark_done(std::vector<Task> &tasks, int id);
// Removes the task with the given id. Returns true if found.
bool remove_task(std::vector<Task> &tasks, int id);
// Hand-rolled "id|done|text" line format, one task per line.
std::string serialize(const std::vector<Task> &tasks);
std::vector<Task> deserialize(const std::string &data);
// Loads/saves tasks from/to a real file using the format above. load_tasks
// returns an empty list if the file does not exist yet.
std::vector<Task> load_tasks(const std::string &path);
void save_tasks(const std::string &path, const std::vector<Task> &tasks);
#include "TodoList.hpp"
#include <algorithm>
#include <fstream>
#include <sstream>
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;
}
void add_task(std::vector<Task> &tasks, const std::string &text) {
tasks.push_back(Task{next_id(tasks), false, text});
}
bool mark_done(std::vector<Task> &tasks, int id) {
for (auto &t : tasks) {
if (t.id == id) {
t.done = true;
return true;
}
}
return false;
}
bool remove_task(std::vector<Task> &tasks, 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::string serialize(const std::vector<Task> &tasks) {
std::ostringstream out;
for (const auto &t : tasks) {
out << t.id << '|' << (t.done ? 1 : 0) << '|' << t.text << '\n';
}
return out.str();
}
std::vector<Task> deserialize(const std::string &data) {
std::vector<Task> tasks;
std::istringstream in(data);
std::string line;
while (std::getline(in, line)) {
if (line.empty()) continue;
auto p1 = line.find('|');
if (p1 == std::string::npos) continue;
auto p2 = line.find('|', p1 + 1);
if (p2 == std::string::npos) continue;
int id = std::stoi(line.substr(0, p1));
bool done = line.substr(p1 + 1, p2 - p1 - 1) == "1";
std::string text = line.substr(p2 + 1);
tasks.push_back(Task{id, done, text});
}
return tasks;
}
std::vector<Task> load_tasks(const std::string &path) {
std::ifstream file(path);
if (!file) return {};
std::ostringstream buffer;
buffer << file.rdbuf();
return deserialize(buffer.str());
}
void save_tasks(const std::string &path, const std::vector<Task> &tasks) {
std::ofstream file(path);
file << serialize(tasks);
}
#include "TodoList.hpp"
#include <iostream>
static const std::string FILE_PATH = "tasks.db";
static void print_list(const std::vector<Task> &tasks) {
for (const auto &t : tasks) {
std::cout << "#" << t.id << " [" << (t.done ? "x" : " ") << "] " << t.text << "\n";
}
}
int main() {
auto tasks = load_tasks(FILE_PATH);
std::cout << "1) Add 2) List 3) Done 4) Remove 5) Quit\nChoice: ";
int choice;
while (std::cin >> choice) {
std::cin.ignore();
if (choice == 1) {
std::cout << "Task: ";
std::string text;
std::getline(std::cin, text);
add_task(tasks, text);
std::cout << "Added.\n";
} else if (choice == 2) {
print_list(tasks);
} else if (choice == 3) {
std::cout << "ID: ";
int id;
std::cin >> id;
std::cout << (mark_done(tasks, id) ? "Marked done.\n" : "No such task.\n");
} else if (choice == 4) {
std::cout << "ID: ";
int id;
std::cin >> id;
std::cout << (remove_task(tasks, id) ? "Removed.\n" : "No such task.\n");
} else if (choice == 5) {
break;
}
std::cout << "1) Add 2) List 3) Done 4) Remove 5) Quit\nChoice: ";
}
save_tasks(FILE_PATH, tasks);
return 0;
}
Task tasks[MAX_TODOS] plus a separate count variable the C version needed. There is no capacity to overflow and no count to keep in sync by hand; push_back and erase manage the size for you.bool operator==(const Task &other) const — defined right inside the
struct, this is what lets the round-trip test below write assert(back == tasks) and compare two whole vectors of tasks with plain ==, instead of writing a manual field-by-field comparison loop for every test that needs one.mark_done and remove_task search by id, not position — exactly like the C version, because the underlying bug that guards against (removing task #1 shifts every later task’s position, but never its id) is not a C-specific problem; it is true of any array-like collection in any language.
std::ifstream / std::ofstream — RAII in action: the file handle is a stack object whose destructor closes the file automatically, whichever way the function returns. There is no
fclose to forget, and no leaked file descriptor if an exception were ever thrown mid-function.
tasks[id], treating the id as if it were a vector index — it is not, once any task has ever been removed..id field with std::find_if, as remove_task does here, and never assume id equals position.next_id must look at the highest id ever assigned, not tasks.size() — after a removal, size shrinks but the next id must not go backwards and collide with a surviving task.next_id does here, so a freed id is never reused.4 Test & Prove Each Part
Six checks, including one that writes to and reads back from a real file in /tmp — exercising the actual save/load path, not just the in-memory logic. Same hand-written assert() harness as this project’s earlier pages.
#include "TodoList.hpp"
#include <cassert>
#include <cstdio>
#include <iostream>
#define RUN(name) do { name(); std::cout << "PASS: " << #name << "\n"; } while (0)
static void ids_count_up_and_are_never_reused() {
std::vector<Task> tasks;
add_task(tasks, "first");
add_task(tasks, "second");
assert(tasks[0].id == 1);
assert(tasks[1].id == 2);
remove_task(tasks, 1); // simulate removing task 1
add_task(tasks, "third");
assert(tasks.back().id == 3); // not reused as 1
}
static void mark_done_finds_by_id_not_position() {
std::vector<Task> tasks = { {5, false, "A"}, {9, false, "B"} };
assert(mark_done(tasks, 9));
assert(tasks[1].done);
assert(!tasks[0].done);
assert(!mark_done(tasks, 999));
}
static void remove_task_finds_by_id_not_position() {
std::vector<Task> tasks = { {5, false, "A"}, {9, false, "B"}, {12, false, "C"} };
assert(remove_task(tasks, 9));
assert(tasks.size() == 2);
assert(tasks[0].id == 5 && tasks[1].id == 12);
assert(!remove_task(tasks, 999));
}
static void round_trips_through_serialize_and_deserialize() {
std::vector<Task> tasks = { {1, true, "Ship the release"}, {2, false, "Write the docs"} };
auto back = deserialize(serialize(tasks));
assert(back == tasks);
}
static void saves_and_loads_through_a_real_file() {
std::vector<Task> tasks = { {1, false, "Buy milk"}, {2, true, "Walk the dog"} };
const std::string path = "/tmp/cpp_todo_test.db";
save_tasks(path, tasks);
auto loaded = load_tasks(path);
assert(loaded == tasks);
std::remove(path.c_str());
}
static void loading_a_missing_file_returns_an_empty_list() {
auto loaded = load_tasks("/tmp/cpp_todo_definitely_does_not_exist.db");
assert(loaded.empty());
}
int main() {
RUN(ids_count_up_and_are_never_reused);
RUN(mark_done_finds_by_id_not_position);
RUN(remove_task_finds_by_id_not_position);
RUN(round_trips_through_serialize_and_deserialize);
RUN(saves_and_loads_through_a_real_file);
RUN(loading_a_missing_file_returns_an_empty_list);
std::cout << "All tests passed.\n";
return 0;
}
Compile and run with g++ -std=c++20 -o test_run TodoList.cpp test_TodoList.cpp && ./test_run.
5 The Interface
What it expects
1
Buy milkWhat it returns
#1 [ ] Buy milk
#2 [x] Walk the dog6 Run It & Automate It
Save the code as TodoList.hpp / TodoList.cpp / main.cpp and compile it with g++ — that turns your source directly into a native executable for your machine. No separate runtime needed: the compiled binary runs on its own.
g++ -std=c++20 -o todo main.cpp TodoList.cpp && ./todoTasks are saved to
tasks.db in the current directory, so they are still there next time you run it.A CI tool like Jenkins runs the same compile-then-test-then-check-for-leaks steps automatically whenever the code changes — every line below has a plain explanation.
1) Add 2) List 3) Done 4) Remove 5) Quit
Choice: 1
Task: Buy milk
Added.
1) Add 2) List 3) Done 4) Remove 5) Quit
Choice: 2
#1 [ ] Buy milk
1) Add 2) List 3) Done 4) Remove 5) Quit
Choice: 52) List (the number after #) is what 3) Done and 4) Remove expect — not the task’s position in the list.save_tasks before returning — quitting through choice 5 falls through to it, but an early return elsewhere would skip it.// Jenkinsfile — compiles, tests, and checks for leaks on every change.
pipeline {
agent any
stages {
stage('Get the code') {
// download the latest code
steps { checkout scm }
}
stage('Compile') {
steps {
// confirm a compiler is installed
sh 'g++ --version'
// compile with strict warnings on
sh 'g++ -std=c++20 -Wall -Wextra -o app *.cpp'
}
}
stage('Run the tests') {
steps {
// prints PASS/FAIL, exits non-zero on failure
sh './app'
}
}
stage('Check for memory leaks') {
steps {
// fails the build on any leak or invalid access
sh 'valgrind --error-exitcode=1 --leak-check=full ./app'
}
}
}
post {
success { echo 'All tests passed, no leaks found.' }
failure { echo 'A test or Valgrind check failed — see above.' }
}
}
- Add an “edit” option. Change a task’s text in place by id. (Teaches: mutating a vector element found by
std::find_if.) - Sort the list before printing. Not-done tasks first, then done ones. (Teaches:
std::sortwith a custom comparator, astop_nused elsewhere on this site.) - Switch the file format to JSON. Replace the hand-rolled
id|done|textline format. (Teaches: why a real format needs escaping the delimiter character if task text can ever contain it.)
std::vector, how a struct’s own operator== makes whole-object comparisons trivial in tests, and how RAII file streams close themselves automatically. Related: Classes and RAII, STL Containers.