Files
cargoxx/SPEC.md
2026-05-07 23:32:46 +00:00

24 KiB
Raw Blame History

cargoxx — project specification

A Cargo-style frontend for modern C++ that uses Nix as the source of truth for dependencies and generates CMake for the build. Users author projects with C++23 modules and never touch CMake or flake.nix by hand.

This document defines the user-facing contract: project layout, CLI commands, manifest schema, generated files, and the algorithms that drive code generation. It is the source of truth for what cargoxx must do. Implementation details are in TECH_SPEC.md.


1. Goals and non-goals

Goals (v0.1)

  • Single binary CLI cargoxx with new, build, add, run, clean commands.
  • Cargo-compatible project layout. A user who knows Cargo recognizes everything.
  • Module-first authoring: every C++ source unit is a module interface (.cppm) or a module implementation (.cpp). Headers are consumed via the global module fragment, not authored.
  • Reproducible builds via flake.nix + flake.lock. The Nix store is the dependency cache.
  • Generated CMake is hidden in build/ and treated as a build artifact. Users never edit it.
  • Curated link database covering ~25 popular libraries, shipped with the tool.

Non-goals (v0.1)

  • Not a workspace tool. No multi-package repos in v0.1.
  • Not a publisher. No cargoxx publish, no central registry.
  • No support for non-Nix package managers as primary (Conan / vcpkg recipes are read-only data sources for the link database).
  • No Windows support in v0.1. Linux + macOS via Nix.
  • No automatic resolution of arbitrary packages from Conan / vcpkg recipes. v0.1 ships a hand-curated database; v0.2 adds automatic resolution.

Explicit scope cuts that may be requested but are out

  • Cross-compilation
  • Custom build scripts (build.rs equivalent)
  • Features / conditional compilation flags
  • Dev-dependencies separate from regular dependencies
  • Profiles other than debug and release

2. Glossary

  • Manifest — the Cargoxx.toml file at the project root.
  • Link recipe — the CMake snippet (find_package(...) + target names) needed to consume a package. Stored per (package, version) in the link database.
  • Link database — SQLite database at ~/.cache/cargoxx/linkdb.sqlite plus a curated JSON file shipped with cargoxx.
  • Module unit — a .cppm file containing export module foo;.
  • Implementation unit — a .cpp file containing module foo; (no export).
  • Crate root — Cargo terminology for the entry source file. We use the same convention: src/main.cpp is the binary crate root, src/lib.cppm is the library crate root.

3. User-facing project layout

The layout is fixed. cargoxx infers targets from file paths; it does not read globs from the manifest.

my-project/
├── Cargoxx.toml              # manifest (committed)
├── Cargoxx.lock              # resolved dep versions (committed)
├── flake.nix                 # generated from manifest (committed)
├── flake.lock                # nix lockfile (committed)
├── .gitignore                # cargoxx-managed entries
├── src/
│   ├── main.cpp              # → binary target named after [package].name
│   ├── lib.cppm              # → library target named after [package].name
│   └── bin/
│       └── tool.cpp          # → additional binary target "tool"
├── tests/
│   └── basic.cpp             # → test target "basic"
├── examples/
│   └── demo.cpp              # → example target "demo"
└── build/                    # gitignored
    ├── CMakeLists.txt        # generated
    ├── debug/                # cmake binary dir for debug profile
    └── release/              # cmake binary dir for release profile

Target inference rules

Applied in order, each rule produces zero or more CMake targets.

