Build a Task Manager CLI with C++

Build a C++17 CLI app with smart pointers, CMake, and GoogleTest.

Introduction

30 Second Summary

Every app on your phone, every game engine, and every high-performance system running behind the scenes has one thing in common: they rely on code that manages memory precisely. C++ is the language that gives you that level of control.

In this project, you will build a command-line Task Manager application using modern C++17 patterns, a professional build system, and automated unit tests.

What You'll Build

A CLI application that lets you add, complete, list, and persist tasks to a file, structured as a professional C++ project with separate source, header, and test files.

By the end of this project, you'll have:

  • A CMake-based C++ project with proper directory layout (src/, include/, tests/).
  • Task and TaskManager classes using smart pointers, enum class, and RAII for automatic resource management.
  • File-based persistence that automatically loads and saves tasks, plus unit tests with GoogleTest to verify your logic.
  • Secret Mission: Add priority-based filtering and sorting using STL algorithms and lambda expressions.

Are there any prerequisites?

You need basic programming knowledge (variables, functions, loops, classes). Python experience is helpful but not required. This project explains every C++ concept from scratch and compares patterns to Python equivalents along the way.

Set Up Your C++ Development Environment

Before you can build a C++ project, you need a compiler that translates your source code into an executable program your computer can run.

On Windows, the recommended approach is MSYS2, a package manager that gives you access to a complete C++ toolchain. In this step, you'll install the toolchain, configure VS Code to use it, and prove everything works by compiling your first program.

In this step, get ready to:
  • Install MSYS2 and the UCRT64 C++ toolchain (GCC, CMake, GDB) on Windows 11.
  • Configure VS Code with the C/C++ extension and compiler path.
  • Compile and run a hello-world program to verify your setup.
Install MSYS2 and the UCRT64 toolchain

MSYS2 is a Windows tool that gives you a Linux-style package manager called pacman. You'll use it to install GCC (the compiler), CMake (the build system), and GDB (the debugger).

  • Open your browser and go to msys2.org.
  • Click the download link for msys2-x86_64-20260611.exe.
  • Run the installer once the download completes.
  • Keep the default installation folder (C:\msys64) and click through each screen.
  • Click Finish when the installer completes.

A terminal window labelled MSYS2 UCRT64 opens automatically after installation.

What is UCRT64?

MSYS2 offers several environments for building Windows software. UCRT64 is the recommended default because it uses the Universal C Runtime that ships with Windows 10 and newer.

Think of it as the "modern Windows" build mode. All the packages you install through UCRT64 produce native Windows executables.

  • Install GCC, CMake, and GDB by running this command in the UCRT64 terminal:
pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-gdb
  • When pacman asks Proceed with installation? [Y/n], press Enter to confirm.

What does this command do?

The pacman -S command installs packages from the MSYS2 repository. You're installing three tools at once:

  • mingw-w64-ucrt-x86_64-gcc is the GCC C++ compiler (g++).
  • mingw-w64-ucrt-x86_64-cmake is CMake, a tool that manages how your code gets compiled.
  • mingw-w64-ucrt-x86_64-gdb is GDB, a debugger for stepping through code.

Now you need to add the UCRT64 tools to your Windows PATH so VS Code and other terminals can find them.

  • Press the Windows key and type advanced system.
  • Click View advanced system settings from the search results.
  • Click Environment Variables at the bottom of the System Properties window.
  • Under System variables, find the row called Path and click Edit.
  • Click New and paste this path:
C:\msys64\ucrt64\bin
  • Click OK three times to close all dialogs.

Why add this to PATH?

The PATH variable tells Windows where to look for programs when you type a command. Without this entry, typing g++ in a terminal would return an error because Windows wouldn't know where the compiler lives.

Now verify that the compiler is accessible. You need to close and reopen any terminal that was open before the PATH change.

  • Close the MSYS2 UCRT64 terminal window.
  • Press the Windows key, type MSYS2 UCRT64, and press Enter to open a fresh UCRT64 terminal.
  • Check the GCC version by running:
g++ --version

✔️ I see version 16 or higher

You should see output similar to g++.exe (Rev1, Built by MSYS2 project) 16.1.0. GCC is installed and ready.

  • Also confirm CMake is working by running:
cmake --version

You should see a version number starting with 4. Your toolchain is fully installed.

ⓧ I see an older version

If you see a version below 16 (like 13.x), you likely have an older MSYS2 installation or another GCC on your PATH.

  • Open the MSYS2 UCRT64 terminal.
  • Update all packages by running:
pacman -Syu
  • If the terminal closes during the update, reopen MSYS2 UCRT64 and run pacman -Syu again.
  • Reinstall GCC by running:
pacman -S mingw-w64-ucrt-x86_64-gcc
  • Close and reopen the terminal, then run g++ --version again. You should now see version 16.

If you still see an older version, check that C:\msys64\ucrt64\bin appears before any other GCC path in your system PATH. Move it to the top of the list in the Environment Variables editor.

ⓧ Command not found

This means your PATH is not set correctly or the packages did not install.

  • Open File Explorer and navigate to C:\msys64\ucrt64\bin. Confirm that g++.exe exists in that folder.
  • If the file does not exist, reopen the MSYS2 UCRT64 terminal from the Start menu and run the pacman install command again:
pacman -S mingw-w64-ucrt-x86_64-gcc
  • If g++.exe exists but the command is not found, re-check your PATH. Press the Windows key, type advanced system, open View advanced system settings, click Environment Variables, and verify that C:\msys64\ucrt64\bin is listed in the Path system variable.
  • After fixing the PATH, close and reopen the terminal before retrying.
Install the C/C++ extension in VS Code

VS Code needs an extension to understand C++ syntax, provide IntelliSense, and locate your compiler.

  • Switch to VS Code.
  • Open the Extensions panel by pressing Ctrl+Shift+X.
  • Type C++ in the search bar.
  • Find the extension called C/C++ by Microsoft and click Install.

Now configure the extension to use the UCRT64 compiler.

  • Open the Command Palette by pressing Ctrl+Shift+P.
  • Type C/C++: Edit Configurations (UI) and select it.
  • In the Compiler path field, enter:
C:\msys64\ucrt64\bin\g++.exe
  • Confirm that the C++ standard dropdown is set to c++17.
  • Close the configuration tab. VS Code now knows where your compiler lives.

What did this configure?

The C/C++ extension uses the compiler path for IntelliSense (code completion and error highlighting). It created a .vscode/c_cpp_properties.json file that stores this setting for your project.

Verify the toolchain by compiling a C++ program

The ultimate proof that your environment works is compiling and running real C++ code. You'll create a small project folder with a hello-world program.

  • Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-manager
cd task-manager
  • Create the src directory by running:
mkdir src
  • Open this folder in VS Code by running:
code .

Why start on the Desktop?

Putting your project on the Desktop makes it easy to find in File Explorer. You'll always know exactly where your code lives.

