Migrating a CMake Project to cmod

You have a working CMake project. It builds. Tests pass. The CI pipeline is green. But the CMakeLists.txt is 400 lines long, find_package calls break on different systems, and nobody on the team fully understands the build configuration. Sound familiar?

This guide walks through migrating a real-world CMake project to cmod, step by step. We'll convert a project that uses fmt, nlohmann/json, and has both a library and a binary target.

The Starting Point

Our example CMake project:

myapp/
├── CMakeLists.txt
├── cmake/
│   └── FindFmt.cmake
├── include/
│   └── myapp/
│       ├── config.h
│       └── processor.h
├── src/
│   ├── main.cpp
│   ├── processor.cpp
│   └── config.cpp
└── tests/
    └── test_processor.cpp

The CMakeLists.txt:

cmake_minimum_required(VERSION 3.20)
project(myapp VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Dependencies — the pain begins
find_package(fmt REQUIRED)
find_package(nlohmann_json 3.11 REQUIRED)

# Library target
add_library(myapp_lib
    src/config.cpp
    src/processor.cpp
)
target_include_directories(myapp_lib PUBLIC include)
target_link_libraries(myapp_lib PUBLIC fmt::fmt nlohmann_json::nlohmann_json)

# Binary target
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE myapp_lib)

# Tests
enable_testing()
add_executable(test_processor tests/test_processor.cpp)
target_link_libraries(test_processor PRIVATE myapp_lib)
add_test(NAME test_processor COMMAND test_processor)

The problems: find_package depends on system-installed libraries (or vcpkg/Conan), the header-based includes are slow, and there's no lockfile.

Step 1: Convert Headers to Module Interfaces

The first and most impactful change is converting your headers to C++20 module interfaces.

Before (include/myapp/processor.h):

#pragma once
#include <string>
#include <vector>
#include <nlohmann/json.hpp>

namespace myapp {
    struct ProcessResult {
        std::string output;
        int status;
    };

    ProcessResult process(const std::string& input);
    std::vector<ProcessResult> batch_process(const nlohmann::json& config);
}

After (src/myapp.cppm):

export module com.github.user.myapp;

import std;  // C++23 standard library module (or individual imports)

export namespace myapp {
    struct ProcessResult {
        std::string output;
        int status;
    };

    ProcessResult process(const std::string& input);
    std::vector<ProcessResult> batch_process(/* json param */);
}

Key changes:

  • #pragma once is gone — modules don't need include guards
  • #include becomes import
  • export marks public API explicitly
  • Implementation details stay private without any extra effort

Step 2: Create the cmod.toml Manifest

[package]
name = "myapp"
version = "1.0.0"
edition = "2024"
license = "MIT"

[module]
name = "com.github.user.myapp"
root = "src/myapp.cppm"

[dependencies]
"github.com/fmtlib/fmt" = ">=10.0"
"github.com/nlohmann/json" = "^3.11"

[toolchain]
compiler = "clang"
version = ">=18.0"
std = "c++23"

[build]
type = "binary"

Compare this with the 30+ line CMakeLists.txt. The cmod manifest is declarative, readable, and complete.

Step 3: Restructure the Source Tree

Reorganize to follow cmod conventions:

myapp/
├── cmod.toml
├── src/
│   ├── myapp.cppm        # Module interface (was include/myapp/*.h)
│   ├── main.cpp          # Binary entry point
│   ├── processor.cpp     # Module implementation
│   └── config.cpp        # Module implementation
└── tests/
    └── test_processor.cpp

The include/ directory is gone. With modules, the interface is defined in .cppm files, not headers. The cmake/ directory is gone too — cmod handles dependency resolution.

Step 4: Update Source Files

Update implementation files to use module syntax:

Before (src/processor.cpp):

#include "myapp/processor.h"
#include <fmt/format.h>
#include <algorithm>

namespace myapp {
    ProcessResult process(const std::string& input) {
        auto output = fmt::format("Processed: {}", input);
        return {output, 0};
    }
}

After (src/processor.cpp):

module com.github.user.myapp;

import com.github.fmtlib.fmt;
import std;

namespace myapp {
    ProcessResult process(const std::string& input) {
        auto output = fmt::format("Processed: {}", input);
        return {output, 0};
    }
}

Update main.cpp:

import com.github.user.myapp;
import com.github.fmtlib.fmt;

int main() {
    auto result = myapp::process("hello");
    fmt::print("{}\n", result.output);
    return result.status;
}

Step 5: Resolve and Build

# Resolve dependencies (fetches from Git)
cmod resolve

# Build
cmod build

# Run
cmod run

# Test
cmod test

That's it. No cmake -B build, no cmake --build build, no figuring out where CMake put the binary. Just cmod build and cmod run.

Step 6: Set Up CI

Replace your CMake CI pipeline:

Before:

- name: Install dependencies
  run: |
    apt-get install -y libfmt-dev nlohmann-json3-dev

- name: Configure
  run: cmake -B build -DCMAKE_BUILD_TYPE=Release

- name: Build
  run: cmake --build build --parallel

- name: Test
  run: cd build && ctest --output-on-failure

After:

- name: Build
  run: cmod build --locked --release

- name: Test
  run: cmod test --locked --release

- name: Verify
  run: cmod verify

No system-level dependency installation. cmod resolves everything from Git.

Step 7: Keep CMake Compatibility (Optional)

If some consumers still need CMake, cmod can generate a CMakeLists.txt:

cmod compile-commands   # For IDE integration

This generates compile_commands.json that CMake-based IDEs can consume. You can maintain a minimal CMakeLists.txt that wraps cmod for teams that haven't migrated yet.

Common Migration Challenges

Dependencies that don't support modules yet

Not all C++ libraries have module interfaces. For header-only libraries, you can use header units as a bridge:

import <legacy_header.h>;  // Import as header unit

For popular libraries, the community is creating module wrappers. cmod's Git-based dependency model makes it easy to point to a fork with module support.

Macro-heavy code

Modules don't export macros (by design — this is a feature, not a bug). If your code relies on macros defined in headers, you'll need to refactor. Common patterns:

  • Replace #define CONSTANT 42 with export constexpr int CONSTANT = 42;
  • Replace macro functions with constexpr or consteval functions
  • Replace conditional compilation macros with if constexpr

Build time comparison

After migration, expect:

  • First build: Slightly slower (dependency cloning + BMI generation)
  • Incremental builds: Significantly faster (module-aware caching)
  • Clean builds: Comparable or faster (parallel module compilation)

The Result

After migration:

  • 400-line CMakeLists.txt replaced by a 20-line cmod.toml
  • Dependencies resolved from Git, not system packages
  • Lockfile ensures reproducible builds
  • Faster incremental builds via module-aware caching
  • Explicit public API via module exports
  • No more "it works on my machine" debugging

Get started with cmod and simplify your C++ build.