Path Target kind Target name
src/lib.cppm static library <package-name>
src/main.cpp executable <package-name>
src/bin/<name>.cpp executable <name>
src/<dir>/*.cppm module units belonging to src/lib.cppm (none — added to library)
src/<dir>/*.cpp implementation units belonging to src/lib.cppm (none — added to library)
tests/<name>.cpp executable + add_test test_<name>
examples/<name>.cpp executable example_<name>

Rules are deliberately rigid. If a user wants exotic layouts, they can drop down to raw CMake — but cargoxx will refuse to generate from a layout it doesn't recognize.

Conflict and validation

  • If src/lib.cppm exists, src/main.cpp (when present) must import <package-name>; — cargoxx does not enforce this with a parser, but documents it.
  • If neither src/main.cpp nor src/lib.cppm exists, cargoxx build errors with: no target found: expected src/main.cpp or src/lib.cppm.
  • File names other than the listed ones in src/, src/bin/, tests/, examples/ are ignored (not an error). This allows users to keep auxiliary scripts in those directories.
  • Subdirectories under src/bin/, tests/, examples/ are not walked. Each top-level file is one target.

4. Manifest format — Cargoxx.toml

[package]
name = "my-project"
version = "0.1.0"
edition = "cpp23"          # one of: cpp20, cpp23, cpp26
authors = ["Name <email>"] # optional
license = "MIT"            # optional, SPDX expression

[dependencies]
fmt = "10.2"
spdlog = "1.13"
boost = { version = "1.84", components = ["filesystem", "system"] }
nlohmann_json = "3.11"

[build]
warnings_as_errors = true  # optional, default false
sanitizers = ["address"]   # optional, list of: address, undefined, thread, leak

Dependency syntax

Two forms allowed:

fmt = "10.2"                                              # version string only
boost = { version = "1.84", components = ["filesystem"] } # table form

Version strings follow Cargo semver semantics ("10.2" means >=10.2.0, <11.0.0). cargoxx resolves to the highest matching version available in the configured Nixpkgs revision.

components is meaningful only for packages whose link recipe declares component support (Boost, Qt, abseil). Specified components map directly to CMake targets.

Reserved fields

The following keys are parsed and stored but not yet acted on. They MUST be accepted without error so manifests written for v0.1 stay valid in later versions:

  • [dev-dependencies]
  • [features]
  • [workspace]
  • [package].repository
  • [package].description

5. Lockfile — Cargoxx.lock

Generated and updated by cargoxx. Format is TOML, committed to version control.

version = 1

[[package]]
name = "my-project"
version = "0.1.0"
dependencies = [
    "fmt 10.2.1",
    "spdlog 1.13.0",
]

[[package]]
name = "fmt"
version = "10.2.1"
nixpkgs_attr = "fmt_10"
nixpkgs_rev = "8a3f...c2d1"
linkdb_source = "curated"

[[package]]
name = "spdlog"
version = "1.13.0"
nixpkgs_attr = "spdlog"
nixpkgs_rev = "8a3f...c2d1"
linkdb_source = "curated"

nixpkgs_rev is the same for all entries in a single resolution — cargoxx pins one Nixpkgs revision per project, picked to satisfy all [dependencies] simultaneously. This is the simplest model and matches what Nix flakes do natively.


6. CLI commands

cargoxx new <name> and cargoxx new --lib <name>

Creates a new project directory.

Default (cargoxx new foo) creates a binary project:

foo/
├── Cargoxx.toml
├── flake.nix
├── flake.lock
├── .gitignore
└── src/
    └── main.cpp

With --lib:

foo/
├── Cargoxx.toml
├── flake.nix
├── flake.lock
├── .gitignore
└── src/
    └── lib.cppm

src/main.cpp template:

import std;

int main() {
    std::println("Hello from {}!", "foo");
    return 0;
}

src/lib.cppm template:

export module foo;

import std;

export namespace foo {
    auto greeting() -> std::string_view {
        return "Hello from foo!";
    }
}

After scaffolding, runs cargoxx build --no-build (generate flake.nix and CMakeLists.txt, do not invoke nix/cmake) so the project opens correctly in IDEs immediately.

cargoxx add <pkg>[@<version>] and cargoxx add <pkg> --components <a,b>

Adds a dependency.

Algorithm:

  1. Parse the package spec.
  2. If @<version> is omitted, query the resolver for the latest version available in Nixpkgs and use that.
  3. Resolve the link recipe (see §9).
  4. Update Cargoxx.toml [dependencies] table, preserving formatting where possible.
  5. Update Cargoxx.lock.
  6. Regenerate flake.nix.
  7. Print the chosen version and link recipe source for transparency: Added fmt 10.2.1 (linkdb: curated).

If the link recipe cannot be resolved, the command fails with instructions for the user to file an issue or supply a manual recipe via Cargoxx.toml.

cargoxx remove <pkg>

Inverse of add. Removes from manifest, lockfile, and regenerates flake.nix.

cargoxx build [--release] [--target <name>]

  1. Validate manifest and project layout.
  2. Resolve dependencies if Cargoxx.lock is missing or stale.
  3. Generate build/CMakeLists.txt from manifest and source tree.
  4. Generate / update flake.nix if stale.
  5. Run nix develop --command cmake -B build/<profile> -S build -G Ninja -DCMAKE_BUILD_TYPE=<Profile> if the binary dir doesn't exist or is stale.
  6. Run nix develop --command cmake --build build/<profile> [--target <name>].

<profile> is debug (default) or release (with --release). Capitalized <Profile> is Debug or Release.

cargoxx run [--release] [--bin <name>] [-- <args>...]

build then exec the chosen binary. If the project has multiple binary targets and --bin is omitted, fail with a list. If exactly one binary exists, run it.

cargoxx test [--release]

Build all tests/*.cpp targets, then run them via ctest --output-on-failure inside build/<profile>.

cargoxx clean

Removes build/. Does not touch Cargoxx.lock or flake.lock.

cargoxx fmt and cargoxx check

Stubs in v0.1 — accepted but only print a deprecation-style message: cargoxx fmt: not implemented in v0.1, run clang-format directly. This reserves the command names.


7. Generated flake.nix

cargoxx generates exactly this template. Fields in <<...>> are substituted from the manifest.

{
  description = "<<package.name>>";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/<<resolved-rev>>";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = import nixpkgs { inherit system; };
        llvmPkgs = pkgs.llvmPackages;
      in {
        devShell = llvmPkgs.libcxxStdenv.mkDerivation {
          name = "shell";
          version = "1.0";
          nativeBuildInputs = [
            pkgs.ninja
            pkgs.cmake
            pkgs.clang-tools
          ];
          env.NIX_CFLAGS_COMPILE = toString [
            "-stdlib=libc++"
            "-Wno-unused-command-line-argument"
            "-B${pkgs.lib.getLib pkgs.libcxx}/lib"
            "-isystem ${pkgs.lib.getDev pkgs.libcxx}/include/c++/v1"
          ];
          hardeningDisable = [
            "all"
          ];
        };
      });
}

<<resolved-rev>> is the Nixpkgs commit hash from Cargoxx.lock. The toolchain (clang_21, cmake, ninja) is fixed in v0.1 — Clang because module support is most mature there.

Regeneration is idempotent: cargoxx writes the file only if its content would change. This avoids spurious flake.lock updates.


8. Generated build/CMakeLists.txt

cmake_minimum_required(VERSION 3.30)
project(<<package.name>> LANGUAGES CXX)

# ----- toolchain configuration -----
set(CMAKE_CXX_STANDARD <<edition-numeric>>)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_SCAN_FOR_MODULES ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# Generated by cargoxx — do not edit.
# Source of truth: ../Cargoxx.toml

# ----- dependencies -----
<<one find_package line per dependency, from link recipe>>

# ----- library target (if present) -----
<<if src/lib.cppm exists>>
add_library(<<package.name>> STATIC)
target_sources(<<package.name>>
    PUBLIC
        FILE_SET CXX_MODULES FILES
            ../src/lib.cppm
            <<each additional .cppm under src/, recursive>>
    PRIVATE
        <<each .cpp under src/ except src/main.cpp and src/bin/*, recursive>>
)
target_link_libraries(<<package.name>> PUBLIC
    <<each dep's CMake target name from link recipe>>
)
<<endif>>

# ----- binary target (if src/main.cpp exists) -----
<<if src/main.cpp exists>>
add_executable(<<package.name>>_bin ../src/main.cpp)
set_target_properties(<<package.name>>_bin PROPERTIES OUTPUT_NAME <<package.name>>)
target_link_libraries(<<package.name>>_bin PRIVATE
    <<package.name>>            # only if library target also exists
    <<each dep's CMake target name>>
)
<<endif>>

# ----- additional binaries (src/bin/*.cpp) -----
<<for each src/bin/<name>.cpp>>
add_executable(<<name>> ../src/bin/<<name>>.cpp)
target_link_libraries(<<name>> PRIVATE
    <<package.name>>            # only if library target also exists
    <<each dep's CMake target name>>
)
<<endfor>>

# ----- tests -----
<<if any tests/*.cpp>>
enable_testing()
<<for each tests/<name>.cpp>>
add_executable(test_<<name>> ../tests/<<name>>.cpp)
target_link_libraries(test_<<name>> PRIVATE
    <<package.name>>            # only if library target also exists
    <<each dep's CMake target name>>
)
add_test(NAME <<name>> COMMAND test_<<name>>)
<<endfor>>
<<endif>>

# ----- examples -----
<<for each examples/<name>.cpp>>
add_executable(example_<<name>> ../examples/<<name>>.cpp)
target_link_libraries(example_<<name>> PRIVATE
    <<package.name>>            # only if library target also exists
    <<each dep's CMake target name>>
)
<<endfor>>

# ----- warnings and sanitizers from [build] section -----
<<if build.warnings_as_errors>>
foreach(target_name IN ITEMS <<all_targets>>)
    target_compile_options(${target_name} PRIVATE -Wall -Wextra -Wpedantic -Werror)
endforeach()
<<endif>>
<<if build.sanitizers non-empty>>
<<emit -fsanitize=... flags via target_compile_options and target_link_options>>
<<endif>>

Edition mapping:

  • cpp20set(CMAKE_CXX_STANDARD 20)
  • cpp23set(CMAKE_CXX_STANDARD 23)
  • cpp26set(CMAKE_CXX_STANDARD 26) (CMake 3.30 supports the value as experimental)

Source paths are written relative to build/ (i.e. prefixed with ../) because the file lives in build/CMakeLists.txt but sources live in ../src/.


On-disk format

Curated database shipped with cargoxx, embedded as a resource (compiled in or read from ${prefix}/share/cargoxx/linkdb.json):

{
  "version": 1,
  "packages": {
    "fmt": [
      {
        "version": ">=10.0.0",
        "nixpkgs_attr": "fmt_10",
        "find_package": "fmt CONFIG REQUIRED",
        "targets": ["fmt::fmt"]
      },
      {
        "version": ">=8.0.0,<10.0.0",
        "nixpkgs_attr": "fmt_8",
        "find_package": "fmt CONFIG REQUIRED",
        "targets": ["fmt::fmt"]
      }
    ],
    "boost": [
      {
        "version": ">=1.70.0",
        "nixpkgs_attr": "boost",
        "find_package": "Boost REQUIRED COMPONENTS {{components}}",
        "targets": ["Boost::{{component}}"],
        "components": "supported"
      }
    ],
    "openssl": [
      {
        "version": "*",
        "nixpkgs_attr": "openssl",
        "find_package": "OpenSSL REQUIRED",
        "targets": ["OpenSSL::SSL", "OpenSSL::Crypto"]
      }
    ]
  }
}

The version field is a Cargo-style range. cargoxx picks the first entry whose range matches the resolved version.

{{components}} and {{component}} are Mustache-style placeholders substituted at codegen time.

User-overlay database

~/.cache/cargoxx/linkdb.sqlite:

CREATE TABLE recipes (
    package        TEXT NOT NULL,
    version_range  TEXT NOT NULL,
    nixpkgs_attr   TEXT NOT NULL,
    find_package   TEXT NOT NULL,
    targets        TEXT NOT NULL,    -- JSON array
    components     TEXT,             -- 'supported' | NULL
    source         TEXT NOT NULL,    -- 'curated' | 'conan' | 'vcpkg' | 'nix-probe' | 'manual'
    verified_at    INTEGER NOT NULL, -- unix epoch
    PRIMARY KEY (package, version_range, source)
);

CREATE TABLE resolution_failures (
    package        TEXT NOT NULL,
    version        TEXT NOT NULL,
    last_attempt   INTEGER NOT NULL,
    error          TEXT NOT NULL,
    PRIMARY KEY (package, version)
);

Lookup precedence: user overlay (any source) → curated (shipped JSON) → automatic resolution (v0.2+) → fail.

Resolution algorithm (v0.1)

resolve_link_recipe(package, version) -> Recipe | Error:

  1. Query user overlay SQLite. If a row matches and verified_at is recent (<30 days for nix-probe, never expires for curated / manual), return it.
  2. Query the embedded curated JSON. If a range matches, return it.
  3. Return Error::Unknown { package }.

In v0.2, after step 2 the algorithm continues:

  1. Try conan-center-index GitHub raw lookup for recipes/<package>/all/conanfile.py. Parse cmake_target_name, cmake_file_name. Convert to a recipe.
  2. Try microsoft/vcpkg/ports/<package>/usage. Parse the literal CMake snippet.
  3. Run nix-build for the package, then scan the output for lib/cmake/*/*Config.cmake and grep add_library(... IMPORTED) lines.

