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¶
add TEXT [--priority low|normal|high]— append a task. Auto-assign the next integer id.list [--all]— show tasks. By default hide completed ones;--allshows everything.done ID— mark a task complete.remove ID— delete a task.- Tasks persist in
tasks.txtin the current directory, one per line:id<TAB>done<TAB>priority<TAB>text. - 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¶
Things to notice¶
std::ranges::find(tasks, id, &Task::id)— the third argument is a projection: search by each task'sidmember.find_taskreturns a pointer (Task*) so it can say "not found" withnullptr. You'll learn pointers properly in the next track; here it's just "a handle to an element, or nothing".std::erase_ifremoves matching elements and returns how many it removed.- Commands return exit codes:
0success,1runtime failure,2usage error — a Unix convention scripts can check. - The anonymous
namespacekeeps helpers private to their.cppfile. try/catchinmainturns any unexpected exception (e.g. a read-only disk) into a clean error message.
Build it in stages¶
- Hardcode first. Make
listprint a fixedstd::vector<Task>. No files yet. - Add storage. Implement
save_tasks/load_tasks. Runaddtwice, then opentasks.txtin an editor. - Add the other commands one at a time, testing each.
- Break it on purpose: delete
tasks.txt, add a garbage line to it, rundone 999,done abc,donewith no id, and an unknown command. Every case must print a clear message. - Polish: sort
listoutput by priority (high first); print a summary line like3 open, 2 done.
Stretch goals¶
todo edit ID "new text".- Due dates:
--due 2026-12-31stored as a fifth column, and atodo overduecommand (usestd::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, thenstd::filesystem::renameit over the original so a crash mid-write can't corrupt your data. - Set up a
CMakeLists.txtfor it (preview of the CMake lesson).
Self-check¶
- [ ] Explain every line of
todo.cppandstorage.cppto someone else. - [ ] Explain why
load_tasksreturns by value andsave_taskstakes aconst&. - [ ] 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.hppcontained the body ofload_tasksand two.cppfiles included it.
When those are true, move on to Intermediate → Pointers & references.