Build a Win32 File Watcher with TDD

Build a C++17 file watcher library using Win32 API, RAII, and Google Test.

Introduction

30 Second Summary

Every modern development tool you use relies on file watching. Your IDE detects when you save a file. Your build system recompiles changed sources. Your cloud sync client uploads new documents automatically. Behind the scenes, these tools hook into operating system APIs that notify them the instant something changes on disk.

In this project, you will build a production-quality C++ library that monitors directories for file changes using the Win32 API. You will write it test-first using TDD methodology, proving every feature works before shipping it.

What You'll Build

A reusable FileWatcher library written in C++17 that wraps the Win32 ReadDirectoryChangesW API, uses RAII to manage Windows handles, runs monitoring on a background thread, and reports changes via callbacks.

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

  • A Win32Handle RAII wrapper class demonstrating modern C++ move semantics and automatic resource cleanup.
  • A FileWatcher class that monitors directories for file creation, deletion, and modification on a background thread.
  • A comprehensive Google Test suite written before the implementation, following strict TDD discipline.
  • A professional CMake build system with presets that integrates dependencies via FetchContent.
  • Secret Mission: Add file extension filtering so the watcher only reports events for specific file types.

Are there any prerequisites?

You should be comfortable with C++11/14 features like smart pointers, lambdas, and templates. A basic understanding of unit testing concepts (assertions, test cases) is helpful but not required.

Set Up Your C++ Development Environment

To build a file watcher library that uses the Windows API, you need a C++ compiler that targets Win32 directly. Microsoft's MSVC toolchain is the standard choice for Windows native development.

In this step, you'll install the MSVC Build Tools (which bundle the compiler, Windows SDK, CMake, and Ninja), then configure VS Code for C++ development.

In this step, get ready to:
  • Install MSVC Build Tools with the C++ workload.
  • Install VS Code extensions for C++ and CMake.
  • Verify the toolchain works end to end.
Install MSVC Build Tools and CMake

The MSVC Build Tools provide the compiler, linker, standard library, and Windows SDK you need to build native C++ programs. The installer also bundles CMake and Ninja, which you'll use as your build system later in this project.

  • Press the Windows key and type Developer Command Prompt for VS.
  • If you find it, open it and run the following command to check your CMake version:
cmake --version

✔️ I see version 3.14 or higher

Your toolchain is ready. Skip ahead to the next substep (Install VS Code extensions).

ⓧ I see a version lower than 3.14

Your Build Tools installation is outdated. You need CMake 3.14 or higher for the FetchContent_MakeAvailable() command this project uses.

  • Open your browser and go to https://visualstudio.microsoft.com/visual-cpp-build-tools/.
  • Click Download Build Tools and run the installer.
  • In the Visual Studio Installer, select the Desktop development with C++ workload.
  • Click Update or Modify and wait for the installation to complete.
  • Close and reopen Developer Command Prompt for VS, then run cmake --version again to confirm the version is 3.14 or higher.

What does this workload include?

The Desktop development with C++ workload installs the MSVC compiler (cl.exe), the Windows SDK, CMake, and the Ninja build system. All four are needed for this project.

ⓧ Developer Command Prompt not found

This means MSVC Build Tools are not installed on your machine yet.

  • Open your browser and go to https://visualstudio.microsoft.com/visual-cpp-build-tools/.
  • Click Download Build Tools and run the downloaded installer.
  • If a User Account Control prompt appears, click Yes.
  • In the Visual Studio Installer, select the Desktop development with C++ workload.
  • Click Install and wait for the download and installation to finish. This may take 10-15 minutes depending on your internet speed.

What does this workload include?

The Desktop development with C++ workload installs the MSVC compiler (cl.exe), the Windows SDK, CMake, and the Ninja build system. All four are needed for this project.

  • After installation completes, press the Windows key and search for Developer Command Prompt for VS.
  • Open it and verify the compiler works by running:
cl

You should see output showing the Microsoft C/C++ compiler version number.

  • Verify CMake is available by running:
cmake --version

You should see a version number of 3.14 or higher.

Still can't find Developer Command Prompt?

Make sure you selected the Desktop development with C++ workload (not just the core Build Tools). Reopen the Visual Studio Installer and verify the checkbox is ticked.

If the installer fails, try restarting your machine and running the installer again as Administrator.

help me troubleshoot the MSVC Build Tools installation

Install VS Code extensions

VS Code needs two extensions to work with C++ and CMake projects. These provide IntelliSense (autocomplete and error checking) and build integration.

  • Switch back to VS Code.
  • Open the Extensions panel by pressing Ctrl+Shift+X.
  • Search for C/C++ and install the extension published by Microsoft (ID: ms-vscode.cpptools).
  • Search for CMake Tools and install the extension published by Microsoft (ID: ms-vscode.cmake-tools).

What do these extensions do?

The C/C++ extension provides IntelliSense, code navigation, and debugging support for C++ files.

The CMake Tools extension detects your compiler, configures builds, and lets you build and test directly from VS Code instead of typing commands manually.

Extension not showing up in search?

Make sure you are searching in the Extensions Marketplace panel (the icon with four squares on the left sidebar). If results still don't appear, check your internet connection.

help me find the C++ and CMake extensions in VS Code

Verify the toolchain

Now you'll confirm that VS Code's CMake Tools extension can detect your MSVC compiler. This requires launching VS Code from the Developer Command Prompt so it inherits the correct environment variables.

  • Switch back to the Developer Command Prompt for VS.
  • Navigate to your Desktop by running:
cd %USERPROFILE%\Desktop
  • Create your project folder and move into it by running:
mkdir file-watcher
cd file-watcher
  • Launch VS Code from this folder by running:
code .

Why launch VS Code from Developer Command Prompt?

The Developer Command Prompt sets up environment variables that point to the MSVC compiler, Windows SDK headers, and other build tools. When you launch VS Code from this prompt, VS Code inherits those variables.

This allows the CMake Tools extension to find cl.exe automatically without extra configuration.

  • If VS Code asks whether you trust the authors of the files in this folder, click Yes, I trust the authors.
  • Open the Command Palette by pressing Ctrl+Shift+P.
  • Type CMake: Scan for Kits and select it.
  • Open the Command Palette again and type CMake: Select a Kit.
  • Select the kit that shows Visual Studio Build Tools with an amd64 architecture label.

You should see the selected kit name appear in the VS Code status bar at the bottom of the window.

No kits found?

Make sure you launched VS Code from the Developer Command Prompt (not from the Start menu or desktop shortcut). Close VS Code, return to the Developer Command Prompt, and run code . again.

If you still don't see a kit after scanning, verify that the C/C++ and CMake Tools extensions are installed and enabled in the Extensions panel.