Each successful step writes the result to the user overlay before returning.


10. Version resolution algorithm

resolve_version(package, version_spec) -> (version, nixpkgs_rev):

  1. If Cargoxx.lock already pins this package and the spec is satisfied, return the lockfile entry.
  2. Query nixhub.io: https://www.nixhub.io/packages/<package>?_data=routes%2F_nixhub.packages.%24pkg._index. Parse JSON for available versions and their commits.
  3. If nixhub.io is unreachable, fall back to lazamar: https://lazamar.co.uk/nix-versions/?package=<package>&channel=nixpkgs-unstable. Parse HTML (well-formed table).
  4. If both fail, fall back to a local Nixpkgs git clone at ~/.cache/cargoxx/nixpkgs/. Run git log --all -S 'version = "<version>"' -- pkgs/.
  5. Filter the candidate list by the version spec, choose the highest match, return (version, rev).

For the whole-project resolution (multiple deps), cargoxx picks one revision: the latest revision that contains acceptable versions of every dependency. This is brute-force in v0.1: for each candidate revision (newest first, capped at 50 attempts), check whether all deps resolve. Take the first hit.

If no revision satisfies all constraints simultaneously, fail with a list of conflicting deps. The user resolves manually by relaxing version specs.


11. Curated package list (v0.1)