VS Code reopens with the task-manager folder as your workspace. Now create the source file.

  • In the Explorer sidebar on the left, click the src folder to expand it.
  • Hover over the src folder name and click the New File icon.
  • Name the file main.cpp and press Enter.
  • Paste the following code into src/main.cpp:
#include <iostream>

int main() {
    std::cout << "Hello, World!\n";
    return 0;
}

What does this code do?

  • #include <iostream> imports the standard input/output library. In Python, this would be like import sys.
  • int main() is the entry point of every C++ program. It's the equivalent of Python's if __name__ == "__main__": block.
  • std::cout prints text to the terminal, like Python's print().
  • return 0 tells the operating system the program finished successfully.
  • Save the file with Ctrl+S.
  • Open the VS Code terminal (Ctrl+`) and compile the program by running:
g++ src/main.cpp -o main.exe
  • Run the compiled program:
.\main.exe

You should see Hello, World! printed in the terminal.

Don't see Hello, World?

  • If you see an error about g++ not being recognized, close VS Code completely and reopen it. The terminal needs to reload the updated PATH.
  • If you see a compilation error, check that your code matches the snippet above exactly. Curly braces, semicolons, and angle brackets all matter in C++.
  • If .\main.exe gives a "not found" error, make sure you're in the task-manager folder. Run ls and confirm main.exe exists.

help me debug my C++ compilation error

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.cpp and confirmed the output.

ⓧ I'd like to double check the full code

Here is the complete src/main.cpp file:

#include <iostream>

int main() {
    std::cout << "Hello, World!\n";
    return 0;
}

Your C++ development environment is fully operational. Next up, you'll turn this folder into a professional project with a CMake build system and a proper directory layout.

Create a Professional Project Structure with CMake

In Python, you can run any .py file directly. C++ works differently. Your source code must be compiled into a binary before it can run, and real projects split code across multiple files.

Professional C++ projects use a build system to manage this compilation process. In this step, you will set up CMake to define how your project compiles, where header files live, and how to integrate a testing framework.

In this step, get ready to:
  • Create the project directory layout with separate folders for source, headers, and tests.
  • Write a CMakeLists.txt that configures C++17, defines build targets, and integrates GoogleTest.
  • Run the CMake configure and build cycle to verify everything compiles.
Create the project directory layout

A professional C++ project separates code by role. Header files (declarations) go in include/, implementation files go in src/, and test files go in tests/.

Why separate headers and source files?

In Python, you import a module and get everything. In C++, other files only see what you declare in a header file. The actual implementation lives in a separate .cpp file.

This separation speeds up compilation because changes to implementation details do not force every file that uses the class to recompile.

  • Create the include and tests directories inside your task_manager project folder by running these commands:
mkdir include
mkdir tests
  • Create empty placeholder files for the source and test files that CMake will expect by running these commands:
touch src/task.cpp
touch src/task_manager.cpp
touch tests/task_test.cpp

Why create empty files?

CMake checks that every source file listed in its configuration actually exists on disk. These empty placeholders satisfy that check now. You will fill them with real code in the next steps.

  • Confirm the directory layout by running:
ls

You should see include, src, and tests listed in the output.

Don't see all three directories?

Make sure you ran the mkdir commands from inside your task_manager project folder. Run pwd to check your current location.

Help me figure out what went wrong with my project directory layout.

Write the CMakeLists.txt

CMake reads a file called CMakeLists.txt to understand how to build your project. Think of it as a recipe that tells the compiler which files to compile, which C++ version to use, and which libraries to link.

  • Create a new file called CMakeLists.txt in the root of your task_manager folder. In VS Code, right-click in the Explorer sidebar and select New File.
  • Name the file CMakeLists.txt (the capitalisation matters).
  • Add the project configuration and executable target by pasting this code into CMakeLists.txt:
# Set the minimum CMake version required
cmake_minimum_required(VERSION 3.14)
project(task_manager)

# Use the C++17 standard
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Define the main executable and its source files
add_executable(task_manager
    src/main.cpp
    src/task.cpp
    src/task_manager.cpp
)

# Tell the compiler where to find header files
target_include_directories(task_manager PRIVATE include)

What does this code do?

cmake_minimum_required(VERSION 3.14) ensures your CMake version supports all features used here. GoogleTest's FetchContent pattern requires at least 3.14.

CMAKE_CXX_STANDARD 17 tells the compiler to use C++17 features. REQUIRED ON means the build fails if the compiler does not support C++17.

add_executable defines a build target named task_manager and lists every .cpp file it needs. target_include_directories tells the compiler to look in include/ when resolving #include statements.

  • Below the existing code, add the GoogleTest integration block:
# Download and build GoogleTest as part of this project
include(FetchContent)
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG v1.17.0
)
# Windows-specific: prevent CRT library conflicts
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

What does FetchContent do?

FetchContent is CMake's built-in way to download dependencies at configure time. It clones the GoogleTest repository at the exact tag v1.17.0 and builds it alongside your project.

The gtest_force_shared_crt setting prevents a common Windows build error where GoogleTest's runtime library choice conflicts with your project's. This is documented in GoogleTest's official quickstart guide.

  • Below the GoogleTest block, add the test target configuration:
# Enable CMake's testing support
enable_testing()

# Define the test executable and its source files
add_executable(task_tests
    tests/task_test.cpp
    src/task.cpp
    src/task_manager.cpp
)

# Test executable also needs access to headers
target_include_directories(task_tests PRIVATE include)
# Link GoogleTest's main entry point to the test executable
target_link_libraries(task_tests GTest::gtest_main)

# Let CMake discover individual tests automatically
include(GoogleTest)
gtest_discover_tests(task_tests)

What does gtest_main provide?

In Python's pytest, you just write test functions and the runner finds them. GoogleTest works similarly. GTest::gtest_main provides the main() function that discovers and runs all your tests. You only write the test cases themselves.

gtest_discover_tests tells CMake to scan the compiled test binary and register each test case individually, so you can run them with ctest.

  • Save CMakeLists.txt.

✔️ Awesome, I've got everything!

Great. Double check you have saved CMakeLists.txt before moving on to the build step.

ⓧ I'd like to double check the full code

Here is the complete CMakeLists.txt file. Make sure yours matches exactly:

cmake_minimum_required(VERSION 3.14)
project(task_manager)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(task_manager
    src/main.cpp
    src/task.cpp
    src/task_manager.cpp
)

target_include_directories(task_manager PRIVATE include)

include(FetchContent)
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG v1.17.0
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

enable_testing()

add_executable(task_tests
    tests/task_test.cpp
    src/task.cpp
    src/task_manager.cpp
)

target_include_directories(task_tests PRIVATE include)
target_link_libraries(task_tests GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(task_tests)
Run the CMake configure and build cycle

CMake has two phases. First, configure reads your CMakeLists.txt and generates platform-specific build files. Then build actually compiles your code into a binary.

  • Run the configure step by running this command:
cmake -G "MinGW Makefiles" -S . -B build

What does this command do?

  • -G "MinGW Makefiles" tells CMake to generate Makefiles compatible with the MinGW toolchain from MSYS2.
  • -S . sets the source directory to the current folder (where your CMakeLists.txt lives).
  • -B build creates a build/ directory to keep generated files separate from your source code.

This step downloads GoogleTest from GitHub, so it takes a minute the first time. You should see output ending with Configuring done and Generating done.

Seeing a 'Cannot find source file' error?

CMake checks that every file listed in add_executable exists. Make sure you created the placeholder files earlier with the touch commands. Run ls src/ and ls tests/ to verify task.cpp, task_manager.cpp, and task_test.cpp all exist.

Help me debug which files are missing.

  • Compile the project by running the build command:
cmake --build build

You should see compilation output and no errors. The build produces a task_manager.exe binary inside the build/ directory.

  • Run the compiled program to verify it works:
./build/task_manager.exe

You should see Hello, World! printed in the terminal.

Build failed with errors?

If you see errors about missing commands, make sure you are running from the UCRT64 terminal or that C:\msys64\ucrt64\bin is in your system PATH. The cmake and g++ commands must both be accessible.

If the configure step succeeded but the build step fails, double-check that src/main.cpp still contains your Hello World program with a main() function.

Help me troubleshoot this build failure.

Your build system is ready. You have a professional project structure with separate directories for source, headers, and tests. Next up, you will write the Task class using modern C++17 features like enum class and const-correctness.

Implement the Task Class with Modern C++17

Every application needs a core data model. For a task manager, that model is the Task itself. Before writing any manager logic or UI, you need a solid class that represents a single task with its description, priority, and completion state.

In this step, you will define and implement the Task class using modern C++17 patterns. You will use an enum class for type-safe priorities, member initializer lists for efficient construction, and const-correct getters that prevent accidental mutation.

In this step, get ready to:
  • Define the Task class header with enum class, private members, and const-correct getters.
  • Implement Task methods using member initializer lists and string serialization.
  • Build and verify the Task class compiles cleanly.
Define the Task class in include/task.h

C++ separates declarations from implementations. The header file (.h) declares the class interface. The source file (.cpp) provides the implementation.

Why separate .h and .cpp files?

In Python, you define a class in one file and import it. C++ uses a two-file model: the header declares what exists (class names, function signatures), and the source file defines how it works. This lets the compiler check types across files without needing to recompile everything when one implementation changes.

  • Create a new file called task.h inside the include/ folder of your project, then add the following code:
#ifndef TASK_H
#define TASK_H

#include <string>

// Type-safe priority levels (like Python's Enum)
enum class Priority {
    Low,
    Medium,
    High
};

class Task {
public:
    // Constructors
    Task(const std::string& description, Priority priority);
    Task(const std::string& description, Priority priority, bool completed);

    // Const-correct getters (promise not to modify the object)
    const std::string& getDescription() const;
    Priority getPriority() const;
    bool isCompleted() const;
    void markComplete();

    // Serialization for file persistence
    std::string serialize() const;
    static Task deserialize(const std::string& line);

private:
    std::string description_;
    Priority priority_;
    bool completed_;
};

// Free functions for converting priority to/from strings
std::string priorityToString(Priority p);
Priority stringToPriority(const std::string& s);

#endif

What does this code do?

  • The #ifndef/#define/#endif pattern is an include guard. It prevents the header from being processed twice if multiple files include it.
  • enum class Priority creates a scoped enumeration. Unlike a plain enum, its values (Priority::Low) cannot accidentally convert to integers. This is similar to Python's Enum class.
  • The const keyword after each getter (e.g. getDescription() const) promises the method will not modify the object. The compiler enforces this promise.
  • Private members use a trailing underscore (description_) to distinguish them from parameters. This is a common C++ naming convention.
  • Save include/task.h.

You will not see any output yet. The header only declares what exists. The implementation comes next.

Seeing red squiggles in VS Code?

VS Code's IntelliSense may show errors until you create the matching .cpp file. This is normal. The errors will disappear once you write the implementation below.

If squiggles persist after completing the step, check that your include guard matches exactly: TASK_H in both the #ifndef and #define lines.

Help me fix IntelliSense errors in my task.h header file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved include/task.h before moving on.

ⓧ I'd like to double check the full code

#ifndef TASK_H
#define TASK_H

#include <string>

enum class Priority {
    Low,
    Medium,
    High
};

class Task {
public:
    Task(const std::string& description, Priority priority);
    Task(const std::string& description, Priority priority, bool completed);

    const std::string& getDescription() const;
    Priority getPriority() const;
    bool isCompleted() const;
    void markComplete();

    std::string serialize() const;
    static Task deserialize(const std::string& line);

private:
    std::string description_;
    Priority priority_;
    bool completed_;
};

std::string priorityToString(Priority p);
Priority stringToPriority(const std::string& s);

#endif
Implement Task methods in src/task.cpp

Now you will write the implementation. This is where Python developers encounter some of C++'s most distinctive patterns: member initializer lists, const references, and explicit type casting.

  • Create a new file called task.cpp inside the src/ folder (or open the existing placeholder), then add the includes and constructors:
#include "task.h"
#include <sstream>
#include <stdexcept>

// Primary constructor using member initializer list
Task::Task(const std::string& description, Priority priority)
    : description_{description}, priority_{priority}, completed_{false} {}

// Overloaded constructor for loading saved tasks
Task::Task(const std::string& description, Priority priority, bool completed)
    : description_{description}, priority_{priority}, completed_{completed} {}

What is a member initializer list?

The colon after the constructor signature (: description_{description}, priority_{priority}, completed_{false}) initializes each member directly. In Python, you would write self.description = description inside __init__. The C++ version is more efficient because it constructs members in place rather than constructing them empty first and then assigning.

The curly braces (description_{description} instead of description_(description)) use uniform initialization syntax. Braces prevent narrowing conversions, catching bugs at compile time that parentheses would silently allow.

  • Below the constructors, add the getter methods and markComplete:
// Const-correct getters return data without allowing modification
const std::string& Task::getDescription() const {
    return description_;
}

Priority Task::getPriority() const {
    return priority_;
}

bool Task::isCompleted() const {
    return completed_;
}

// Only method that modifies state
void Task::markComplete() {
    completed_ = true;
}

Why return const std::string& instead of std::string?

Returning const std::string& gives the caller a read-only reference to the existing string. No copy is made. In Python, returning self.description also avoids a copy (strings are reference-counted objects), but Python hides this from you. C++ makes the choice explicit so you control performance.

The const after the method name tells the compiler this method can be called on a read-only Task object. If you accidentally try to modify description_ inside a const method, the compiler will reject it.

  • Below the getters, add the serialize and deserialize methods for file persistence:
// Convert task to a pipe-delimited string for file storage
std::string Task::serialize() const {
    std::ostringstream oss;
    oss << static_cast<int>(priority_) << "|"
        << (completed_ ? "1" : "0") << "|"
        << description_;
    return oss.str();
}

// Reconstruct a Task from a serialized string
Task Task::deserialize(const std::string& line) {
    std::istringstream iss{line};
    std::string priority_str;
    std::string completed_str;
    std::string description;

    std::getline(iss, priority_str, '|');
    std::getline(iss, completed_str, '|');
    std::getline(iss, description);

    Priority priority = static_cast<Priority>(std::stoi(priority_str));
    bool completed = (completed_str == "1");

    return Task{description, priority, completed};
}

What does this serialization do?

serialize() converts a Task into a string like 2|0|Buy groceries (priority 2 = High, not completed, description). The pipe character | separates fields so the description can contain spaces.

deserialize() is a static factory method. It reads the pipe-delimited string and reconstructs a Task. In Python, you might use a @classmethod for this pattern. The static_cast<int>(priority_) explicitly converts the enum class to its underlying integer value for storage.

  • Below the deserialize method, add the two free functions that convert priority values to and from strings:
// Convert Priority enum to human-readable string
std::string priorityToString(Priority p) {
    switch (p) {
        case Priority::Low: return "Low";
        case Priority::Medium: return "Medium";
        case Priority::High: return "High";
        default: return "Unknown";
    }
}

// Convert string or number to Priority enum
Priority stringToPriority(const std::string& s) {
    if (s == "Low" || s == "1") return Priority::Low;
    if (s == "Medium" || s == "2") return Priority::Medium;
    if (s == "High" || s == "3") return Priority::High;
    return Priority::Medium;
}

Why free functions instead of methods?

These helper functions operate on the Priority enum, not on a Task object. Making them free functions (not class members) keeps the Task class focused on task-specific logic. This follows the C++ principle of preferring non-member functions when they do not need access to private state.

  • Save src/task.cpp.

You will not see visible output yet. The next substep builds the project to confirm everything compiles correctly.

Seeing a red underline on the #include lines?

Make sure the first line is #include "task.h" with double quotes, not angle brackets. Double quotes tell the compiler to look in your project's include directory first. Angle brackets search system paths only.

Help me fix include errors in my task.cpp file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/task.cpp before moving on.

ⓧ I'd like to double check the full code

#include "task.h"
#include <sstream>
#include <stdexcept>

Task::Task(const std::string& description, Priority priority)
    : description_{description}, priority_{priority}, completed_{false} {}

Task::Task(const std::string& description, Priority priority, bool completed)
    : description_{description}, priority_{priority}, completed_{completed} {}

const std::string& Task::getDescription() const {
    return description_;
}

Priority Task::getPriority() const {
    return priority_;
}

bool Task::isCompleted() const {
    return completed_;
}

void Task::markComplete() {
    completed_ = true;
}

std::string Task::serialize() const {
    std::ostringstream oss;
    oss << static_cast<int>(priority_) << "|"
        << (completed_ ? "1" : "0") << "|"
        << description_;
    return oss.str();
}

Task Task::deserialize(const std::string& line) {
    std::istringstream iss{line};
    std::string priority_str;
    std::string completed_str;
    std::string description;

    std::getline(iss, priority_str, '|');
    std::getline(iss, completed_str, '|');
    std::getline(iss, description);

    Priority priority = static_cast<Priority>(std::stoi(priority_str));
    bool completed = (completed_str == "1");

    return Task{description, priority, completed};
}

std::string priorityToString(Priority p) {
    switch (p) {
        case Priority::Low: return "Low";
        case Priority::Medium: return "Medium";
        case Priority::High: return "High";
        default: return "Unknown";
    }
}

Priority stringToPriority(const std::string& s) {
    if (s == "Low" || s == "1") return Priority::Low;
    if (s == "Medium" || s == "2") return Priority::Medium;
    if (s == "High" || s == "3") return Priority::High;
    return Priority::Medium;
}
Build and verify the Task class compiles cleanly

A clean compile confirms your header declarations match your implementation, all types are correct, and no syntax errors slipped in.

  • Return to your MSYS2 UCRT64 terminal and rebuild the project by running:
cmake --build build
  • You should see output ending with lines like Linking CXX executable task_manager.exe with no errors.

Seeing compiler errors?

If you see errors mentioning undefined references or missing symbols, check that you saved both include/task.h and src/task.cpp. The function signatures in the .cpp file must match the declarations in the .h file exactly.

If you see an error about task_manager.cpp or task_test.cpp, those files are referenced in CMakeLists.txt but do not have content yet. Create empty placeholder files for them if they do not exist already.

Help me fix compiler errors when building my Task class.

C++ concepts vs Python equivalents

  • enum class = Python's enum.Enum. Both create type-safe named constants, but C++ enforces it at compile time.
  • Member initializer list = Python's __init__ assignments. C++ initializes members before the constructor body runs, which is more efficient for non-trivial types.
  • const methods = Python has no equivalent enforcement. Python trusts you not to mutate. C++ makes the compiler enforce immutability.
  • Header + source files = Python's single .py file with import. C++ splits declaration from definition for faster recompilation and clearer interfaces.

Your Task class is implemented and compiling cleanly. Next up, you will build the TaskManager class that owns and manages a collection of these tasks using smart pointers and file persistence.

Build the TaskManager with Smart Pointers and File Persistence

You have a Task class that knows how to describe itself, serialize to text, and deserialize back. Now you need something to own those tasks. In Python, you would toss objects into a list and let the garbage collector worry about cleanup. C++ gives you a choice: who is responsible for each object's lifetime?

In this step, you will build a TaskManager class that exclusively owns every Task through std::unique_ptr. You will also wire up file persistence using RAII: the constructor loads tasks from disk, and the destructor saves them back automatically when the program ends.

In this step, get ready to:
  • Implement the TaskManager class with smart pointer ownership.
  • Add RAII-based file persistence that loads and saves tasks automatically.
  • Wire up a CLI menu loop so you can interact with your tasks from the terminal.
Define the TaskManager interface with smart pointer ownership

Before writing any logic, think about ownership. In Python, a list of tasks would look like self.tasks = [] and garbage collection handles the rest. In C++, you have to answer: who creates each Task, who can access it, and who deletes it?

The answer here is that TaskManager is the sole owner. No other code should be able to delete or move a Task out from under you. std::unique_ptr enforces this at compile time.

  • In the VS Code Explorer sidebar, right-click the include folder and select New File.
  • Name the file task_manager.h.
  • Add the following code to task_manager.h:
#ifndef TASK_MANAGER_H
#define TASK_MANAGER_H

#include "task.h"
#include <vector>
#include <memory>
#include <string>

class TaskManager {
public:
    // Constructor takes a filename for persistence
    explicit TaskManager(const std::string& filename);
    // Destructor automatically saves tasks to file (RAII)
    ~TaskManager();

    // Task operations
    void addTask(const std::string& description, Priority priority);
    void completeTask(std::size_t index);
    void displayTasks() const;
    std::size_t getTaskCount() const;
    const Task& getTask(std::size_t index) const;

private:
    // unique_ptr enforces exclusive ownership of each Task
    std::vector<std::unique_ptr<Task>> tasks_;
    std::string filename_;

    // File persistence helpers
    void loadFromFile();
    void saveToFile() const;
};

#endif

What does this header declare?

This header defines the TaskManager's public contract and private internals.

  • explicit on the constructor prevents accidental implicit conversions from a string. In Python, __init__ never has this problem because you always call it explicitly.
  • std::vector<std::unique_ptr<Task>> is like Python's list[Task], but each element is exclusively owned. No other code can copy or steal a task from this vector.
  • The destructor ~TaskManager() is where RAII shines. When the TaskManager goes out of scope, the destructor fires and saves all tasks to disk automatically.

Why unique_ptr instead of shared_ptr?

Tasks are owned exclusively by the TaskManager. No other part of the program should hold a long-lived reference to a Task. std::unique_ptr has zero overhead compared to a raw pointer and makes ownership semantics explicit at compile time.

In professional C++ codebases, unique_ptr is the default smart pointer. Reach for shared_ptr only when multiple owners genuinely need to share a resource's lifetime.

  • Save task_manager.h.

You won't see a change yet. The header declares the interface, but you need the implementation next.

Implement TaskManager methods with RAII persistence

Now you will write the actual logic. Think of this like implementing a Python class method by method: the constructor sets up state, methods operate on it, and the destructor cleans up.

  • In the Explorer sidebar, right-click the src folder and select New File.
  • Name it task_manager.cpp.
  • Add the includes, constructor, destructor, and core task methods by pasting this code:
#include "task_manager.h"
#include <iostream>
#include <fstream>

// Constructor: initialize filename and load existing tasks from disk
TaskManager::TaskManager(const std::string& filename)
    : filename_{filename} {
    loadFromFile();
}

// Destructor: automatically save all tasks when the object is destroyed
TaskManager::~TaskManager() {
    saveToFile();
}

// Create a new Task and transfer ownership into the vector
void TaskManager::addTask(const std::string& description, Priority priority) {
    tasks_.push_back(std::make_unique<Task>(description, priority));
}

// Mark a task as complete by index (with bounds check)
void TaskManager::completeTask(std::size_t index) {
    if (index < tasks_.size()) {
        tasks_[index]->markComplete();
    }
}

// Display all tasks with their index, status, and priority
void TaskManager::displayTasks() const {
    if (tasks_.empty()) {
        std::cout << "No tasks.\n";
        return;
    }

    for (std::size_t i = 0; i < tasks_.size(); ++i) {
        const auto& task = tasks_[i];
        std::cout << i + 1 << ". "
                  << (task->isCompleted() ? "[X] " : "[ ] ")
                  << task->getDescription()
                  << " (" << priorityToString(task->getPriority()) << ")\n";
    }
}

std::size_t TaskManager::getTaskCount() const {
    return tasks_.size();
}

const Task& TaskManager::getTask(std::size_t index) const {
    return *tasks_.at(index);
}

What does this code do?

  • The constructor uses a member initializer list (: filename_{filename}) to set the filename before the body runs. This is more efficient than assigning inside the body. Python has no equivalent.
  • std::make_unique<Task>(...) creates a Task on the heap and wraps it in a unique_ptr. It is the modern replacement for new Task(...) and ensures no memory leaks.
  • tasks_[index]->markComplete() uses the arrow operator because each element is a pointer. In Python, you would write self.tasks[index].mark_complete() with dot notation.
  • getTask uses tasks_.at(index) instead of tasks_[index]. The at() method throws an exception on out-of-bounds access, making bugs easier to catch.

You won't see a change yet. The constructor calls loadFromFile() which does not have an implementation. The next chunk adds that.

  • Add the file persistence methods below the code you just wrote in task_manager.cpp:
// Load tasks from file on startup (called by constructor)
void TaskManager::loadFromFile() {
    std::ifstream file{filename_};
    if (!file) return;

    std::string line;
    while (std::getline(file, line)) {
        if (!line.empty()) {
            tasks_.push_back(std::make_unique<Task>(Task::deserialize(line)));
        }
    }
}

// Save all tasks to file on shutdown (called by destructor)
void TaskManager::saveToFile() const {
    std::ofstream file{filename_};
    for (const auto& task : tasks_) {
        file << task->serialize() << "\n";
    }
}

What is RAII and why does it matter here?

RAII stands for Resource Acquisition Is Initialization. The idea: acquire a resource (the file data) in the constructor, release it (save to file) in the destructor. The object's lifetime IS the resource's lifetime.

In Python, you would use a with statement or rely on __del__ (unreliable). C++ destructors are deterministic. The moment manager goes out of scope, the destructor fires and your tasks are saved. No forgetting to call save().

  • Save task_manager.cpp.
  • Build the project to confirm it compiles by running this in your UCRT64 terminal:
cmake --build build

You should see the build complete with no errors. The executable still prints "Hello, World!" because main.cpp has not been updated yet.

Seeing undefined reference errors?

  • Check that src/task_manager.cpp is saved. An unsaved file means the compiler sees an empty file.
  • Verify your CMakeLists.txt still lists src/task_manager.cpp in the add_executable call.
  • Make sure #include "task_manager.h" is the first line in your .cpp file.

Help me debug my undefined reference errors in task_manager.cpp

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete include/task_manager.h:

#ifndef TASK_MANAGER_H
#define TASK_MANAGER_H

#include "task.h"
#include <vector>
#include <memory>
#include <string>

class TaskManager {
public:
    explicit TaskManager(const std::string& filename);
    ~TaskManager();

    void addTask(const std::string& description, Priority priority);
    void completeTask(std::size_t index);
    void displayTasks() const;
    std::size_t getTaskCount() const;
    const Task& getTask(std::size_t index) const;

private:
    std::vector<std::unique_ptr<Task>> tasks_;
    std::string filename_;

    void loadFromFile();
    void saveToFile() const;
};

#endif

Here is the complete src/task_manager.cpp:

#include "task_manager.h"
#include <iostream>
#include <fstream>

TaskManager::TaskManager(const std::string& filename)
    : filename_{filename} {
    loadFromFile();
}

TaskManager::~TaskManager() {
    saveToFile();
}

void TaskManager::addTask(const std::string& description, Priority priority) {
    tasks_.push_back(std::make_unique<Task>(description, priority));
}

void TaskManager::completeTask(std::size_t index) {
    if (index < tasks_.size()) {
        tasks_[index]->markComplete();
    }
}

void TaskManager::displayTasks() const {
    if (tasks_.empty()) {
        std::cout << "No tasks.\n";
        return;
    }

    for (std::size_t i = 0; i < tasks_.size(); ++i) {
        const auto& task = tasks_[i];
        std::cout << i + 1 << ". "
                  << (task->isCompleted() ? "[X] " : "[ ] ")
                  << task->getDescription()
                  << " (" << priorityToString(task->getPriority()) << ")\n";
    }
}

std::size_t TaskManager::getTaskCount() const {
    return tasks_.size();
}

const Task& TaskManager::getTask(std::size_t index) const {
    return *tasks_.at(index);
}

void TaskManager::loadFromFile() {
    std::ifstream file{filename_};
    if (!file) return;

    std::string line;
    while (std::getline(file, line)) {
        if (!line.empty()) {
            tasks_.push_back(std::make_unique<Task>(Task::deserialize(line)));
        }
    }
}

void TaskManager::saveToFile() const {
    std::ofstream file{filename_};
    for (const auto& task : tasks_) {
        file << task->serialize() << "\n";
    }
}
Wire up main.cpp with a CLI menu loop

Your TaskManager is ready. Now you need a way for the user to interact with it. You will replace the placeholder main.cpp with a menu loop that accepts commands until the user quits.

  • Open src/main.cpp in VS Code.
  • Select all existing content with Ctrl+A and delete it.
  • Replace the entire file with this code:
#include "task_manager.h"
#include <iostream>
#include <string>
#include <limits>

int main() {
    // TaskManager loads tasks from file automatically (RAII constructor)
    TaskManager manager{"tasks.txt"};

    bool running = true;
    while (running) {
        std::cout << "\n=== Task Manager ===\n";
        std::cout << "1. Add task\n";
        std::cout << "2. List tasks\n";
        std::cout << "3. Complete task\n";
        std::cout << "4. Quit\n";
        std::cout << "Choice: ";

        int choice = 0;
        std::cin >> choice;
        // Clear the input buffer after reading an integer
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');

        switch (choice) {
            case 1: {
                std::cout << "Description: ";
                std::string desc;
                std::getline(std::cin, desc);

                std::cout << "Priority (1=Low, 2=Medium, 3=High): ";
                int p = 2;
                std::cin >> p;
                std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');

                // Convert user input (1-3) to enum (0-2)
                Priority priority = static_cast<Priority>(p - 1);
                manager.addTask(desc, priority);
                std::cout << "Task added!\n";
                break;
            }
            case 2:
                manager.displayTasks();
                break;
            case 3: {
                manager.displayTasks();
                std::cout << "Task number to complete: ";
                std::size_t num = 0;
                std::cin >> num;
                std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
                // Convert display number (1-based) to index (0-based)
                manager.completeTask(num - 1);
                std::cout << "Task completed!\n";
                break;
            }
            case 4:
                running = false;
                break;
            default:
                std::cout << "Invalid choice.\n";
                break;
        }
    }

    // When manager goes out of scope here, destructor saves tasks to file
    std::cout << "Tasks saved. Goodbye!\n";
    return 0;
}

What does this code do?

  • TaskManager manager{"tasks.txt"} uses uniform initialization (curly braces). The constructor immediately loads any existing tasks from tasks.txt.
  • std::cin.ignore(...) clears leftover newline characters from the input buffer. Without this, std::getline would read an empty string.
  • static_cast<Priority>(p - 1) converts the user's choice (1, 2, or 3) to the enum values (Low=0, Medium=1, High=2).
  • When main() returns, manager is destroyed. The destructor calls saveToFile() automatically. The user never has to remember to save.
  • Save main.cpp.
  • Rebuild and run the application by running these commands in your UCRT64 terminal:
cmake --build build
./build/task_manager.exe

You should see the menu appear. Try adding a task (choice 1), listing tasks (choice 2), then quitting (choice 4).

  • Run the application a second time with the same command:
./build/task_manager.exe
  • Choose option 2 to list tasks. You should see the task you added in the previous session. This proves RAII persistence is working.

Tasks not persisting across runs?

  • Check that you quit with option 4, not by closing the terminal. Closing the terminal kills the process before the destructor can run.
  • Look for a tasks.txt file in your build folder. The file is created relative to where you run the executable.
  • Verify your serialize() and deserialize() methods in task.cpp use the same pipe-delimited format.

Help me debug why my tasks aren't saving between runs

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete include/task_manager.h:

#ifndef TASK_MANAGER_H
#define TASK_MANAGER_H

#include "task.h"
#include <vector>
#include <memory>
#include <string>

class TaskManager {
public:
    explicit TaskManager(const std::string& filename);
    ~TaskManager();

    void addTask(const std::string& description, Priority priority);
    void completeTask(std::size_t index);
    void displayTasks() const;
    std::size_t getTaskCount() const;
    const Task& getTask(std::size_t index) const;

private:
    std::vector<std::unique_ptr<Task>> tasks_;
    std::string filename_;

    void loadFromFile();
    void saveToFile() const;
};

#endif

Here is the complete src/task_manager.cpp:

#include "task_manager.h"
#include <iostream>
#include <fstream>

TaskManager::TaskManager(const std::string& filename)
    : filename_{filename} {
    loadFromFile();
}

TaskManager::~TaskManager() {
    saveToFile();
}

void TaskManager::addTask(const std::string& description, Priority priority) {
    tasks_.push_back(std::make_unique<Task>(description, priority));
}

void TaskManager::completeTask(std::size_t index) {
    if (index < tasks_.size()) {
        tasks_[index]->markComplete();
    }
}

void TaskManager::displayTasks() const {
    if (tasks_.empty()) {
        std::cout << "No tasks.\n";
        return;
    }

    for (std::size_t i = 0; i < tasks_.size(); ++i) {
        const auto& task = tasks_[i];
        std::cout << i + 1 << ". "
                  << (task->isCompleted() ? "[X] " : "[ ] ")
                  << task->getDescription()
                  << " (" << priorityToString(task->getPriority()) << ")\n";
    }
}

std::size_t TaskManager::getTaskCount() const {
    return tasks_.size();
}

const Task& TaskManager::getTask(std::size_t index) const {
    return *tasks_.at(index);
}

void TaskManager::loadFromFile() {
    std::ifstream file{filename_};
    if (!file) return;

    std::string line;
    while (std::getline(file, line)) {
        if (!line.empty()) {
            tasks_.push_back(std::make_unique<Task>(Task::deserialize(line)));
        }
    }
}

void TaskManager::saveToFile() const {
    std::ofstream file{filename_};
    for (const auto& task : tasks_) {
        file << task->serialize() << "\n";
    }
}

Here is the complete src/main.cpp:

#include "task_manager.h"
#include <iostream>
#include <string>
#include <limits>

int main() {
    TaskManager manager{"tasks.txt"};

    bool running = true;
    while (running) {
        std::cout << "\n=== Task Manager ===\n";
        std::cout << "1. Add task\n";
        std::cout << "2. List tasks\n";
        std::cout << "3. Complete task\n";
        std::cout << "4. Quit\n";
        std::cout << "Choice: ";

        int choice = 0;
        std::cin >> choice;
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');

        switch (choice) {
            case 1: {
                std::cout << "Description: ";
                std::string desc;
                std::getline(std::cin, desc);

                std::cout << "Priority (1=Low, 2=Medium, 3=High): ";
                int p = 2;
                std::cin >> p;
                std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');

                Priority priority = static_cast<Priority>(p - 1);
                manager.addTask(desc, priority);
                std::cout << "Task added!\n";
                break;
            }
            case 2:
                manager.displayTasks();
                break;
            case 3: {
                manager.displayTasks();
                std::cout << "Task number to complete: ";
                std::size_t num = 0;
                std::cin >> num;
                std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
                manager.completeTask(num - 1);
                std::cout << "Task completed!\n";
                break;
            }
            case 4:
                running = false;
                break;
            default:
                std::cout << "Invalid choice.\n";
                break;
        }
    }

    std::cout << "Tasks saved. Goodbye!\n";
    return 0;
}

Your Task Manager is fully functional. You can add tasks, complete them, list them, and they persist across sessions without you ever calling a save function. Next up, you will write unit tests to prove your code actually works correctly.

Write Unit Tests with GoogleTest

Your Task Manager runs, persists data, and handles user input. But how do you know future changes won't silently break something?

Automated tests act as a safety net. They verify that each piece of your code behaves correctly, and they run in seconds. In this step, you'll write unit tests using GoogleTest to lock in the behavior of your Task and TaskManager classes.

In this step, get ready to:
  • Write TEST cases that verify Task construction, completion, and serialization.
  • Write TEST cases that verify TaskManager operations and file persistence.
  • Run your full test suite with ctest and confirm all tests pass.
Create tests for Task construction, completion, and serialization

Think of unit tests as a contract. Each test says "given this input, I expect this output." If a future code change violates that contract, the test fails immediately and tells you exactly what broke.

How does GoogleTest compare to pytest?

If you've used Python's pytest, GoogleTest fills the same role. Instead of def test_something(): you write TEST(GroupName, TestName) { ... }. Instead of assert you use macros like EXPECT_EQ and EXPECT_TRUE.

The GTest::gtest_main library provides the main() function automatically. You only write test functions.

  • Create a new file called task_test.cpp inside your tests/ folder.
  • Add the includes and the first two Task tests by pasting this code:
#include <gtest/gtest.h>
#include "task.h"
#include "task_manager.h"
#include <cstdio>

// Test that a new Task stores its description, priority, and starts incomplete
TEST(TaskTest, ConstructionSetsValues) {
    Task task{"Buy groceries", Priority::High};
    EXPECT_EQ(task.getDescription(), "Buy groceries");
    EXPECT_EQ(task.getPriority(), Priority::High);
    EXPECT_FALSE(task.isCompleted());
}

// Test that markComplete() flips the completed flag
TEST(TaskTest, MarkComplete) {
    Task task{"Read book", Priority::Low};
    EXPECT_FALSE(task.isCompleted());
    task.markComplete();
    EXPECT_TRUE(task.isCompleted());
}

What does this code do?

Each TEST(GroupName, TestName) macro defines an independent test case. GoogleTest runs them all and reports which passed or failed.

  • EXPECT_EQ(a, b) checks that two values are equal.
  • EXPECT_TRUE(x) and EXPECT_FALSE(x) check boolean conditions.
  • The cstdio include gives us std::remove for cleaning up temp files in later tests.

You won't see a change yet. The next chunk adds the serialization tests, then you'll build.

  • Below the MarkComplete test, add these two serialization tests:
// Test that serialize() and deserialize() produce an identical Task
TEST(TaskTest, SerializeAndDeserialize) {
    Task original{"Write code", Priority::Medium};
    std::string serialized = original.serialize();
    Task restored = Task::deserialize(serialized);

    EXPECT_EQ(restored.getDescription(), "Write code");
    EXPECT_EQ(restored.getPriority(), Priority::Medium);
    EXPECT_FALSE(restored.isCompleted());
}

// Test that a completed task survives the serialize/deserialize round-trip
TEST(TaskTest, SerializeCompletedTask) {
    Task task{"Done task", Priority::High, true};
    std::string serialized = task.serialize();
    Task restored = Task::deserialize(serialized);

    EXPECT_TRUE(restored.isCompleted());
}

Why test serialization?

Your TaskManager saves tasks to a file and loads them back. If serialize() and deserialize() ever drift out of sync, tasks will be corrupted on disk. These tests catch that instantly.

  • Save tests/task_test.cpp.
  • Rebuild the project to verify compilation by running this command in your UCRT64 terminal:
cmake --build build

You should see the build complete with no errors. The task_tests target compiles alongside task_manager.

Seeing a compilation error?

  • Check that task_test.cpp is inside the tests/ folder, not in src/. The CMakeLists.txt expects tests/task_test.cpp.
  • Make sure you included #include <gtest/gtest.h> at the top. Without it, the TEST macro is undefined.
  • If CMake can't find GoogleTest, delete your build/ folder and re-run cmake -G "MinGW Makefiles" -S . -B build to re-fetch it.

help me fix this GoogleTest compilation error

Add tests for TaskManager operations

The Task tests verified individual objects. Now you need to test the TaskManager, which owns tasks and persists them to a file. There's a design question here: how do you test something that writes to disk without leaving junk files behind?

Why use scoped blocks and temp files?

Each TaskManager test creates its own temporary file (like test_tasks.txt) and wraps the TaskManager in a scoped block { }. When the block ends, the destructor runs and saves to the file. After the block, std::remove deletes the temp file.

This is RAII in action. You don't need a tearDown() method like in Python's unittest. The destructor handles cleanup automatically.

  • Below the SerializeCompletedTask test in tests/task_test.cpp, add these three TaskManager tests:
// Test that addTask increases the count
TEST(TaskManagerTest, AddAndCount) {
    const std::string test_file = "test_tasks.txt";
    {
        TaskManager manager{test_file};
        manager.addTask("Task 1", Priority::Low);
        manager.addTask("Task 2", Priority::High);
        EXPECT_EQ(manager.getTaskCount(), 2);
    }
    std::remove(test_file.c_str());
}

// Test that completeTask marks the correct task as done
TEST(TaskManagerTest, CompleteTask) {
    const std::string test_file = "test_complete.txt";
    {
        TaskManager manager{test_file};
        manager.addTask("Task to complete", Priority::Medium);
        manager.completeTask(0);
        EXPECT_TRUE(manager.getTask(0).isCompleted());
    }
    std::remove(test_file.c_str());
}

// Test that tasks survive destruction and reconstruction (file persistence)
TEST(TaskManagerTest, PersistenceAcrossSessions) {
    const std::string test_file = "test_persist.txt";
    {
        TaskManager manager{test_file};
        manager.addTask("Persistent task", Priority::High);
    }
    {
        TaskManager manager{test_file};
        EXPECT_EQ(manager.getTaskCount(), 1);
        EXPECT_EQ(manager.getTask(0).getDescription(), "Persistent task");
    }
    std::remove(test_file.c_str());
}

What does this code do?

  • AddAndCount creates a TaskManager, adds two tasks, and checks the count. The scoped block forces the destructor to save, then std::remove deletes the temp file.
  • CompleteTask verifies that completeTask(0) actually marks the first task as done.
  • PersistenceAcrossSessions is the most interesting. It creates a manager, adds a task, then destroys it. A second manager loads from the same file and checks the task survived. This proves your RAII persistence works.
  • Save tests/task_test.cpp.

You won't see a visible change yet. The next substep builds and runs the full test suite.

Run tests with ctest and verify all pass

You've written seven tests. Now let's compile them and see if your code actually passes.

  • Rebuild the project to compile the latest test file by running:
cmake --build build
  • Run your test suite by running:
ctest --test-dir build

You should see output showing all 7 tests passed, with each test name listed and a final summary line reporting 100% pass rate.

What is ctest?

ctest is CMake's built-in test runner. It discovers all tests registered via gtest_discover_tests() in your CMakeLists.txt and runs them in isolation. Each TEST() case runs independently, so one failure doesn't prevent others from running.

The --test-dir build flag tells ctest where to find the compiled test binary and test metadata.

Tests failing or not found?

  • If ctest says "No tests were found", re-run cmake -G "MinGW Makefiles" -S . -B build to regenerate the build system. GoogleTest discovery happens at configure time.
  • If a test fails, ctest prints the test name. Read the EXPECT_EQ output to see the expected vs actual values. Check that your src/task.cpp and src/task_manager.cpp match what you wrote in the previous steps.

help me debug my failing GoogleTest tests

Why automated tests matter for C++ projects

In Python, a typo might only surface at runtime when that code path executes. In C++, the compiler catches type errors, but logic bugs still hide. Unit tests catch logic bugs instantly, every time you rebuild.

Professional C++ projects run tests automatically on every commit. You now have the same infrastructure that production codebases use.

✔️ Awesome, I've got everything!

Great. Make sure you've saved tests/task_test.cpp and that all 7 tests pass when you run ctest --test-dir build.

ⓧ I'd like to double check the full code

Here is the complete tests/task_test.cpp file. Compare it to yours:

#include <gtest/gtest.h>
#include "task.h"
#include "task_manager.h"
#include <cstdio>

TEST(TaskTest, ConstructionSetsValues) {
    Task task{"Buy groceries", Priority::High};
    EXPECT_EQ(task.getDescription(), "Buy groceries");
    EXPECT_EQ(task.getPriority(), Priority::High);
    EXPECT_FALSE(task.isCompleted());
}

TEST(TaskTest, MarkComplete) {
    Task task{"Read book", Priority::Low};
    EXPECT_FALSE(task.isCompleted());
    task.markComplete();
    EXPECT_TRUE(task.isCompleted());
}

TEST(TaskTest, SerializeAndDeserialize) {
    Task original{"Write code", Priority::Medium};
    std::string serialized = original.serialize();
    Task restored = Task::deserialize(serialized);

    EXPECT_EQ(restored.getDescription(), "Write code");
    EXPECT_EQ(restored.getPriority(), Priority::Medium);
    EXPECT_FALSE(restored.isCompleted());
}

TEST(TaskTest, SerializeCompletedTask) {
    Task task{"Done task", Priority::High, true};
    std::string serialized = task.serialize();
    Task restored = Task::deserialize(serialized);

    EXPECT_TRUE(restored.isCompleted());
}

TEST(TaskManagerTest, AddAndCount) {
    const std::string test_file = "test_tasks.txt";
    {
        TaskManager manager{test_file};
        manager.addTask("Task 1", Priority::Low);
        manager.addTask("Task 2", Priority::High);
        EXPECT_EQ(manager.getTaskCount(), 2);
    }
    std::remove(test_file.c_str());
}

TEST(TaskManagerTest, CompleteTask) {
    const std::string test_file = "test_complete.txt";
    {
        TaskManager manager{test_file};
        manager.addTask("Task to complete", Priority::Medium);
        manager.completeTask(0);
        EXPECT_TRUE(manager.getTask(0).isCompleted());
    }
    std::remove(test_file.c_str());
}

TEST(TaskManagerTest, PersistenceAcrossSessions) {
    const std::string test_file = "test_persist.txt";
    {
        TaskManager manager{test_file};
        manager.addTask("Persistent task", Priority::High);
    }
    {
        TaskManager manager{test_file};
        EXPECT_EQ(manager.getTaskCount(), 1);
        EXPECT_EQ(manager.getTask(0).getDescription(), "Persistent task");
    }
    std::remove(test_file.c_str());
}

Your code is now protected by automated tests. Any future change that breaks Task behavior, serialization, or persistence will be caught the moment you run ctest. Next up, you'll tackle the Secret Mission and add STL algorithm-powered filtering and sorting.

Secret mission

Add Priority Filtering and Sorting with STL Algorithms

Your task manager works, but in a real project with dozens of tasks, you need ways to focus. Can you add priority-based filtering and sorting using STL algorithms and lambda expressions?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files on disk or remove them entirely.

Resources you used:

  • Project folder on your Desktop (task_manager directory with source code, build output, and test files).
  • MSYS2 installation at C:\msys64 (GCC, CMake, GDB toolchain).
  • VS Code C/C++ extension.

Keep everything running

No action needed. Choose this if you plan to keep building on this project or want to start another C++ project.

  • Your project folder stays on the Desktop, ready to extend or push to GitHub.
  • MSYS2 and the UCRT64 toolchain stay installed for future C++ work.
  • The VS Code C/C++ extension stays active for IntelliSense and debugging.

Pause - I'll come back to this later

Close running processes to free up memory. Your files stay on disk for when you return.

  • Close any open MSYS2 UCRT64 terminal windows.
  • Close VS Code if you do not need it for other work.
  • Your project files and MSYS2 installation remain on disk unchanged.

Delete - I don't want to use this again

Remove all project resources and tools installed during this project.

  • Delete the project folder. Open PowerShell and run:
  • Delete the project folder by running this in PowerShell:
Remove-Item -Recurse -Force ~\Desktop\task_manager
  • Uninstall MSYS2 by running this in PowerShell (closes all MSYS2 terminals first):
C:\msys64\uninstall.exe pr --confirm-command
  • Remove C:\msys64\ucrt64\bin from your Windows PATH environment variable. Open **Settings**, search for **Environment Variables**, select **Path** under User variables, find the C:\msys64\ucrt64\bin entry, and click **Delete**.
  • Uninstall the C/C++ extension in VS Code. Open the Extensions sidebar (Ctrl+Shift+X), find the C/C++ extension, and click **Uninstall**.

Nice Work!

Nice Work!

You just built a complete command-line Task Manager in modern C++17, from an empty directory all the way to a tested, file-persistent application with professional tooling.

You've learned how to:

  • Set up a professional C++ development environment on Windows with MSYS2, GCC, CMake, and VS Code, then built a project with proper directory structure.
  • Implemented Task and TaskManager classes using modern C++17 patterns: smart pointers (std::unique_ptr), enum class, RAII-based file persistence, and const-correctness.
  • Wrote and ran unit tests with GoogleTest to validate your code, understanding how automated testing catches bugs early.
  • Secret Mission: Added STL algorithm-based filtering and sorting with lambda expressions.

Ready to quiz yourself?