help me get CMake Tools to detect my MSVC compiler in VS Code

Your C++ development environment is locked in. Next, you'll create the project structure with CMake and pull in Google Test for your test suite.

Create the Project Structure with CMake and Google Test

A well-organized project structure separates public headers from implementation and keeps tests isolated from production code.

In this step, you'll create the directory layout, write a CMake build configuration that pulls in Google Test automatically, and verify everything compiles.

In this step, get ready to:
  • Create the directory structure with separate folders for headers, source, and tests.
  • Write a CMakeLists.txt that defines the library and test targets with Google Test.
  • Verify the build system works by configuring and compiling the project.
Create the project directories and placeholder files

Your library needs three top-level folders: one for public headers that consumers include, one for private source files, and one for tests.

  • Switch back to the Developer Command Prompt you used in Step 1.
  • Make sure you are inside your project folder (the one you opened VS Code from).
  • Create the directory structure by running these commands:
mkdir include\file_watcher
mkdir src
mkdir tests
  • Create the empty placeholder source files by running:
type nul > src\file_watcher.cpp
type nul > tests\win32_handle_test.cpp
type nul > tests\file_watcher_test.cpp

Why empty files?

CMake needs every file listed in a target to exist at configure time. You'll fill these with real code in Steps 3 and 4, but creating them now lets the build system run without errors.

  • Confirm the files exist by running:
dir src
dir tests
  • You should see file_watcher.cpp in the src folder, and win32_handle_test.cpp plus file_watcher_test.cpp in the tests folder.
  • In VS Code, check the Explorer sidebar on the left. You should see the include/file_watcher, src, and tests folders listed.

Don't see the folders in VS Code?

Click the refresh icon at the top of the Explorer panel. If you still don't see them, confirm your terminal was inside the same folder VS Code has open.

Help me find my project folders in VS Code

Create the CMakeLists.txt build file

The main build file tells CMake how to compile your library, where to find Google Test, and how to link everything together.

  • In VS Code, right-click in the Explorer sidebar and select New File.
  • Name the file CMakeLists.txt and press Enter.
  • Paste the following content into CMakeLists.txt:
cmake_minimum_required(VERSION 3.14)
project(FileWatcher VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(file_watcher
    src/file_watcher.cpp
)
target_include_directories(file_watcher PUBLIC include)

include(FetchContent)
FetchContent_Declare(
    googletest
    URL https://github.com/google/googletest/archive/refs/tags/v1.17.0.zip
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

enable_testing()

add_executable(file_watcher_tests
    tests/win32_handle_test.cpp
    tests/file_watcher_test.cpp
)
target_link_libraries(file_watcher_tests PRIVATE file_watcher GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(file_watcher_tests)

What does this build file do?

  • cmake_minimum_required(VERSION 3.14) sets the minimum CMake version needed for FetchContent support.
  • add_library(file_watcher ...) defines your library target from the source files in src/.
  • The FetchContent block downloads Google Test v1.17.0 at configure time so you never need to install it manually.
  • gtest_force_shared_crt prevents Windows runtime library conflicts between your project and Google Test.
  • GTest::gtest_main links both the test framework and a default main() so your test files don't need one.
  • gtest_discover_tests integrates with CTest so you can run all tests from the command line.
  • Save CMakeLists.txt with Ctrl+S.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

cmake_minimum_required(VERSION 3.14)
project(FileWatcher VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(file_watcher
    src/file_watcher.cpp
)
target_include_directories(file_watcher PUBLIC include)

include(FetchContent)
FetchContent_Declare(
    googletest
    URL https://github.com/google/googletest/archive/refs/tags/v1.17.0.zip
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

enable_testing()

add_executable(file_watcher_tests
    tests/win32_handle_test.cpp
    tests/file_watcher_test.cpp
)
target_link_libraries(file_watcher_tests PRIVATE file_watcher GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(file_watcher_tests)
Create the CMake presets file

CMake presets let you define reusable build configurations so you don't need to type long command-line flags every time.

  • In VS Code, right-click in the Explorer sidebar and select New File.
  • Name the file CMakePresets.json and press Enter.
  • Paste the following content into CMakePresets.json:
{
    "version": 3,
    "configurePresets": [
        {
            "name": "default",
            "displayName": "Default",
            "generator": "Ninja",
            "binaryDir": "${sourceDir}/build",
            "cacheVariables": {
                "CMAKE_BUILD_TYPE": "Debug"
            }
        }
    ],
    "buildPresets": [
        {
            "name": "default",
            "configurePreset": "default"
        }
    ],
    "testPresets": [
        {
            "name": "default",
            "configurePreset": "default",
            "output": {
                "outputOnFailure": true
            }
        }
    ]
}

What do these presets control?

  • The configurePreset uses the Ninja build generator (fast and included with MSVC Build Tools) and puts all build output in a build/ folder.
  • CMAKE_BUILD_TYPE: Debug enables debug symbols so you can step through code in the VS Code debugger.
  • The testPreset sets outputOnFailure to true so you see detailed output only when a test fails.
  • Save CMakePresets.json with Ctrl+S.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

{
    "version": 3,
    "configurePresets": [
        {
            "name": "default",
            "displayName": "Default",
            "generator": "Ninja",
            "binaryDir": "${sourceDir}/build",
            "cacheVariables": {
                "CMAKE_BUILD_TYPE": "Debug"
            }
        }
    ],
    "buildPresets": [
        {
            "name": "default",
            "configurePreset": "default"
        }
    ],
    "testPresets": [
        {
            "name": "default",
            "configurePreset": "default",
            "output": {
                "outputOnFailure": true
            }
        }
    ]
}
Configure and build the project

With the files in place, you can now run CMake to generate the build system and compile everything. This also triggers the Google Test download.

  • In your Developer Command Prompt, configure the project by running:
cmake --preset default

What happens during configure?

CMake reads your CMakeLists.txt, downloads Google Test v1.17.0 from GitHub, and generates Ninja build files inside the build/ folder. The first run takes a minute because it fetches the dependency.

  • Once the configure step finishes, build the project by running:
cmake --build build
  • You should see output ending with lines about linking file_watcher_tests.exe and file_watcher.lib. No errors should appear.

Seeing errors during configure or build?

  • If CMake says it cannot find Ninja, make sure you are running from the Developer Command Prompt for VS (not a plain cmd window). The Developer Prompt adds Ninja to your PATH.
  • If you see a file-not-found error for one of the .cpp files, double-check that the empty files exist in the correct folders. Run dir src and dir tests to verify.
  • If the Google Test download fails, check your internet connection and try running the configure command again.

Help me debug my CMake build errors

Your build system is ready and Google Test is integrated. Next up, you'll write failing tests for both classes before implementing any logic.

TDD Red Phase - Write Failing Tests First

Your build system is ready, but you have no code yet. In Test-Driven Development, you write the tests before the implementation. This feels backwards, but it forces you to think about your class interfaces before writing a single line of logic.

In this step, you will write a full test suite for two classes that do not exist yet. The goal is to reach "TDD Red" where every test fails because the implementation is missing. This confirms your tests are actually testing something real.

In this step, get ready to:
  • Write tests for a Win32Handle RAII wrapper class.
  • Write tests for a FileWatcher class using a test fixture.
  • Create minimal stubs and confirm the TDD Red phase.
Write tests for the Win32Handle RAII wrapper

The Win32Handle class will wrap a raw Windows HANDLE and manage its lifetime automatically using RAII. Before implementing it, you will define what correct behavior looks like through tests.

  • Create a new file called win32_handle_test.cpp inside your tests/ folder and paste the following test code:
#include <gtest/gtest.h>
#include "file_watcher/win32_handle.h"

using namespace file_watcher;

TEST(Win32HandleTest, DefaultConstructorCreatesInvalidHandle) {
    Win32Handle handle;
    EXPECT_FALSE(handle.is_valid());
    EXPECT_EQ(handle.get(), INVALID_HANDLE_VALUE);
}

TEST(Win32HandleTest, ConstructWithValidHandle) {
    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    ASSERT_NE(raw, nullptr);

    Win32Handle handle(raw);
    EXPECT_TRUE(handle.is_valid());
    EXPECT_EQ(handle.get(), raw);
}

TEST(Win32HandleTest, MoveConstructorTransfersOwnership) {
    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle original(raw);

    Win32Handle moved(std::move(original));

    EXPECT_TRUE(moved.is_valid());
    EXPECT_EQ(moved.get(), raw);
    EXPECT_FALSE(original.is_valid());
}

TEST(Win32HandleTest, MoveAssignmentTransfersOwnership) {
    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle original(raw);

    Win32Handle target;
    target = std::move(original);

    EXPECT_TRUE(target.is_valid());
    EXPECT_EQ(target.get(), raw);
    EXPECT_FALSE(original.is_valid());
}

TEST(Win32HandleTest, ReleaseReturnsHandleAndInvalidates) {
    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle handle(raw);

    HANDLE released = handle.release();

    EXPECT_EQ(released, raw);
    EXPECT_FALSE(handle.is_valid());
    CloseHandle(released);
}

TEST(Win32HandleTest, ResetClosesOldAndAcceptsNew) {
    HANDLE first = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    HANDLE second = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle handle(first);

    handle.reset(second);

    EXPECT_TRUE(handle.is_valid());
    EXPECT_EQ(handle.get(), second);
}

TEST(Win32HandleTest, BoolOperatorReflectsValidity) {
    Win32Handle invalid;
    EXPECT_FALSE(static_cast<bool>(invalid));

    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle valid(raw);
    EXPECT_TRUE(static_cast<bool>(valid));
}

What do these tests cover?

Each test targets one behavior of the RAII wrapper:

  • DefaultConstructorCreatesInvalidHandle ensures a default-constructed handle starts invalid, so you never access an uninitialized resource.
  • MoveConstructorTransfersOwnership and MoveAssignmentTransfersOwnership verify that moving a handle leaves the source empty. This prevents double-close bugs.
  • Release and Reset tests confirm the wrapper can relinquish or swap ownership safely.
  • The tests use CreateEvent to create real Win32 handles. This tests against actual OS resources, not mocks.
  • Save tests/win32_handle_test.cpp.
  • You won't see a change yet. The project cannot compile until you create the header stubs in substep 3.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

#include <gtest/gtest.h>
#include "file_watcher/win32_handle.h"

using namespace file_watcher;

TEST(Win32HandleTest, DefaultConstructorCreatesInvalidHandle) {
    Win32Handle handle;
    EXPECT_FALSE(handle.is_valid());
    EXPECT_EQ(handle.get(), INVALID_HANDLE_VALUE);
}

TEST(Win32HandleTest, ConstructWithValidHandle) {
    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    ASSERT_NE(raw, nullptr);

    Win32Handle handle(raw);
    EXPECT_TRUE(handle.is_valid());
    EXPECT_EQ(handle.get(), raw);
}

TEST(Win32HandleTest, MoveConstructorTransfersOwnership) {
    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle original(raw);

    Win32Handle moved(std::move(original));

    EXPECT_TRUE(moved.is_valid());
    EXPECT_EQ(moved.get(), raw);
    EXPECT_FALSE(original.is_valid());
}

TEST(Win32HandleTest, MoveAssignmentTransfersOwnership) {
    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle original(raw);

    Win32Handle target;
    target = std::move(original);

    EXPECT_TRUE(target.is_valid());
    EXPECT_EQ(target.get(), raw);
    EXPECT_FALSE(original.is_valid());
}

TEST(Win32HandleTest, ReleaseReturnsHandleAndInvalidates) {
    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle handle(raw);

    HANDLE released = handle.release();

    EXPECT_EQ(released, raw);
    EXPECT_FALSE(handle.is_valid());
    CloseHandle(released);
}

TEST(Win32HandleTest, ResetClosesOldAndAcceptsNew) {
    HANDLE first = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    HANDLE second = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle handle(first);

    handle.reset(second);

    EXPECT_TRUE(handle.is_valid());
    EXPECT_EQ(handle.get(), second);
}

TEST(Win32HandleTest, BoolOperatorReflectsValidity) {
    Win32Handle invalid;
    EXPECT_FALSE(static_cast<bool>(invalid));

    HANDLE raw = CreateEvent(nullptr, FALSE, FALSE, nullptr);
    Win32Handle valid(raw);
    EXPECT_TRUE(static_cast<bool>(valid));
}
Write tests for the FileWatcher class

The FileWatcher class will monitor a directory for changes and report them via callbacks. Because file system events are asynchronous, these tests use a test fixture with a condition variable to wait for events without blocking forever.

  • Create a new file called file_watcher_test.cpp inside your tests/ folder and paste the following test code:
#include <gtest/gtest.h>
#include "file_watcher/file_watcher.h"

#include <fstream>
#include <chrono>
#include <mutex>
#include <condition_variable>

using namespace file_watcher;
namespace fs = std::filesystem;

class FileWatcherTest : public ::testing::Test {
protected:
    void SetUp() override {
        test_dir_ = fs::temp_directory_path() / "file_watcher_test";
        fs::create_directories(test_dir_);
    }

    void TearDown() override {
        watcher_.reset();
        fs::remove_all(test_dir_);
    }

    bool wait_for_events(int count, std::chrono::milliseconds timeout = std::chrono::milliseconds(3000)) {
        std::unique_lock<std::mutex> lock(mutex_);
        return cv_.wait_for(lock, timeout, [&] {
            return static_cast<int>(events_.size()) >= count;
        });
    }

    fs::path test_dir_;
    std::unique_ptr<FileWatcher> watcher_;
    std::vector<FileEvent> events_;
    std::mutex mutex_;
    std::condition_variable cv_;
};

TEST_F(FileWatcherTest, ThrowsOnNonExistentDirectory) {
    EXPECT_THROW(
        FileWatcher(fs::path("C:\\nonexistent_dir_xyz_123")),
        std::invalid_argument
    );
}

TEST_F(FileWatcherTest, ThrowsOnFilePath) {
    auto file_path = test_dir_ / "not_a_dir.txt";
    std::ofstream(file_path) << "test";

    EXPECT_THROW(FileWatcher(file_path), std::invalid_argument);
}

TEST_F(FileWatcherTest, StartsAndStopsWatching) {
    watcher_ = std::make_unique<FileWatcher>(test_dir_);

    bool started = watcher_->start([](const FileEvent&) {});
    EXPECT_TRUE(started);
    EXPECT_TRUE(watcher_->is_watching());

    watcher_->stop();
    EXPECT_FALSE(watcher_->is_watching());
}

TEST_F(FileWatcherTest, DetectsFileCreation) {
    watcher_ = std::make_unique<FileWatcher>(test_dir_);
    watcher_->start([this](const FileEvent& event) {
        std::lock_guard<std::mutex> lock(mutex_);
        events_.push_back(event);
        cv_.notify_all();
    });

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::ofstream(test_dir_ / "new_file.txt") << "hello";

    ASSERT_TRUE(wait_for_events(1));

    std::lock_guard<std::mutex> lock(mutex_);
    bool found_added = false;
    for (const auto& event : events_) {
        if (event.action == FileAction::Added) {
            found_added = true;
            EXPECT_EQ(event.path.filename(), "new_file.txt");
        }
    }
    EXPECT_TRUE(found_added);
}

TEST_F(FileWatcherTest, DetectsFileDeletion) {
    auto file_path = test_dir_ / "to_delete.txt";
    std::ofstream(file_path) << "delete me";

    watcher_ = std::make_unique<FileWatcher>(test_dir_);
    watcher_->start([this](const FileEvent& event) {
        std::lock_guard<std::mutex> lock(mutex_);
        events_.push_back(event);
        cv_.notify_all();
    });

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    fs::remove(file_path);

    ASSERT_TRUE(wait_for_events(1));

    std::lock_guard<std::mutex> lock(mutex_);
    bool found_removed = false;
    for (const auto& event : events_) {
        if (event.action == FileAction::Removed) {
            found_removed = true;
            EXPECT_EQ(event.path.filename(), "to_delete.txt");
        }
    }
    EXPECT_TRUE(found_removed);
}

TEST_F(FileWatcherTest, DetectsFileModification) {
    auto file_path = test_dir_ / "modify_me.txt";
    std::ofstream(file_path) << "original";

    watcher_ = std::make_unique<FileWatcher>(test_dir_);
    watcher_->start([this](const FileEvent& event) {
        std::lock_guard<std::mutex> lock(mutex_);
        events_.push_back(event);
        cv_.notify_all();
    });

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::ofstream(file_path) << "modified content";

    ASSERT_TRUE(wait_for_events(1));

    std::lock_guard<std::mutex> lock(mutex_);
    bool found_modified = false;
    for (const auto& event : events_) {
        if (event.action == FileAction::Modified) {
            found_modified = true;
        }
    }
    EXPECT_TRUE(found_modified);
}

TEST_F(FileWatcherTest, CannotStartTwice) {
    watcher_ = std::make_unique<FileWatcher>(test_dir_);
    EXPECT_TRUE(watcher_->start([](const FileEvent&) {}));
    EXPECT_FALSE(watcher_->start([](const FileEvent&) {}));
}

What does this test file do?

The FileWatcherTest fixture creates a fresh temporary directory before each test and removes it afterwards. This keeps tests isolated from each other.

  • wait_for_events() uses a condition variable with a 3-second timeout. This lets the test wait efficiently for async file events without hanging forever if something breaks.
  • The sleep_for(100ms) before creating test files gives the watcher time to register with the OS before events start firing.
  • Each detection test creates a real file on disk, then checks that the watcher reports the correct FileAction and filename.
  • Save tests/file_watcher_test.cpp.
  • You won't see a change yet. The next substep creates the header stubs these tests include.

✔️ Awesome, I've got everything!

Great. Double check you saved both test files before continuing.

ⓧ I'd like to double check the full code

#include <gtest/gtest.h>
#include "file_watcher/file_watcher.h"

#include <fstream>
#include <chrono>
#include <mutex>
#include <condition_variable>

using namespace file_watcher;
namespace fs = std::filesystem;

class FileWatcherTest : public ::testing::Test {
protected:
    void SetUp() override {
        test_dir_ = fs::temp_directory_path() / "file_watcher_test";
        fs::create_directories(test_dir_);
    }

    void TearDown() override {
        watcher_.reset();
        fs::remove_all(test_dir_);
    }

    bool wait_for_events(int count, std::chrono::milliseconds timeout = std::chrono::milliseconds(3000)) {
        std::unique_lock<std::mutex> lock(mutex_);
        return cv_.wait_for(lock, timeout, [&] {
            return static_cast<int>(events_.size()) >= count;
        });
    }

    fs::path test_dir_;
    std::unique_ptr<FileWatcher> watcher_;
    std::vector<FileEvent> events_;
    std::mutex mutex_;
    std::condition_variable cv_;
};

TEST_F(FileWatcherTest, ThrowsOnNonExistentDirectory) {
    EXPECT_THROW(
        FileWatcher(fs::path("C:\\nonexistent_dir_xyz_123")),
        std::invalid_argument
    );
}

TEST_F(FileWatcherTest, ThrowsOnFilePath) {
    auto file_path = test_dir_ / "not_a_dir.txt";
    std::ofstream(file_path) << "test";

    EXPECT_THROW(FileWatcher(file_path), std::invalid_argument);
}

TEST_F(FileWatcherTest, StartsAndStopsWatching) {
    watcher_ = std::make_unique<FileWatcher>(test_dir_);

    bool started = watcher_->start([](const FileEvent&) {});
    EXPECT_TRUE(started);
    EXPECT_TRUE(watcher_->is_watching());

    watcher_->stop();
    EXPECT_FALSE(watcher_->is_watching());
}

TEST_F(FileWatcherTest, DetectsFileCreation) {
    watcher_ = std::make_unique<FileWatcher>(test_dir_);
    watcher_->start([this](const FileEvent& event) {
        std::lock_guard<std::mutex> lock(mutex_);
        events_.push_back(event);
        cv_.notify_all();
    });

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::ofstream(test_dir_ / "new_file.txt") << "hello";

    ASSERT_TRUE(wait_for_events(1));

    std::lock_guard<std::mutex> lock(mutex_);
    bool found_added = false;
    for (const auto& event : events_) {
        if (event.action == FileAction::Added) {
            found_added = true;
            EXPECT_EQ(event.path.filename(), "new_file.txt");
        }
    }
    EXPECT_TRUE(found_added);
}

TEST_F(FileWatcherTest, DetectsFileDeletion) {
    auto file_path = test_dir_ / "to_delete.txt";
    std::ofstream(file_path) << "delete me";

    watcher_ = std::make_unique<FileWatcher>(test_dir_);
    watcher_->start([this](const FileEvent& event) {
        std::lock_guard<std::mutex> lock(mutex_);
        events_.push_back(event);
        cv_.notify_all();
    });

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    fs::remove(file_path);

    ASSERT_TRUE(wait_for_events(1));

    std::lock_guard<std::mutex> lock(mutex_);
    bool found_removed = false;
    for (const auto& event : events_) {
        if (event.action == FileAction::Removed) {
            found_removed = true;
            EXPECT_EQ(event.path.filename(), "to_delete.txt");
        }
    }
    EXPECT_TRUE(found_removed);
}

TEST_F(FileWatcherTest, DetectsFileModification) {
    auto file_path = test_dir_ / "modify_me.txt";
    std::ofstream(file_path) << "original";

    watcher_ = std::make_unique<FileWatcher>(test_dir_);
    watcher_->start([this](const FileEvent& event) {
        std::lock_guard<std::mutex> lock(mutex_);
        events_.push_back(event);
        cv_.notify_all();
    });

    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::ofstream(file_path) << "modified content";

    ASSERT_TRUE(wait_for_events(1));

    std::lock_guard<std::mutex> lock(mutex_);
    bool found_modified = false;
    for (const auto& event : events_) {
        if (event.action == FileAction::Modified) {
            found_modified = true;
        }
    }
    EXPECT_TRUE(found_modified);
}

TEST_F(FileWatcherTest, CannotStartTwice) {
    watcher_ = std::make_unique<FileWatcher>(test_dir_);
    EXPECT_TRUE(watcher_->start([](const FileEvent&) {}));
    EXPECT_FALSE(watcher_->start([](const FileEvent&) {}));
}

Why write tests before the code exists?

Writing tests first forces you to design the public interface (method names, parameters, return types) before getting tangled in implementation details. The tests become a specification of what correct behavior looks like.

When you run these tests and see them fail, you know they are actually exercising your code. A test that passes before you write the implementation is a test that proves nothing.

Create minimal header stubs and confirm TDD Red

Your tests reference headers that do not exist yet. You need to create minimal stub files so CMake can locate them. The stubs contain no real implementation, so the build will produce errors when it tries to use the types your tests expect.

  • Create a new file called win32_handle.h inside your include/file_watcher/ folder and paste this stub:
// Stub header for Win32Handle
  • Create a new file called file_watcher.h inside your include/file_watcher/ folder and paste this stub:
// Stub header for FileWatcher
  • Create a new file called file_watcher.cpp inside your src/ folder and paste this stub:
// Empty stub

Why are these stubs so minimal?

These stubs exist so CMake can find the files it expects. They deliberately contain no class definitions or function declarations. When the compiler tries to resolve the types your tests use (like Win32Handle or FileWatcher), it will fail with errors.

Those errors ARE the TDD Red phase. They confirm your tests are demanding something that does not exist yet.

  • Save all three stub files.
  • Attempt to build the project by running this command in your Developer Command Prompt:
cmake --build build

You should see compilation errors. The compiler cannot find the Win32Handle, FileWatcher, FileEvent, or FileAction types because the headers are empty stubs. This is TDD Red.

Not seeing compilation errors?

  • If you see a CMake configuration error instead, make sure you ran cmake --preset default successfully in the previous step. The build/ directory must exist.
  • If you see "file not found" for a header, double check the folder structure matches include/file_watcher/win32_handle.h and include/file_watcher/file_watcher.h exactly.
  • If the build succeeds with no errors, your test files may be empty. Confirm tests/win32_handle_test.cpp and tests/file_watcher_test.cpp contain the test code you pasted earlier.

Help me debug my CMake build errors.

Your test suite is written and confirmed failing. Every test demands behavior that does not exist yet. Next up, you will implement the Win32Handle and FileWatcher classes to turn that wall of errors into a clean green test run.

TDD Green Phase - Implement to Pass Tests

Your tests are written and failing. That's exactly where you want to be in the TDD workflow.

Now comes the Green phase: write the minimum implementation needed to make every test pass. You'll implement the Win32Handle RAII wrapper first, then the FileWatcher class that uses it to monitor directories via the Win32 API.

In this step, get ready to:
  • Implement the Win32Handle RAII wrapper with move semantics.
  • Implement the FileWatcher class with overlapped I/O and background threading.
  • Run the full test suite and verify all tests pass (TDD Green).
Implement the Win32Handle RAII wrapper

Windows kernel objects (events, files, threads) are accessed through raw HANDLE values that must be closed with CloseHandle. Forgetting to close one leaks kernel resources.

What is RAII?

RAII (Resource Acquisition Is Initialization) ties a resource's lifetime to an object's lifetime. When the object goes out of scope, its destructor automatically releases the resource. This eliminates manual cleanup and makes your code exception-safe.

  • In VS Code, open include/file_watcher/win32_handle.h and replace the stub comment with the full implementation:
#pragma once

#include <windows.h>
#include <utility>

namespace file_watcher {

class Win32Handle {
public:
    // Default constructor: starts with an invalid handle
    Win32Handle() noexcept : handle_(INVALID_HANDLE_VALUE) {}

    // Take ownership of an existing raw HANDLE
    explicit Win32Handle(HANDLE handle) noexcept : handle_(handle) {}

    // Destructor: automatically close the handle
    ~Win32Handle() {
        close();
    }

    // Prevent copying (two owners = double-close)
    Win32Handle(const Win32Handle&) = delete;
    Win32Handle& operator=(const Win32Handle&) = delete;

    // Move constructor: transfer ownership from other
    Win32Handle(Win32Handle&& other) noexcept : handle_(other.handle_) {
        other.handle_ = INVALID_HANDLE_VALUE;
    }

    // Move assignment: close our handle, take other's
    Win32Handle& operator=(Win32Handle&& other) noexcept {
        if (this != &other) {
            close();
            handle_ = other.handle_;
            other.handle_ = INVALID_HANDLE_VALUE;
        }
        return *this;
    }

    // Check if the handle refers to a valid kernel object
    bool is_valid() const noexcept {
        return handle_ != INVALID_HANDLE_VALUE && handle_ != nullptr;
    }

    // Get the raw HANDLE for Win32 API calls
    HANDLE get() const noexcept {
        return handle_;
    }

    // Release ownership without closing
    HANDLE release() noexcept {
        HANDLE h = handle_;
        handle_ = INVALID_HANDLE_VALUE;
        return h;
    }

    // Close current handle and optionally accept a new one
    void reset(HANDLE handle = INVALID_HANDLE_VALUE) noexcept {
        close();
        handle_ = handle;
    }

    // Allow using Win32Handle in boolean contexts
    explicit operator bool() const noexcept {
        return is_valid();
    }

private:
    // Internal helper to safely close the handle
    void close() noexcept {
        if (is_valid()) {
            CloseHandle(handle_);
            handle_ = INVALID_HANDLE_VALUE;
        }
    }

    HANDLE handle_;
};

} // namespace file_watcher

What does this code do?

  • The destructor calls CloseHandle automatically when the object goes out of scope. No manual cleanup needed.
  • Copy operations are deleted because two objects owning the same handle would double-close it.
  • Move operations transfer the raw handle to the new owner and set the old owner to INVALID_HANDLE_VALUE. This ensures exactly one owner at all times.
  • release() gives up ownership without closing, while reset() closes the current handle before accepting a new one.
  • Save include/file_watcher/win32_handle.h.
  • You won't be able to build yet because file_watcher.h is still a stub. The next substep implements it.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

#pragma once

#include <windows.h>
#include <utility>

namespace file_watcher {

class Win32Handle {
public:
    Win32Handle() noexcept : handle_(INVALID_HANDLE_VALUE) {}

    explicit Win32Handle(HANDLE handle) noexcept : handle_(handle) {}

    ~Win32Handle() {
        close();
    }

    Win32Handle(const Win32Handle&) = delete;
    Win32Handle& operator=(const Win32Handle&) = delete;

    Win32Handle(Win32Handle&& other) noexcept : handle_(other.handle_) {
        other.handle_ = INVALID_HANDLE_VALUE;
    }

    Win32Handle& operator=(Win32Handle&& other) noexcept {
        if (this != &other) {
            close();
            handle_ = other.handle_;
            other.handle_ = INVALID_HANDLE_VALUE;
        }
        return *this;
    }

    bool is_valid() const noexcept {
        return handle_ != INVALID_HANDLE_VALUE && handle_ != nullptr;
    }

    HANDLE get() const noexcept {
        return handle_;
    }

    HANDLE release() noexcept {
        HANDLE h = handle_;
        handle_ = INVALID_HANDLE_VALUE;
        return h;
    }

    void reset(HANDLE handle = INVALID_HANDLE_VALUE) noexcept {
        close();
        handle_ = handle;
    }

    explicit operator bool() const noexcept {
        return is_valid();
    }

private:
    void close() noexcept {
        if (is_valid()) {
            CloseHandle(handle_);
            handle_ = INVALID_HANDLE_VALUE;
        }
    }

    HANDLE handle_;
};

} // namespace file_watcher
Implement the FileWatcher class

The FileWatcher class is the heart of the library. It opens a directory handle, spawns a background thread, and uses ReadDirectoryChangesW with overlapped I/O to detect file system events without blocking the main thread.

  • Open include/file_watcher/file_watcher.h and replace the stub comment with the class declaration:
#pragma once

#include <windows.h>
#include <filesystem>
#include <functional>
#include <string>
#include <thread>
#include <atomic>
#include <vector>

#include "win32_handle.h"

namespace file_watcher {

// The type of change detected on a file
enum class FileAction {
    Added,
    Removed,
    Modified,
    RenamedOld,
    RenamedNew
};

// Bundles a file action with the affected path
struct FileEvent {
    FileAction action;
    std::filesystem::path path;
};

// Callback type that consumers register to receive events
using EventCallback = std::function<void(const FileEvent&)>;

class FileWatcher {
public:
    explicit FileWatcher(const std::filesystem::path& directory);
    ~FileWatcher();

    // Non-copyable (owns a thread and a handle)
    FileWatcher(const FileWatcher&) = delete;
    FileWatcher& operator=(const FileWatcher&) = delete;

    // Start monitoring the directory, invoking callback on changes
    bool start(EventCallback callback);
    void stop();
    bool is_watching() const noexcept;

private:
    // Background thread entry point
    void watch_loop();
    // Convert Win32 action constants to our enum
    static FileAction translate_action(DWORD action);

    std::filesystem::path directory_;
    Win32Handle dir_handle_;
    EventCallback callback_;
    std::thread watch_thread_;
    std::atomic<bool> running_{false};
    static constexpr DWORD buffer_size_ = 64 * 1024;
};

} // namespace file_watcher

What does this header declare?

  • FileAction is a scoped enum representing the five types of file system changes Windows reports.
  • FileEvent pairs an action with the file path that changed, so the callback gets all the context it needs.
  • The class uses std::atomic<bool> for running_ so the main thread and the watch thread can communicate safely without a mutex.
  • Save include/file_watcher/file_watcher.h.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before continuing.

ⓧ I'd like to double check the full code

#pragma once

#include <windows.h>
#include <filesystem>
#include <functional>
#include <string>
#include <thread>
#include <atomic>
#include <vector>

#include "win32_handle.h"

namespace file_watcher {

enum class FileAction {
    Added,
    Removed,
    Modified,
    RenamedOld,
    RenamedNew
};

struct FileEvent {
    FileAction action;
    std::filesystem::path path;
};

using EventCallback = std::function<void(const FileEvent&)>;

class FileWatcher {
public:
    explicit FileWatcher(const std::filesystem::path& directory);
    ~FileWatcher();

    FileWatcher(const FileWatcher&) = delete;
    FileWatcher& operator=(const FileWatcher&) = delete;

    bool start(EventCallback callback);
    void stop();
    bool is_watching() const noexcept;

private:
    void watch_loop();
    static FileAction translate_action(DWORD action);

    std::filesystem::path directory_;
    Win32Handle dir_handle_;
    EventCallback callback_;
    std::thread watch_thread_;
    std::atomic<bool> running_{false};
    static constexpr DWORD buffer_size_ = 64 * 1024;
};

} // namespace file_watcher

Now implement the methods declared in the header. This is where the Win32 API calls live.

  • Open src/file_watcher.cpp and replace the stub comment with the full implementation:
#include "file_watcher/file_watcher.h"

#include <stdexcept>
#include <cstring>

namespace file_watcher {

// Validate the directory exists at construction time
FileWatcher::FileWatcher(const std::filesystem::path& directory)
    : directory_(directory) {
    if (!std::filesystem::exists(directory)) {
        throw std::invalid_argument("Directory does not exist: " + directory.string());
    }
    if (!std::filesystem::is_directory(directory)) {
        throw std::invalid_argument("Path is not a directory: " + directory.string());
    }
}

// Destructor ensures the watch thread is stopped
FileWatcher::~FileWatcher() {
    stop();
}

bool FileWatcher::start(EventCallback callback) {
    // Prevent double-start
    if (running_) {
        return false;
    }

    // Open directory with overlapped I/O flags
    HANDLE raw_handle = CreateFileW(
        directory_.wstring().c_str(),
        FILE_LIST_DIRECTORY,
        FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
        nullptr,
        OPEN_EXISTING,
        FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
        nullptr
    );

    if (raw_handle == INVALID_HANDLE_VALUE) {
        return false;
    }

    // Transfer ownership to our RAII handle
    dir_handle_.reset(raw_handle);
    callback_ = std::move(callback);
    running_ = true;
    // Spawn background thread running the watch loop
    watch_thread_ = std::thread(&FileWatcher::watch_loop, this);
    return true;
}

void FileWatcher::stop() {
    if (running_) {
        running_ = false;
        // Cancel the pending ReadDirectoryChangesW call
        CancelIoEx(dir_handle_.get(), nullptr);
        if (watch_thread_.joinable()) {
            watch_thread_.join();
        }
        dir_handle_.reset();
    }
}

bool FileWatcher::is_watching() const noexcept {
    return running_;
}

void FileWatcher::watch_loop() {
    std::vector<BYTE> buffer(buffer_size_);
    OVERLAPPED overlapped{};
    overlapped.hEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr);
    // RAII-manage the event handle
    Win32Handle event_handle(overlapped.hEvent);

    while (running_) {
        DWORD bytes_returned = 0;
        ResetEvent(overlapped.hEvent);

        // Ask Windows to fill our buffer with change notifications
        BOOL result = ReadDirectoryChangesW(
            dir_handle_.get(),
            buffer.data(),
            static_cast<DWORD>(buffer.size()),
            FALSE,
            FILE_NOTIFY_CHANGE_FILE_NAME |
            FILE_NOTIFY_CHANGE_DIR_NAME |
            FILE_NOTIFY_CHANGE_LAST_WRITE |
            FILE_NOTIFY_CHANGE_SIZE,
            &bytes_returned,
            &overlapped,
            nullptr
        );

        if (!result && GetLastError() != ERROR_IO_PENDING) {
            break;
        }

        // Wait up to 500ms so we can check running_ periodically
        DWORD wait_result = WaitForSingleObject(overlapped.hEvent, 500);

        if (wait_result == WAIT_OBJECT_0) {
            if (!GetOverlappedResult(dir_handle_.get(), &overlapped, &bytes_returned, FALSE)) {
                break;
            }

            if (bytes_returned == 0) {
                continue;
            }

            // Parse the linked list of FILE_NOTIFY_INFORMATION records
            auto* info = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(buffer.data());
            while (info != nullptr) {
                std::wstring filename(info->FileName, info->FileNameLength / sizeof(WCHAR));
                FileEvent event{
                    translate_action(info->Action),
                    directory_ / filename
                };
                callback_(event);

                if (info->NextEntryOffset == 0) {
                    break;
                }
                info = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(
                    reinterpret_cast<BYTE*>(info) + info->NextEntryOffset
                );
            }
        }
    }
}

// Map Win32 constants to our FileAction enum
FileAction FileWatcher::translate_action(DWORD action) {
    switch (action) {
        case FILE_ACTION_ADDED:            return FileAction::Added;
        case FILE_ACTION_REMOVED:          return FileAction::Removed;
        case FILE_ACTION_MODIFIED:         return FileAction::Modified;
        case FILE_ACTION_RENAMED_OLD_NAME: return FileAction::RenamedOld;
        case FILE_ACTION_RENAMED_NEW_NAME: return FileAction::RenamedNew;
        default:                           return FileAction::Modified;
    }
}

} // namespace file_watcher

What does this implementation do?

  • The constructor validates the path upfront. If it does not exist or is not a directory, it throws immediately.
  • start() opens the directory with FILE_FLAG_BACKUP_SEMANTICS (required for directories) and FILE_FLAG_OVERLAPPED (enables async I/O), then spawns a background thread.
  • The watch loop uses a 500ms timeout on WaitForSingleObject so the thread wakes up periodically to check the running_ flag. This lets stop() cleanly shut down the thread.
  • stop() calls CancelIoEx to unblock any pending ReadDirectoryChangesW call, then joins the thread.

Why overlapped I/O instead of synchronous?

Synchronous ReadDirectoryChangesW blocks until a change happens. If no files change, the thread is stuck forever and cannot be stopped cleanly.

Overlapped I/O with a timed wait gives you a cooperative cancellation point. The thread checks running_ every 500ms and exits gracefully when asked to stop.

  • Save src/file_watcher.cpp.

Seeing red squiggles in VS Code?

IntelliSense may show errors if it has not re-indexed after you saved the headers. Press Ctrl+Shift+P and run C/C++: Reset IntelliSense Database to force a refresh.

Also confirm you are using the Developer Command Prompt environment. IntelliSense needs access to the Windows SDK headers to resolve Win32 types.

Help me fix IntelliSense errors in my Win32 C++ project.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before building.

ⓧ I'd like to double check the full code

#include "file_watcher/file_watcher.h"

#include <stdexcept>
#include <cstring>

namespace file_watcher {

FileWatcher::FileWatcher(const std::filesystem::path& directory)
    : directory_(directory) {
    if (!std::filesystem::exists(directory)) {
        throw std::invalid_argument("Directory does not exist: " + directory.string());
    }
    if (!std::filesystem::is_directory(directory)) {
        throw std::invalid_argument("Path is not a directory: " + directory.string());
    }
}

FileWatcher::~FileWatcher() {
    stop();
}

bool FileWatcher::start(EventCallback callback) {
    if (running_) {
        return false;
    }

    HANDLE raw_handle = CreateFileW(
        directory_.wstring().c_str(),
        FILE_LIST_DIRECTORY,
        FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
        nullptr,
        OPEN_EXISTING,
        FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
        nullptr
    );

    if (raw_handle == INVALID_HANDLE_VALUE) {
        return false;
    }

    dir_handle_.reset(raw_handle);
    callback_ = std::move(callback);
    running_ = true;
    watch_thread_ = std::thread(&FileWatcher::watch_loop, this);
    return true;
}

void FileWatcher::stop() {
    if (running_) {
        running_ = false;
        CancelIoEx(dir_handle_.get(), nullptr);
        if (watch_thread_.joinable()) {
            watch_thread_.join();
        }
        dir_handle_.reset();
    }
}

bool FileWatcher::is_watching() const noexcept {
    return running_;
}

void FileWatcher::watch_loop() {
    std::vector<BYTE> buffer(buffer_size_);
    OVERLAPPED overlapped{};
    overlapped.hEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr);
    Win32Handle event_handle(overlapped.hEvent);

    while (running_) {
        DWORD bytes_returned = 0;
        ResetEvent(overlapped.hEvent);

        BOOL result = ReadDirectoryChangesW(
            dir_handle_.get(),
            buffer.data(),
            static_cast<DWORD>(buffer.size()),
            FALSE,
            FILE_NOTIFY_CHANGE_FILE_NAME |
            FILE_NOTIFY_CHANGE_DIR_NAME |
            FILE_NOTIFY_CHANGE_LAST_WRITE |
            FILE_NOTIFY_CHANGE_SIZE,
            &bytes_returned,
            &overlapped,
            nullptr
        );

        if (!result && GetLastError() != ERROR_IO_PENDING) {
            break;
        }

        DWORD wait_result = WaitForSingleObject(overlapped.hEvent, 500);

        if (wait_result == WAIT_OBJECT_0) {
            if (!GetOverlappedResult(dir_handle_.get(), &overlapped, &bytes_returned, FALSE)) {
                break;
            }

            if (bytes_returned == 0) {
                continue;
            }

            auto* info = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(buffer.data());
            while (info != nullptr) {
                std::wstring filename(info->FileName, info->FileNameLength / sizeof(WCHAR));
                FileEvent event{
                    translate_action(info->Action),
                    directory_ / filename
                };
                callback_(event);

                if (info->NextEntryOffset == 0) {
                    break;
                }
                info = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(
                    reinterpret_cast<BYTE*>(info) + info->NextEntryOffset
                );
            }
        }
    }
}

FileAction FileWatcher::translate_action(DWORD action) {
    switch (action) {
        case FILE_ACTION_ADDED:            return FileAction::Added;
        case FILE_ACTION_REMOVED:          return FileAction::Removed;
        case FILE_ACTION_MODIFIED:         return FileAction::Modified;
        case FILE_ACTION_RENAMED_OLD_NAME: return FileAction::RenamedOld;
        case FILE_ACTION_RENAMED_NEW_NAME: return FileAction::RenamedNew;
        default:                           return FileAction::Modified;
    }
}

} // namespace file_watcher
Run the full test suite and verify TDD Green

All three implementation files are in place. Time to build the project and run every test.

  • Build the project by running this command in your Developer Command Prompt:
cmake --build build
  • Run the full test suite by running:
ctest --preset default
  • You should see output showing all tests passed. Look for 100% tests passed at the bottom of the output.

What should I see?

CTest runs all discovered Google Test cases and prints a summary. You should see 14 tests pass: 7 for Win32HandleTest and 7 for FileWatcherTest. Every line should show Passed.

Build errors or test failures?

  • If you see linker errors about unresolved symbols, make sure src/file_watcher.cpp is saved and includes the full implementation (not just the stub comment).
  • If you see errors about missing headers like windows.h, confirm you are running the build from the Developer Command Prompt (not a regular terminal).
  • If the file detection tests time out, this can happen on very slow machines. The tests use a 3-second timeout. Re-run them to rule out a transient OS delay.

Help me debug build errors or test failures in my FileWatcher project.

All green. You started with failing tests and wrote just enough code to make them pass. That is the TDD Green phase complete. Next up, you'll extend this library with file extension filtering in the Secret Mission.

Secret mission

Add File Extension Filtering

Your file watcher currently reports every single file change in the directory. In production, a build tool might only care about .cpp and .h files, ignoring .log and .tmp noise. Can you add a filtering API that lets callers specify which extensions matter?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using free tools. There are no cloud resources or ongoing costs to worry about.

Decide whether to keep your project files for future reference, or delete them to free up disk space.

Resources you used:

  • Your FileWatcher project folder on the Desktop (source code, headers, and tests).
  • The build directory inside your project (compiled binaries and downloaded Google Test).
  • MSVC Build Tools (C++ compiler and Windows SDK).
  • VS Code extensions (C/C++ and CMake Tools).

Keep everything running

No action needed. Choose this if you plan to extend the library, add more tests, or use it as a reference for future Win32 projects.

  • Your project folder stays on the Desktop with all source code intact.
  • MSVC Build Tools remain installed and available for other C++ projects.
  • VS Code extensions continue providing IntelliSense and CMake integration.

Pause - I'll come back to this later

Delete the build artifacts to free up disk space while keeping your source code. The build folder contains downloaded dependencies and compiled objects that can be regenerated.

  • Open a Developer Command Prompt and navigate to your project folder.
  • Delete the build directory by running:
rmdir /s /q build

When you return, run cmake --preset default and cmake --build build to rebuild everything.

Delete - I don't want to use this again

Remove all project files and optionally uninstall the tools you installed for this project.

  • Delete the project folder from your Desktop by running this in a Developer Command Prompt:
cd %USERPROFILE%\Desktop
rmdir /s /q FileWatcher

Adjust the folder name

If you named your project folder something different, replace FileWatcher with the actual folder name you used.

  • To uninstall MSVC Build Tools, press the Windows key and search for Add or remove programs.
  • Find Visual Studio Build Tools in the list and click Uninstall.
  • To remove VS Code extensions, open VS Code and click the Extensions icon in the left sidebar.
  • Find C/C++ and CMake Tools, then click Uninstall on each.

Nice Work!

Nice Work!

You just built a production-quality C++ library that monitors directories for file changes using the Win32 API. This is the exact kind of systems-level work that senior C++ roles demand.

What you learned:

  • Built a production-quality RAII wrapper for Win32 HANDLEs demonstrating modern C++ move semantics and safe resource management.
  • Implemented a multi-threaded file watcher using ReadDirectoryChangesW with overlapped I/O for non-blocking directory monitoring.
  • Practiced TDD methodology by writing comprehensive tests before implementation using Google Test and CMake FetchContent.
  • Secret Mission: Added configurable file extension filtering with full test coverage, proving you can extend a library without breaking existing functionality.

Ready to quiz yourself?