cargoxx ships with link recipes for these 25 packages. They cover the common cases. The list is fixed for v0.1; any package outside it requires a v0.2 automatic resolver or a manual user-overlay entry.

Package Nixpkgs attr CMake find_package CMake targets
fmt fmt fmt CONFIG REQUIRED fmt::fmt
spdlog spdlog spdlog CONFIG REQUIRED spdlog::spdlog
nlohmann_json nlohmann_json nlohmann_json CONFIG REQUIRED nlohmann_json::nlohmann_json
boost boost Boost REQUIRED COMPONENTS {{c}} Boost::{{c}}
openssl openssl OpenSSL REQUIRED OpenSSL::SSL, OpenSSL::Crypto
zlib zlib ZLIB REQUIRED ZLIB::ZLIB
sqlite3 sqlite SQLite3 REQUIRED SQLite::SQLite3
curl curl CURL REQUIRED CURL::libcurl
protobuf protobuf Protobuf REQUIRED protobuf::libprotobuf
grpc grpc gRPC CONFIG REQUIRED gRPC::grpc++
abseil-cpp abseil-cpp absl CONFIG REQUIRED absl::{{c}}
gtest gtest GTest CONFIG REQUIRED GTest::gtest, GTest::gtest_main
catch2 catch2_3 Catch2 CONFIG REQUIRED Catch2::Catch2WithMain
eigen eigen Eigen3 CONFIG REQUIRED Eigen3::Eigen
tbb tbb TBB CONFIG REQUIRED TBB::tbb
libpng libpng PNG REQUIRED PNG::PNG
libjpeg libjpeg JPEG REQUIRED JPEG::JPEG
freetype freetype Freetype REQUIRED Freetype::Freetype
glfw glfw glfw3 CONFIG REQUIRED glfw
glm glm glm CONFIG REQUIRED glm::glm
sdl2 SDL2 SDL2 CONFIG REQUIRED SDL2::SDL2
cli11 cli11 CLI11 CONFIG REQUIRED CLI11::CLI11
cxxopts cxxopts cxxopts CONFIG REQUIRED cxxopts::cxxopts
range-v3 range-v3 range-v3 CONFIG REQUIRED range-v3::range-v3
magic_enum magic-enum magic_enum CONFIG REQUIRED magic_enum::magic_enum

Verification of these entries is part of acceptance for v0.1 — see §13 of TECH_SPEC.md.


12. Error model

User-facing errors must be actionable. Each error has a code, a one-line message, and a "what to do" hint.

Examples:

error[E0001]: no target found
  --> ./
  expected one of: src/main.cpp, src/lib.cppm
  hint: run `cargoxx new --lib <name>` to create a library project
error[E0042]: package not in link database
  --> Cargoxx.toml:7:1
  package "obscurelib" has no known CMake link recipe
  hint: file an issue at <repo>/issues/new, or add a manual recipe via:
        cargoxx linkdb add obscurelib --find-package "obscurelib CONFIG REQUIRED" --targets "obscurelib::obscurelib"
error[E0010]: unsatisfiable version constraint
  fmt = "11.0" is not available in any Nixpkgs revision that also has spdlog = "1.13"
  available revisions for fmt 11.0: 8a3f...c2d1, 7e21...b09a
  available revisions for spdlog 1.13: 4d22...e1f8
  hint: relax one constraint and re-run `cargoxx add`

The full list of error codes is in TECH_SPEC.md §6.


13. Tools and dependencies (host project)

cargoxx itself is a C++23 application. It is a single static binary at install time.

Concern Library Why
TOML parsing toml++ (header-only) Best C++ TOML library, supports comment-preserving writes via toml::table
JSON parsing nlohmann/json Familiar, header-only, fine for our scale
HTTP client cpp-httplib (header-only) No OpenSSL hard dep at build time, bundled TLS via system OpenSSL
SQLite sqlite3 C API directly One translation unit, no abstraction tax
Git operations shell out to git libgit2 is a heavy dep for what we need
Subprocess reproc (or boost::process) Cross-platform, capture stdout/stderr cleanly
CLI parsing CLI11 Subcommand support, good UX
Logging spdlog Everyone has it
Filesystem std::filesystem Built in
Testing Catch2 v3 Module support, modern

Bootstrap toolchain: clang_21, cmake_3_30+, ninja. Provided by a bootstrap flake.nix shipped in cargoxx's own repo.


14. Security considerations

  • cargoxx never runs commands from network responses. Conan recipes are Python files; v0.2's automatic resolver MUST parse them as text (regex / minimal AST), never execute them.
  • vcpkg usage files are plain text and safe to read.
  • nixhub.io and lazamar are trusted only as version directories. The actual fetch happens through Nix, which verifies hashes via flake.lock.
  • The user overlay SQLite is per-user with default permissions (0600).
  • cargoxx never writes outside the project directory and ~/.cache/cargoxx/.

15. Versioning and stability

v0.1 is explicitly experimental. Manifest format may break at v0.2. After v1.0:

  • Bumping [package].edition is the only way to opt into breaking C++ language changes.
  • The manifest format is stable across minor versions.
  • The link database format is independent of the manifest version.
  • Generated flake.nix and CMakeLists.txt are not part of the stability contract — they're build artifacts.

Cargoxx.lock format version is bumped on incompatible changes; cargoxx refuses to read a newer lockfile than it understands.