Compare commits

..

7 Commits

25 changed files with 914 additions and 77 deletions

View File

@@ -341,3 +341,60 @@ All notable changes to cargoxx will be documented in this file.
window. `tests/linkdb_overlay.cpp` covers 7 cases (insert/persist, window. `tests/linkdb_overlay.cpp` covers 7 cases (insert/persist,
override-curated, version-range gating, components rejection, override-curated, version-range gating, components rejection,
move semantics). move semantics).
- M7 generated flake.nix moves to `build/flake.nix`. The project root
belongs to the user — any hand-written `flake.nix` there is never
overwritten by cargoxx. `cargoxx build` always invokes `nix develop`
against `path:./build`.
- M7 lockfile pins top-level `nixpkgs_rev` and `flake_utils_rev`. The
generated flake's `inputs.nixpkgs.url` / `inputs.flake-utils.url` now
use the pinned revs (falling back to the branch tips during the
transitional first build before the lock is written). Per-package
schema gains the full recipe (`find_package`, `targets`,
`pkg_config_module`, `brute_force_libs`, `brute_force_includes`,
`linkdb_source`) so the lockfile is a complete dependency-pinning
artifact and `cmd_build`'s `recipe_from_lock` can short-circuit the
linkdb entirely. `tests/lockfile_round_trip.cpp` extended.
- M7 `codegen::VendorIndex` + `parse_vendor_toml` — new pure parser
(`src/codegen/vendor.cpp`) returns a struct of
`nixpkgs_store_path`, `flake_utils_store_path`, and a per-dep
`nixpkgs_attr → store_path` map. `GenerateInputs` gains an optional
`vendor` field; when set, `emit_inputs_block` emits `path:` inputs
and drops the per-dep `github:` pins.
- M7 new helpers in `cargoxx.resolver`:
`realize_path_at_rev(rev, attr)` realizes
`github:NixOS/nixpkgs/<rev>#<attr>` to a `/nix/store/...` path
(used by `cmd_vendor`); `realize_flake_source(flake_ref)` returns
the source store path via `nix flake prefetch --json` (used to pin
`nixpkgs` and `flake-utils` for offline mode).
- M7 `cargoxx vendor [--output <path>]` — new CLI verb. Reads
`Cargoxx.lock`, realizes each locked dep at its pinned
`(nixpkgs_rev, nixpkgs_attr)` into `/nix/store`, and writes
`vendor.toml` (schema = 1) recording the resolved store paths for
every dep plus the `nixpkgs` and `flake-utils` flake sources. The
output is the input to `cargoxx build --offline`.
- M7 `cargoxx build --offline [--vendor <path>]` — skips every network
probe (Conan/vcpkg fuzzy, devbox, nixpkgs_git, linkdb auto-resolve),
reads `vendor.toml` (default `./vendor.toml`), and emits
`build/flake.nix` with literal `path:/nix/store/...` inputs for
`nixpkgs`, `flake-utils`, and every dep. Offline mode also runs cmake
directly (no outer `nix develop` wrapper) since all paths are already
realized in the local store.
- M7 `cargoxx.lib.buildCppPackage` — hermetic, sandbox-safe nix builder
for downstream flakes. Mirrors `rustPlatform.buildRustPackage`'s
ergonomics: a consumer flake passes `src` and gets a derivation. Reads
`Cargoxx.lock` at outer eval time, resolves each dep's
`(nixpkgs_rev, nixpkgs_attr)` via `builtins.getFlake` into concrete
`/nix/store/...` paths, and synthesizes a `vendor.toml` via
`pkgs.writeText` — no network or nested `nix` invocations inside any
build phase. The single derivation runs `cargoxx build --release
--offline --vendor <store-path>/vendor.toml`, which emits a hermetic
`build/flake.nix` with literal `path:/nix/store/...` inputs and drives
cmake directly. Works under the host's default sandbox (sandbox=true,
non-trusted user, no `__noChroot`, no daemon escape). New e2e fixture
at `tests/e2e/buildCppPackage/` with a `run.sh` smoke test that
scaffolds the fixture in a tmp dir and runs `nix build .#default`
end-to-end. Live verified: `Hello from world!` from a binary built
entirely inside the standard nix sandbox.
- Fix: `-Wparentheses` warning in `looks_like_missing_attribute`
(`src/resolver/nixpkgs_probe.cpp:34`) — explicitly parenthesize the
`&&` clause inside `||`.

View File

@@ -1,3 +1,5 @@
flake_utils_rev = '11707dc2f618dd54ca8739b309ec4fc024de578b'
nixpkgs_rev = 'da5ad661ba4e5ef59ba743f0d112cbc30e474f32'
version = 1 version = 1
[[package]] [[package]]
@@ -6,19 +8,25 @@ name = 'cargoxx'
version = '0.1.0' version = '0.1.0'
[[package]] [[package]]
find_package = 'reproc CONFIG REQUIRED'
linkdb_source = 'nix-probe' linkdb_source = 'nix-probe'
name = 'reproc' name = 'reproc'
nixpkgs_attr = 'reproc' nixpkgs_attr = 'reproc'
targets = [ 'reproc' ]
version = '*' version = '*'
[[package]] [[package]]
find_package = 'SQLite3 REQUIRED'
linkdb_source = 'cmake-findmodule' linkdb_source = 'cmake-findmodule'
name = 'sqlite' name = 'sqlite'
nixpkgs_attr = 'sqlite' nixpkgs_attr = 'sqlite'
targets = [ 'SQLite::SQLite3' ]
version = '*' version = '*'
[[package]] [[package]]
find_package = 'Catch2 CONFIG REQUIRED'
linkdb_source = 'nix-probe' linkdb_source = 'nix-probe'
name = 'catch2_3' name = 'catch2_3'
nixpkgs_attr = 'catch2_3' nixpkgs_attr = 'catch2_3'
targets = [ 'Catch2::Catch2', 'Catch2::Catch2WithMain' ]
version = '*' version = '*'

View File

@@ -15,7 +15,7 @@ set(CMAKE_CXX_EXTENSIONS ON)
set(CMAKE_CXX_SCAN_FOR_MODULES ON) set(CMAKE_CXX_SCAN_FOR_MODULES ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
add_compile_options(-Wall -Wextra -Wpedantic -Wconversion) add_compile_options(-Wall -Wextra -Wpedantic -Wconversion -Wno-missing-field-initializers)
# ----- dependencies ----- # ----- dependencies -----
find_package(reproc CONFIG REQUIRED) find_package(reproc CONFIG REQUIRED)
@@ -46,9 +46,11 @@ target_sources(cargoxx
../src/cli/cmd_remove.cpp ../src/cli/cmd_remove.cpp
../src/cli/cmd_run.cpp ../src/cli/cmd_run.cpp
../src/cli/cmd_test.cpp ../src/cli/cmd_test.cpp
../src/cli/cmd_vendor.cpp
../src/cli/run.cpp ../src/cli/run.cpp
../src/codegen/cmake.cpp ../src/codegen/cmake.cpp
../src/codegen/flake.cpp ../src/codegen/flake.cpp
../src/codegen/vendor.cpp
../src/exec/subprocess.cpp ../src/exec/subprocess.cpp
../src/layout/layout.cpp ../src/layout/layout.cpp
../src/linkdb/database.cpp ../src/linkdb/database.cpp

116
flake.nix
View File

@@ -10,22 +10,108 @@
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
cargoxx-bin = pkgs.gcc15Stdenv.mkDerivation {
pname = "cargoxx";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [ pkgs.cmake pkgs.ninja ];
buildInputs = [ pkgs.sqlite pkgs.reproc pkgs.catch2_3 ];
configurePhase = ''
cmake -S build -B build/release -G Ninja \
-DCMAKE_BUILD_TYPE=Release
'';
buildPhase = ''
cmake --build build/release
'';
installPhase = ''
mkdir -p $out/bin
cp build/release/cargoxx $out/bin/
'';
hardeningDisable = [ "all" ];
};
buildCppPackage = { src, name ? null, ... }@args:
let
lock = builtins.fromTOML (builtins.readFile (src + "/Cargoxx.lock"));
isDep = p: p ? linkdb_source;
isRoot = p: !(isDep p);
root = builtins.head (builtins.filter isRoot lock.package);
depPkgs = builtins.filter isDep lock.package;
pname = if name != null then name else root.name;
pkgsAt = rev:
(builtins.getFlake "github:NixOS/nixpkgs/${rev}")
.legacyPackages.${system};
evalDep = p:
let rev = if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "")
then p.nixpkgs_rev
else lock.nixpkgs_rev;
in (pkgsAt rev).${p.nixpkgs_attr};
depInputs = map evalDep depPkgs;
usesPkgConfig = builtins.any
(p: (p.linkdb_source or "") == "pkg-config") depPkgs;
nixpkgsSource = (builtins.getFlake
"github:NixOS/nixpkgs/${lock.nixpkgs_rev}").outPath;
flakeUtilsSource = (builtins.getFlake
"github:numtide/flake-utils/${lock.flake_utils_rev}").outPath;
mkDepTomlEntry = p:
let
derivation = evalDep p;
rev = if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "")
then p.nixpkgs_rev else lock.nixpkgs_rev;
in ''
[[dep]]
name = "${p.name}"
nixpkgs_attr = "${p.nixpkgs_attr}"
nixpkgs_rev = "${rev}"
store_path = "${derivation}"
'';
vendorToml = pkgs.writeText "vendor.toml" (''
schema = 1
[nixpkgs]
rev = "${lock.nixpkgs_rev}"
store_path = "${nixpkgsSource}"
[flake_utils]
rev = "${lock.flake_utils_rev}"
store_path = "${flakeUtilsSource}"
'' + builtins.concatStringsSep "\n" (map mkDepTomlEntry depPkgs));
in pkgs.gcc15Stdenv.mkDerivation {
inherit pname src;
version = root.version;
nativeBuildInputs =
[ cargoxx-bin pkgs.cmake pkgs.ninja ]
++ pkgs.lib.optional usesPkgConfig pkgs.pkg-config;
buildInputs = depInputs;
dontConfigure = true;
buildPhase = ''
export HOME=$(mktemp -d)
cargoxx build --release --offline --vendor ${vendorToml}
'';
installPhase = ''
mkdir -p $out/bin
cp build/release/${pname} $out/bin/ 2>/dev/null || \
cp build/release/${pname}_bin $out/bin/${pname}
'';
hardeningDisable = [ "all" ];
};
in { in {
devShell = pkgs.gcc15Stdenv.mkDerivation { packages.default = cargoxx-bin;
name = "shell"; lib.buildCppPackage = buildCppPackage;
version = "1.0"; devShells.default = pkgs.gcc15Stdenv.mkDerivation {
nativeBuildInputs = [ name = "cargoxx-dev";
pkgs.ninja version = "0.1.0";
pkgs.cmake nativeBuildInputs = [ pkgs.ninja pkgs.cmake ];
]; buildInputs = [ pkgs.reproc pkgs.sqlite pkgs.catch2_3 ];
buildInputs = [ hardeningDisable = [ "all" ];
pkgs.reproc
pkgs.sqlite
pkgs.catch2_3
];
hardeningDisable = [
"all"
];
}; };
}); });
} }

View File

@@ -20,7 +20,13 @@ auto cmd_new(const std::string& name, bool lib_only,
// `overlay_path` lets tests redirect the linkdb overlay away from ~/.cache. // `overlay_path` lets tests redirect the linkdb overlay away from ~/.cache.
auto cmd_build(const std::filesystem::path& project_root, bool no_build, bool release, auto cmd_build(const std::filesystem::path& project_root, bool no_build, bool release,
std::optional<std::string> target = std::nullopt, std::optional<std::string> target = std::nullopt,
std::optional<std::filesystem::path> overlay_path = std::nullopt) std::optional<std::filesystem::path> overlay_path = std::nullopt,
bool offline = false,
std::optional<std::filesystem::path> vendor = std::nullopt)
-> util::Result<void>;
auto cmd_vendor(const std::filesystem::path& project_root,
const std::filesystem::path& output)
-> util::Result<void>; -> util::Result<void>;
// Builds the project, picks a binary target, and execs it with `args`. // Builds the project, picks a binary target, and execs it with `args`.

View File

@@ -35,6 +35,34 @@ auto write_text(const fs::path& path, std::string_view content) -> util::Result<
return {}; return {};
} }
auto query_flake_rev(std::string_view flake_ref) -> std::optional<std::string> {
auto r = exec::run("nix",
{"--extra-experimental-features",
"nix-command flakes", "flake", "metadata", "--json",
std::string{flake_ref}},
exec::ExecOptions{
.cwd = fs::current_path(),
.env_overrides = {},
.timeout = std::chrono::seconds{30},
.inherit_stdio = false,
});
if (!r || r->exit_code != 0) {
return std::nullopt;
}
std::string_view body = r->stdout_text;
constexpr std::string_view key = "\"rev\":\"";
auto pos = body.find(key);
if (pos == std::string_view::npos) {
return std::nullopt;
}
pos += key.size();
auto end = body.find('"', pos);
if (end == std::string_view::npos) {
return std::nullopt;
}
return std::string{body.substr(pos, end - pos)};
}
// Builds the lockfile from the manifest + resolved recipes, **preserving** // Builds the lockfile from the manifest + resolved recipes, **preserving**
// `nixpkgs_rev` for any (name, version) entry that already exists in // `nixpkgs_rev` for any (name, version) entry that already exists in
// `prior` with a matching key. This is what makes `cargoxx build` // `prior` with a matching key. This is what makes `cargoxx build`
@@ -59,6 +87,13 @@ auto merge_lockfile(const manifest::Manifest& m,
lockfile::Lockfile lock; lockfile::Lockfile lock;
lock.version = 1; lock.version = 1;
lock.nixpkgs_rev_pin = prior.nixpkgs_rev_pin.has_value()
? prior.nixpkgs_rev_pin
: query_flake_rev("github:NixOS/nixpkgs/nixos-unstable");
lock.flake_utils_rev_pin =
prior.flake_utils_rev_pin.has_value()
? prior.flake_utils_rev_pin
: query_flake_rev("github:numtide/flake-utils");
lockfile::LockfilePackage root{ lockfile::LockfilePackage root{
.name = m.package.name, .name = m.package.name,
@@ -92,6 +127,11 @@ auto merge_lockfile(const manifest::Manifest& m,
.nixpkgs_attr = std::move(attr), .nixpkgs_attr = std::move(attr),
.nixpkgs_rev = std::move(rev), .nixpkgs_rev = std::move(rev),
.linkdb_source = rec.source, .linkdb_source = rec.source,
.find_package = rec.find_package,
.targets = rec.targets,
.pkg_config_module = rec.pkg_config_module,
.brute_force_libs = rec.brute_force_libs,
.brute_force_includes = rec.brute_force_includes,
}); });
}; };
for (std::size_t i = 0; i < m.dependencies.size(); ++i) { for (std::size_t i = 0; i < m.dependencies.size(); ++i) {
@@ -109,7 +149,9 @@ namespace {
auto run_nix_cmake(const fs::path& project_root, const std::vector<std::string>& cmake_args, auto run_nix_cmake(const fs::path& project_root, const std::vector<std::string>& cmake_args,
std::string_view phase) -> util::Result<void> { std::string_view phase) -> util::Result<void> {
std::vector<std::string> args{"develop", "--command", "cmake"}; std::vector<std::string> args{"--extra-experimental-features",
"nix-command flakes", "develop",
"path:./build", "--command", "cmake"};
args.insert(args.end(), cmake_args.begin(), cmake_args.end()); args.insert(args.end(), cmake_args.begin(), cmake_args.end());
auto r = exec::run("nix", args, exec::ExecOptions{ auto r = exec::run("nix", args, exec::ExecOptions{
@@ -135,7 +177,8 @@ auto run_nix_cmake(const fs::path& project_root, const std::vector<std::string>&
auto cmd_build(const fs::path& project_root, bool no_build, bool release, auto cmd_build(const fs::path& project_root, bool no_build, bool release,
std::optional<std::string> target, std::optional<std::string> target,
std::optional<fs::path> overlay_path) -> util::Result<void> { std::optional<fs::path> overlay_path, bool offline,
std::optional<fs::path> vendor) -> util::Result<void> {
auto manifest_path = project_root / "Cargoxx.toml"; auto manifest_path = project_root / "Cargoxx.toml";
auto m = manifest::parse(manifest_path); auto m = manifest::parse(manifest_path);
if (!m) { if (!m) {
@@ -176,11 +219,45 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
return {}; return {};
}; };
lockfile::Lockfile prior;
if (std::error_code ec; std::filesystem::exists(project_root / "Cargoxx.lock", ec)) {
if (auto r = lockfile::parse(project_root / "Cargoxx.lock"); r) {
prior = std::move(*r);
}
}
auto recipe_from_lock = [&](const std::string& name, const std::string& version)
-> std::optional<linkdb::Recipe> {
for (const auto& p : prior.packages) {
if (p.name != name || p.version != version) {
continue;
}
if (!p.find_package || p.targets.empty()) {
if (p.brute_force_libs.empty() && p.brute_force_includes.empty()) {
return std::nullopt;
}
}
return linkdb::Recipe{
.nixpkgs_attr = p.nixpkgs_attr.value_or(""),
.find_package = p.find_package.value_or(""),
.targets = p.targets,
.source = p.linkdb_source.value_or(""),
.pkg_config_module = p.pkg_config_module,
.brute_force_libs = p.brute_force_libs,
.brute_force_includes = p.brute_force_includes,
};
}
return std::nullopt;
};
auto resolve_list = [&](const std::vector<manifest::Dependency>& deps) auto resolve_list = [&](const std::vector<manifest::Dependency>& deps)
-> util::Result<std::vector<linkdb::Recipe>> { -> util::Result<std::vector<linkdb::Recipe>> {
std::vector<linkdb::Recipe> out; std::vector<linkdb::Recipe> out;
out.reserve(deps.size()); out.reserve(deps.size());
for (const auto& dep : deps) { for (const auto& dep : deps) {
if (auto cached = recipe_from_lock(dep.name, dep.version_spec); cached) {
out.push_back(std::move(*cached));
continue;
}
auto r = db->resolve(dep.name, dep.version_spec, dep.components); auto r = db->resolve(dep.name, dep.version_spec, dep.components);
if (!r && r.error().code == util::ErrorCode::LinkdbUnknownPackage) { if (!r && r.error().code == util::ErrorCode::LinkdbUnknownPackage) {
if (auto resolved = auto_resolve(dep.name, dep.version_spec, if (auto resolved = auto_resolve(dep.name, dep.version_spec,
@@ -205,15 +282,26 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
if (!dev_recipes) { if (!dev_recipes) {
return std::unexpected(dev_recipes.error()); return std::unexpected(dev_recipes.error());
} }
lockfile::Lockfile prior;
if (std::error_code ec; std::filesystem::exists(project_root / "Cargoxx.lock", ec)) {
if (auto r = lockfile::parse(project_root / "Cargoxx.lock"); r) {
prior = std::move(*r);
}
}
auto lock = merge_lockfile(*m, *recipes, *dev_recipes, prior); auto lock = merge_lockfile(*m, *recipes, *dev_recipes, prior);
std::optional<codegen::VendorIndex> vendor_index;
if (offline) {
auto vendor_path = vendor.value_or(project_root / "vendor.toml");
if (std::error_code v_ec; !fs::exists(vendor_path, v_ec)) {
return std::unexpected(io_error(
std::format("--offline requires vendor.toml; expected at '{}'",
vendor_path.string()),
vendor_path));
}
std::ifstream in_file{vendor_path};
std::string body{std::istreambuf_iterator<char>(in_file), {}};
auto parsed = codegen::parse_vendor_toml(body);
if (!parsed) {
return std::unexpected(parsed.error());
}
vendor_index = std::move(*parsed);
}
codegen::GenerateInputs in{ codegen::GenerateInputs in{
.manifest = *m, .manifest = *m,
.layout = *layout_result, .layout = *layout_result,
@@ -221,6 +309,7 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
.recipes = *recipes, .recipes = *recipes,
.dev_recipes = *dev_recipes, .dev_recipes = *dev_recipes,
.project_root = project_root, .project_root = project_root,
.vendor = vendor_index,
}; };
auto flake_text = codegen::flake_nix(in); auto flake_text = codegen::flake_nix(in);
auto cmake_text = codegen::cmake_lists(in); auto cmake_text = codegen::cmake_lists(in);
@@ -233,7 +322,7 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
project_root / "build")); project_root / "build"));
} }
if (auto r = write_text(project_root / "flake.nix", flake_text); !r) { if (auto r = write_text(project_root / "build" / "flake.nix", flake_text); !r) {
return std::unexpected(r.error()); return std::unexpected(r.error());
} }
if (auto r = write_text(project_root / "build" / "CMakeLists.txt", cmake_text); !r) { if (auto r = write_text(project_root / "build" / "CMakeLists.txt", cmake_text); !r) {
@@ -257,16 +346,41 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
"-G", "Ninja", "-G", "Ninja",
std::format("-DCMAKE_BUILD_TYPE={}", profile_cap), std::format("-DCMAKE_BUILD_TYPE={}", profile_cap),
}; };
if (auto r = run_nix_cmake(project_root, configure_args, "configure"); !r) {
return std::unexpected(r.error());
}
std::vector<std::string> build_args{"--build", build_dir}; std::vector<std::string> build_args{"--build", build_dir};
if (target) { if (target) {
build_args.push_back("--target"); build_args.push_back("--target");
build_args.push_back(*target); build_args.push_back(*target);
} }
if (auto r = run_nix_cmake(project_root, build_args, "build"); !r) {
auto run_cmake = [&](const std::vector<std::string>& args,
std::string_view phase) -> util::Result<void> {
if (offline) {
auto r = exec::run("cmake", args,
exec::ExecOptions{
.cwd = project_root,
.env_overrides = {},
.timeout = std::nullopt,
.inherit_stdio = true,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code != 0) {
return std::unexpected(util::Error{
util::ErrorCode::BuildCmakeFailed,
std::format("cmake {} failed (exit {})", phase, r->exit_code),
"", std::nullopt, std::nullopt,
});
}
return {};
}
return run_nix_cmake(project_root, args, phase);
};
if (auto r = run_cmake(configure_args, "configure"); !r) {
return std::unexpected(r.error());
}
if (auto r = run_cmake(build_args, "build"); !r) {
return std::unexpected(r.error()); return std::unexpected(r.error());
} }

View File

@@ -19,8 +19,9 @@ auto cmd_test(const fs::path& project_root, bool release,
const auto build_dir = std::format("build/{}", profile); const auto build_dir = std::format("build/{}", profile);
auto r = exec::run("nix", auto r = exec::run("nix",
{"develop", "--command", "ctest", "--test-dir", build_dir, {"--extra-experimental-features", "nix-command flakes",
"--output-on-failure"}, "develop", "path:./build", "--command", "ctest",
"--test-dir", build_dir, "--output-on-failure"},
exec::ExecOptions{ exec::ExecOptions{
.cwd = project_root, .cwd = project_root,
.env_overrides = {}, .env_overrides = {},

128
src/cli/cmd_vendor.cpp Normal file
View File

@@ -0,0 +1,128 @@
module cargoxx.cli;
import std;
import cargoxx.lockfile;
import cargoxx.resolver;
import cargoxx.util;
namespace cargoxx::cli {
namespace fs = std::filesystem;
namespace {
auto error(util::ErrorCode code, std::string msg, fs::path path) -> util::Error {
return util::Error{code, std::move(msg), "", std::move(path), std::nullopt};
}
auto escape_toml(std::string_view s) -> std::string {
std::string out;
out.reserve(s.size() + 2);
out += '"';
for (char c : s) {
if (c == '"' || c == '\\') {
out += '\\';
}
out += c;
}
out += '"';
return out;
}
} // namespace
auto cmd_vendor(const fs::path& project_root, const fs::path& output)
-> util::Result<void> {
auto lock_path = project_root / "Cargoxx.lock";
auto lock = lockfile::parse(lock_path);
if (!lock) {
return std::unexpected(lock.error());
}
if (!lock->nixpkgs_rev_pin || lock->nixpkgs_rev_pin->empty()) {
return std::unexpected(error(
util::ErrorCode::ManifestInvalidField,
"Cargoxx.lock has no top-level nixpkgs_rev — run cargoxx build "
"online first to pin it",
lock_path));
}
if (!lock->flake_utils_rev_pin || lock->flake_utils_rev_pin->empty()) {
return std::unexpected(error(
util::ErrorCode::ManifestInvalidField,
"Cargoxx.lock has no top-level flake_utils_rev",
lock_path));
}
auto nixpkgs_src = resolver::realize_flake_source(
std::format("github:NixOS/nixpkgs/{}", *lock->nixpkgs_rev_pin));
if (!nixpkgs_src) {
return std::unexpected(nixpkgs_src.error());
}
auto flake_utils_src = resolver::realize_flake_source(
std::format("github:numtide/flake-utils/{}", *lock->flake_utils_rev_pin));
if (!flake_utils_src) {
return std::unexpected(flake_utils_src.error());
}
std::string body;
body += "schema = 1\n\n";
body += "[nixpkgs]\n";
body += std::format("rev = {}\n", escape_toml(*lock->nixpkgs_rev_pin));
body += std::format("store_path = {}\n\n", escape_toml(*nixpkgs_src));
body += "[flake_utils]\n";
body += std::format("rev = {}\n", escape_toml(*lock->flake_utils_rev_pin));
body += std::format("store_path = {}\n", escape_toml(*flake_utils_src));
for (const auto& p : lock->packages) {
if (!p.linkdb_source) {
continue;
}
if (!p.nixpkgs_attr) {
return std::unexpected(error(
util::ErrorCode::ManifestInvalidField,
std::format("lockfile dep '{}' has no nixpkgs_attr", p.name),
lock_path));
}
auto rev = p.nixpkgs_rev.has_value() && !p.nixpkgs_rev->empty()
? *p.nixpkgs_rev
: *lock->nixpkgs_rev_pin;
auto store = resolver::realize_path_at_rev(rev, *p.nixpkgs_attr);
if (!store) {
return std::unexpected(store.error());
}
std::optional<std::string> dev_path;
if (auto d = resolver::realize_path_at_rev(
rev, std::format("{}.dev", *p.nixpkgs_attr));
d) {
dev_path = *d;
}
body += "\n[[dep]]\n";
body += std::format("name = {}\n", escape_toml(p.name));
body += std::format("nixpkgs_attr = {}\n", escape_toml(*p.nixpkgs_attr));
body += std::format("nixpkgs_rev = {}\n", escape_toml(rev));
body += std::format("store_path = {}\n", escape_toml(*store));
if (dev_path) {
body += std::format("dev_path = {}\n", escape_toml(*dev_path));
}
}
std::error_code ec;
fs::create_directories(output.parent_path(), ec);
std::ofstream out{output};
if (!out) {
return std::unexpected(error(
util::ErrorCode::Internal,
std::format("cannot open vendor file for writing: {}",
output.string()),
output));
}
out << body;
if (!out) {
return std::unexpected(error(
util::ErrorCode::Internal,
std::format("write failed: {}", output.string()), output));
}
return {};
}
} // namespace cargoxx::cli

View File

@@ -23,13 +23,26 @@ auto run(int argc, char** argv) -> int {
"build", "Generate flake.nix and build/CMakeLists.txt; build with nix+cmake"); "build", "Generate flake.nix and build/CMakeLists.txt; build with nix+cmake");
bool build_no_build = false; bool build_no_build = false;
bool build_release = false; bool build_release = false;
bool build_offline = false;
std::string build_target; std::string build_target;
std::string build_vendor;
build_cmd->add_flag("--no-build", build_no_build, build_cmd->add_flag("--no-build", build_no_build,
"Generate files only; do not invoke nix/cmake"); "Generate files only; do not invoke nix/cmake");
build_cmd->add_flag("--release", build_release, "Build the release profile"); build_cmd->add_flag("--release", build_release, "Build the release profile");
build_cmd->add_flag("--offline", build_offline,
"Skip network probes and nix develop wrappers. "
"Reads vendor.toml for store-path inputs.");
build_cmd->add_option("--vendor", build_vendor,
"Path to vendor.toml (used with --offline; default ./vendor.toml)");
build_cmd->add_option("--target", build_target, build_cmd->add_option("--target", build_target,
"Build a specific target (passed to cmake --build)"); "Build a specific target (passed to cmake --build)");
auto* vendor_cmd = app.add_subcommand(
"vendor", "Resolve every locked dependency into /nix/store and write vendor.toml");
std::string vendor_output;
vendor_cmd->add_option("--output", vendor_output,
"Path to write vendor.toml (default ./vendor.toml)");
auto* run_cmd = app.add_subcommand("run", "Build and run a binary target"); auto* run_cmd = app.add_subcommand("run", "Build and run a binary target");
bool run_release = false; bool run_release = false;
std::string run_bin; std::string run_bin;
@@ -109,19 +122,36 @@ auto run(int argc, char** argv) -> int {
if (!build_target.empty()) { if (!build_target.empty()) {
target = build_target; target = build_target;
} }
auto r = cmd_build(cwd, build_no_build, build_release, target); std::optional<std::filesystem::path> vendor_path;
if (!build_vendor.empty()) {
vendor_path = build_vendor;
}
auto r = cmd_build(cwd, build_no_build, build_release, target,
std::nullopt, build_offline, vendor_path);
if (!r) { if (!r) {
std::cerr << util::format(r.error()); std::cerr << util::format(r.error());
return 1; return 1;
} }
if (build_no_build) { if (build_no_build) {
std::cout << " Generated flake.nix, build/CMakeLists.txt, Cargoxx.lock\n"; std::cout << " Generated build/flake.nix, build/CMakeLists.txt, Cargoxx.lock\n";
} else { } else {
std::cout << " Built\n"; std::cout << " Built\n";
} }
return 0; return 0;
} }
if (*vendor_cmd) {
auto out = vendor_output.empty() ? cwd / "vendor.toml"
: std::filesystem::path{vendor_output};
auto r = cmd_vendor(cwd, out);
if (!r) {
std::cerr << util::format(r.error());
return 1;
}
std::cout << std::format(" Wrote {}\n", out.string());
return 0;
}
if (*run_cmd) { if (*run_cmd) {
std::optional<std::string> bin; std::optional<std::string> bin;
if (!run_bin.empty()) { if (!run_bin.empty()) {

View File

@@ -75,7 +75,8 @@ auto emit_header(const manifest::Manifest& m) -> std::string {
"set(CMAKE_CXX_SCAN_FOR_MODULES ON)\n" "set(CMAKE_CXX_SCAN_FOR_MODULES ON)\n"
"set(CMAKE_EXPORT_COMPILE_COMMANDS ON)\n" "set(CMAKE_EXPORT_COMPILE_COMMANDS ON)\n"
"\n" "\n"
"add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)\n", "add_compile_options(-Wall -Wextra -Wpedantic -Wconversion "
"-Wno-missing-field-initializers)\n",
m.package.name, edition_to_int(m.package.edition)); m.package.name, edition_to_int(m.package.edition));
} }

View File

@@ -9,6 +9,16 @@ import cargoxx.lockfile;
export namespace cargoxx::codegen { export namespace cargoxx::codegen {
// When set, codegen emits the generated flake's nixpkgs / flake-utils
// inputs as `path:/nix/store/...` references (already-realized source
// paths) instead of `github:NixOS/nixpkgs/<rev>` URLs. This makes the
// inner build hermetic — no network and no nix daemon access required.
struct VendorIndex {
std::string nixpkgs_store_path;
std::string flake_utils_store_path;
std::unordered_map<std::string, std::string> dep_store_paths; // by attr
};
// All inputs the generators need. Held by const reference; the caller owns // All inputs the generators need. Held by const reference; the caller owns
// the underlying objects. Not copyable. // the underlying objects. Not copyable.
struct GenerateInputs { struct GenerateInputs {
@@ -18,9 +28,14 @@ struct GenerateInputs {
std::vector<linkdb::Recipe> recipes; // one per manifest dep, same order std::vector<linkdb::Recipe> recipes; // one per manifest dep, same order
std::vector<linkdb::Recipe> dev_recipes; // one per dev_dependency, same order std::vector<linkdb::Recipe> dev_recipes; // one per dev_dependency, same order
std::filesystem::path project_root; std::filesystem::path project_root;
std::optional<VendorIndex> vendor;
}; };
auto flake_nix(const GenerateInputs& in) -> std::string; auto flake_nix(const GenerateInputs& in) -> std::string;
auto cmake_lists(const GenerateInputs& in) -> std::string; auto cmake_lists(const GenerateInputs& in) -> std::string;
// Pure: parses a vendor.toml (see cmd_vendor) into a VendorIndex.
auto parse_vendor_toml(std::string_view body)
-> util::Result<VendorIndex>;
} // namespace cargoxx::codegen } // namespace cargoxx::codegen

View File

@@ -99,20 +99,40 @@ auto pinned_inputs_dedup(const std::vector<DepBinding>& bindings)
return out; return out;
} }
auto emit_inputs_block(const std::vector<const DepBinding*>& pinned) auto emit_inputs_block(const std::vector<const DepBinding*>& pinned,
const lockfile::Lockfile& lock,
const std::optional<VendorIndex>& vendor)
-> std::string { -> std::string {
// Always emit the shared toolchain `nixpkgs` and `flake-utils` auto nixpkgs_url = [&]() -> std::string {
// inputs. Per-pinned-dep inputs land between them so the output if (vendor && !vendor->nixpkgs_store_path.empty()) {
// diff stays stable across reruns. return std::format("path:{}", vendor->nixpkgs_store_path);
}
if (lock.nixpkgs_rev_pin && !lock.nixpkgs_rev_pin->empty()) {
return std::format("github:NixOS/nixpkgs/{}", *lock.nixpkgs_rev_pin);
}
return "github:NixOS/nixpkgs/nixos-unstable";
}();
auto flake_utils_url = [&]() -> std::string {
if (vendor && !vendor->flake_utils_store_path.empty()) {
return std::format("path:{}", vendor->flake_utils_store_path);
}
if (lock.flake_utils_rev_pin && !lock.flake_utils_rev_pin->empty()) {
return std::format("github:numtide/flake-utils/{}",
*lock.flake_utils_rev_pin);
}
return "github:numtide/flake-utils";
}();
std::string out = std::string out =
" inputs = {\n" " inputs = {\n"
" nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n"; + std::format(" nixpkgs.url = \"{}\";\n", nixpkgs_url);
if (!vendor) {
for (const auto* b : pinned) { for (const auto* b : pinned) {
out += std::format(" {}.url = \"github:NixOS/nixpkgs/{}\";\n", out += std::format(" {}.url = \"github:NixOS/nixpkgs/{}\";\n",
b->sanitized, *b->rev); b->sanitized, *b->rev);
} }
out += " flake-utils.url = \"github:numtide/flake-utils\";\n" }
" };\n"; out += std::format(" flake-utils.url = \"{}\";\n", flake_utils_url);
out += " };\n";
return out; return out;
} }
@@ -165,7 +185,7 @@ auto flake_nix(const GenerateInputs& in) -> std::string {
out += "{\n"; out += "{\n";
out += std::format(" description = \"{}\";\n\n", in.manifest.package.name); out += std::format(" description = \"{}\";\n\n", in.manifest.package.name);
out += emit_inputs_block(pinned); out += emit_inputs_block(pinned, in.lock, in.vendor);
const bool any_pkg_config = const bool any_pkg_config =
std::ranges::any_of(in.recipes, std::ranges::any_of(in.recipes,

69
src/codegen/vendor.cpp Normal file
View File

@@ -0,0 +1,69 @@
module;
#include <toml.hpp>
module cargoxx.codegen;
import std;
import cargoxx.util;
namespace cargoxx::codegen {
auto parse_vendor_toml(std::string_view body) -> util::Result<VendorIndex> {
toml::table root;
try {
root = toml::parse(body);
} catch (const toml::parse_error& e) {
return std::unexpected(util::Error{
util::ErrorCode::ManifestParseError,
std::format("vendor.toml is not valid TOML: {}", e.description()),
"", std::nullopt, std::nullopt,
});
}
auto missing = [](std::string msg) {
return util::Error{
util::ErrorCode::ManifestInvalidField, std::move(msg), "",
std::nullopt, std::nullopt,
};
};
VendorIndex out;
if (const auto* tbl = root["nixpkgs"].as_table()) {
if (auto v = (*tbl)["store_path"].value<std::string>()) {
out.nixpkgs_store_path = *v;
} else {
return std::unexpected(missing("vendor.toml: [nixpkgs].store_path is required"));
}
} else {
return std::unexpected(missing("vendor.toml: [nixpkgs] table is required"));
}
if (const auto* tbl = root["flake_utils"].as_table()) {
if (auto v = (*tbl)["store_path"].value<std::string>()) {
out.flake_utils_store_path = *v;
} else {
return std::unexpected(missing("vendor.toml: [flake_utils].store_path is required"));
}
} else {
return std::unexpected(missing("vendor.toml: [flake_utils] table is required"));
}
if (const auto* arr = root["dep"].as_array()) {
for (const auto& el : *arr) {
const auto* tbl = el.as_table();
if (!tbl) {
return std::unexpected(missing("vendor.toml: [[dep]] entries must be tables"));
}
auto attr = (*tbl)["nixpkgs_attr"].value<std::string>();
auto path = (*tbl)["store_path"].value<std::string>();
if (!attr || !path) {
return std::unexpected(missing(
"vendor.toml: each [[dep]] needs nixpkgs_attr and store_path"));
}
out.dep_store_paths.emplace(*attr, *path);
}
}
return out;
}
} // namespace cargoxx::codegen

View File

@@ -79,6 +79,33 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path)
if (auto v = tbl["linkdb_source"].value<std::string>()) { if (auto v = tbl["linkdb_source"].value<std::string>()) {
pkg.linkdb_source = *v; pkg.linkdb_source = *v;
} }
if (auto v = tbl["find_package"].value<std::string>()) {
pkg.find_package = *v;
}
if (const auto* arr = tbl["targets"].as_array()) {
auto r = extract_string_array(*arr, "targets", path);
if (!r) {
return std::unexpected(r.error());
}
pkg.targets = std::move(*r);
}
if (auto v = tbl["pkg_config_module"].value<std::string>()) {
pkg.pkg_config_module = *v;
}
if (const auto* arr = tbl["brute_force_libs"].as_array()) {
auto r = extract_string_array(*arr, "brute_force_libs", path);
if (!r) {
return std::unexpected(r.error());
}
pkg.brute_force_libs = std::move(*r);
}
if (const auto* arr = tbl["brute_force_includes"].as_array()) {
auto r = extract_string_array(*arr, "brute_force_includes", path);
if (!r) {
return std::unexpected(r.error());
}
pkg.brute_force_includes = std::move(*r);
}
return pkg; return pkg;
} }
@@ -104,6 +131,12 @@ auto parse(const std::filesystem::path& path) -> util::Result<Lockfile> {
if (auto v = root["version"].value<int>()) { if (auto v = root["version"].value<int>()) {
lock.version = *v; lock.version = *v;
} }
if (auto v = root["nixpkgs_rev"].value<std::string>()) {
lock.nixpkgs_rev_pin = *v;
}
if (auto v = root["flake_utils_rev"].value<std::string>()) {
lock.flake_utils_rev_pin = *v;
}
if (const auto* arr = root["package"].as_array()) { if (const auto* arr = root["package"].as_array()) {
lock.packages.reserve(arr->size()); lock.packages.reserve(arr->size());
@@ -127,6 +160,12 @@ auto parse(const std::filesystem::path& path) -> util::Result<Lockfile> {
auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Result<void> { auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Result<void> {
toml::table root; toml::table root;
root.insert_or_assign("version", lock.version); root.insert_or_assign("version", lock.version);
if (lock.nixpkgs_rev_pin) {
root.insert_or_assign("nixpkgs_rev", *lock.nixpkgs_rev_pin);
}
if (lock.flake_utils_rev_pin) {
root.insert_or_assign("flake_utils_rev", *lock.flake_utils_rev_pin);
}
toml::array packages; toml::array packages;
for (const auto& p : lock.packages) { for (const auto& p : lock.packages) {
@@ -149,6 +188,33 @@ auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Res
if (p.linkdb_source) { if (p.linkdb_source) {
tbl.insert_or_assign("linkdb_source", *p.linkdb_source); tbl.insert_or_assign("linkdb_source", *p.linkdb_source);
} }
if (p.find_package) {
tbl.insert_or_assign("find_package", *p.find_package);
}
if (!p.targets.empty()) {
toml::array arr;
for (const auto& t : p.targets) {
arr.push_back(t);
}
tbl.insert_or_assign("targets", std::move(arr));
}
if (p.pkg_config_module) {
tbl.insert_or_assign("pkg_config_module", *p.pkg_config_module);
}
if (!p.brute_force_libs.empty()) {
toml::array arr;
for (const auto& l : p.brute_force_libs) {
arr.push_back(l);
}
tbl.insert_or_assign("brute_force_libs", std::move(arr));
}
if (!p.brute_force_includes.empty()) {
toml::array arr;
for (const auto& i : p.brute_force_includes) {
arr.push_back(i);
}
tbl.insert_or_assign("brute_force_includes", std::move(arr));
}
packages.push_back(std::move(tbl)); packages.push_back(std::move(tbl));
} }
root.insert_or_assign("package", std::move(packages)); root.insert_or_assign("package", std::move(packages));

View File

@@ -9,22 +9,27 @@ export namespace cargoxx::lockfile {
struct LockfilePackage { struct LockfilePackage {
std::string name; std::string name;
std::string version; std::string version;
std::vector<std::string> dependencies; // "<name> <version>" entries; non-empty for the root std::vector<std::string> dependencies;
std::optional<std::string> nixpkgs_attr; std::optional<std::string> nixpkgs_attr;
std::optional<std::string> nixpkgs_rev; std::optional<std::string> nixpkgs_rev;
std::optional<std::string> linkdb_source; std::optional<std::string> linkdb_source;
std::optional<std::string> find_package;
std::vector<std::string> targets;
std::optional<std::string> pkg_config_module;
std::vector<std::string> brute_force_libs;
std::vector<std::string> brute_force_includes;
bool operator==(const LockfilePackage&) const = default; bool operator==(const LockfilePackage&) const = default;
}; };
struct Lockfile { struct Lockfile {
int version = 1; int version = 1;
std::optional<std::string> nixpkgs_rev_pin;
std::optional<std::string> flake_utils_rev_pin;
std::vector<LockfilePackage> packages; std::vector<LockfilePackage> packages;
bool operator==(const Lockfile&) const = default; bool operator==(const Lockfile&) const = default;
// The nixpkgs revision is shared across every dep package per SPEC §5.
// Returns the first non-empty rev seen, or nullopt if no deps are pinned.
[[nodiscard]] auto nixpkgs_rev() const -> std::optional<std::string>; [[nodiscard]] auto nixpkgs_rev() const -> std::optional<std::string>;
}; };

View File

@@ -31,8 +31,8 @@ auto make_error(util::ErrorCode code, std::string msg) -> util::Error {
// nix eval emits these markers when an attribute is missing on the flake. // nix eval emits these markers when an attribute is missing on the flake.
auto looks_like_missing_attribute(std::string_view stderr_text) -> bool { auto looks_like_missing_attribute(std::string_view stderr_text) -> bool {
return stderr_text.find("does not provide attribute") != std::string_view::npos || return stderr_text.find("does not provide attribute") != std::string_view::npos ||
stderr_text.find("attribute '") != std::string_view::npos && (stderr_text.find("attribute '") != std::string_view::npos &&
stderr_text.find("missing") != std::string_view::npos; stderr_text.find("missing") != std::string_view::npos);
} }
} // namespace } // namespace
@@ -162,4 +162,95 @@ auto realize_path(const std::string& flake_attr) -> util::Result<std::string> {
return path; return path;
} }
auto realize_path_at_rev(const std::string& rev, const std::string& attr)
-> util::Result<std::string> {
if (rev.empty() || attr.empty()) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionUnknownPackage,
"realize_path_at_rev: rev and attr must be non-empty"));
}
std::vector<std::string> args{
"--extra-experimental-features", "nix-command flakes",
"build", std::format("github:NixOS/nixpkgs/{}#{}", rev, attr),
"--no-link", "--print-out-paths",
};
auto r = exec::run("nix", args,
exec::ExecOptions{
.cwd = {},
.env_overrides = {},
.timeout = std::chrono::seconds{600},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code != 0) {
if (looks_like_missing_attribute(r->stderr_text)) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionUnknownPackage,
std::format("nixpkgs/{} has no attribute '{}'", rev, attr)));
}
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix build failed (exit {}): {}", r->exit_code,
r->stderr_text)));
}
auto path = r->stdout_text;
while (!path.empty() && (path.back() == '\n' || path.back() == ' ')) {
path.pop_back();
}
if (path.empty()) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix build emitted no path for '{}#{}'", rev, attr)));
}
return path;
}
auto realize_flake_source(const std::string& flake_ref)
-> util::Result<std::string> {
if (flake_ref.empty()) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionUnknownPackage,
"realize_flake_source: flake_ref is empty"));
}
std::vector<std::string> args{
"--extra-experimental-features", "nix-command flakes",
"flake", "prefetch", flake_ref, "--json",
};
auto r = exec::run("nix", args,
exec::ExecOptions{
.cwd = {},
.env_overrides = {},
.timeout = std::chrono::seconds{300},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code != 0) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix flake prefetch failed (exit {}): {}",
r->exit_code, r->stderr_text)));
}
std::string_view body = r->stdout_text;
constexpr std::string_view key = "\"storePath\":\"";
auto pos = body.find(key);
if (pos == std::string_view::npos) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix flake prefetch emitted no storePath for '{}'",
flake_ref)));
}
pos += key.size();
auto end = body.find('"', pos);
if (end == std::string_view::npos) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
"nix flake prefetch JSON malformed"));
}
return std::string{body.substr(pos, end - pos)};
}
} // namespace cargoxx::resolver } // namespace cargoxx::resolver

View File

@@ -45,6 +45,20 @@ auto nixpkgs_probe(const std::string& attr) -> util::Result<NixpkgsInfo>;
// for build / network errors. // for build / network errors.
auto realize_path(const std::string& flake_attr) -> util::Result<std::string>; auto realize_path(const std::string& flake_attr) -> util::Result<std::string>;
// Like `realize_path`, but pins the nixpkgs revision instead of using the
// registry alias. Builds `github:NixOS/nixpkgs/<rev>#<attr>` and returns
// the resulting `/nix/store/...` path. Used by the vendor subcommand to
// materialize each lockfile-pinned dep without depending on the user's
// flake registry.
auto realize_path_at_rev(const std::string& rev, const std::string& attr)
-> util::Result<std::string>;
// Returns the source store path for `github:NixOS/nixpkgs/<rev>` (the
// path Nix would set as `nixpkgs.outPath` when imported). Used by the
// vendor subcommand to record nixpkgs/flake-utils source locations.
auto realize_flake_source(const std::string& flake_ref)
-> util::Result<std::string>;
// One CMake config-file's IMPORTED targets together with the find_package // One CMake config-file's IMPORTED targets together with the find_package
// expression derived from its filename stem. // expression derived from its filename stem.
struct NixCmakeCandidate { struct NixCmakeCandidate {

View File

@@ -64,7 +64,7 @@ TEST_CASE("cmd_build generates files for a no-deps binary project",
auto r = cmd_build(root, true, false, std::nullopt, overlay_path(parent)); auto r = cmd_build(root, true, false, std::nullopt, overlay_path(parent));
REQUIRE(r.has_value()); REQUIRE(r.has_value());
REQUIRE(std::filesystem::exists(root / "flake.nix")); REQUIRE(std::filesystem::exists(root / "build" / "flake.nix"));
REQUIRE(std::filesystem::exists(root / "build" / "CMakeLists.txt")); REQUIRE(std::filesystem::exists(root / "build" / "CMakeLists.txt"));
REQUIRE(std::filesystem::exists(root / "Cargoxx.lock")); REQUIRE(std::filesystem::exists(root / "Cargoxx.lock"));
@@ -73,9 +73,9 @@ TEST_CASE("cmd_build generates files for a no-deps binary project",
REQUIRE(cmake_text.find("add_executable(hello_bin ../src/main.cpp)") != REQUIRE(cmake_text.find("add_executable(hello_bin ../src/main.cpp)") !=
std::string::npos); std::string::npos);
auto flake_text = read_file(root / "flake.nix"); auto flake_text = read_file(root / "build" / "flake.nix");
REQUIRE(flake_text.find("description = \"hello\";") != std::string::npos); REQUIRE(flake_text.find("description = \"hello\";") != std::string::npos);
REQUIRE(flake_text.find("github:NixOS/nixpkgs/nixos-unstable") != std::string::npos); REQUIRE(flake_text.find("github:NixOS/nixpkgs/") != std::string::npos);
} }
TEST_CASE("cmd_build generates files for a library project", "[cli][build]") { TEST_CASE("cmd_build generates files for a library project", "[cli][build]") {
@@ -113,7 +113,7 @@ TEST_CASE("cmd_build resolves a manually-seeded dep into find_package + targets"
REQUIRE(cmake_text.find("find_package(fmt CONFIG REQUIRED)") != std::string::npos); REQUIRE(cmake_text.find("find_package(fmt CONFIG REQUIRED)") != std::string::npos);
REQUIRE(cmake_text.find("fmt::fmt") != std::string::npos); REQUIRE(cmake_text.find("fmt::fmt") != std::string::npos);
auto flake_text = read_file(root / "flake.nix"); auto flake_text = read_file(root / "build" / "flake.nix");
REQUIRE(flake_text.find("pkgs.fmt_10") != std::string::npos); REQUIRE(flake_text.find("pkgs.fmt_10") != std::string::npos);
} }
@@ -197,11 +197,11 @@ TEST_CASE("cmd_build is idempotent — second run produces identical files",
REQUIRE(cmd_build(root, true, false, std::nullopt, overlay_path(parent)).has_value()); REQUIRE(cmd_build(root, true, false, std::nullopt, overlay_path(parent)).has_value());
auto first_cmake = read_file(root / "build" / "CMakeLists.txt"); auto first_cmake = read_file(root / "build" / "CMakeLists.txt");
auto first_flake = read_file(root / "flake.nix"); auto first_flake = read_file(root / "build" / "flake.nix");
auto first_lock = read_file(root / "Cargoxx.lock"); auto first_lock = read_file(root / "Cargoxx.lock");
REQUIRE(cmd_build(root, true, false, std::nullopt, overlay_path(parent)).has_value()); REQUIRE(cmd_build(root, true, false, std::nullopt, overlay_path(parent)).has_value());
REQUIRE(read_file(root / "build" / "CMakeLists.txt") == first_cmake); REQUIRE(read_file(root / "build" / "CMakeLists.txt") == first_cmake);
REQUIRE(read_file(root / "flake.nix") == first_flake); REQUIRE(read_file(root / "build" / "flake.nix") == first_flake);
REQUIRE(read_file(root / "Cargoxx.lock") == first_lock); REQUIRE(read_file(root / "Cargoxx.lock") == first_lock);
} }

View File

@@ -290,7 +290,8 @@ TEST_CASE("cmake_lists emits baseline warnings", "[codegen][cmake]") {
GenerateInputs in{m, layout, lock, {}, {}, ROOT}; GenerateInputs in{m, layout, lock, {}, {}, ROOT};
auto out = cmake_lists(in); auto out = cmake_lists(in);
REQUIRE(out.find("add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)") != REQUIRE(out.find("add_compile_options(-Wall -Wextra -Wpedantic -Wconversion "
"-Wno-missing-field-initializers)") !=
std::string::npos); std::string::npos);
} }

View File

@@ -77,7 +77,7 @@ TEST_CASE("flake_nix adds pkgs.pkg-config to nativeBuildInputs only when needed"
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("app"), {dep("sqlite", "*")}, {}}; Manifest m{pkg("app"), {dep("sqlite", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("app", "0.1.0")}}; Lockfile lock{.version = 1, .packages = {root_pkg("app", "0.1.0")}};
std::vector<Recipe> recipes = {Recipe{ std::vector<Recipe> recipes = {Recipe{
.nixpkgs_attr = "sqlite", .nixpkgs_attr = "sqlite",
.find_package = "PkgConfig REQUIRED", .find_package = "PkgConfig REQUIRED",
@@ -96,7 +96,7 @@ TEST_CASE("flake_nix omits pkgs.pkg-config when no recipe needs it",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("hello"), {dep("fmt", "*")}, {}}; Manifest m{pkg("hello"), {dep("fmt", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; Lockfile lock{.version = 1, .packages = {root_pkg("hello", "0.1.0")}};
std::vector<Recipe> recipes = {recipe("fmt_10")}; std::vector<Recipe> recipes = {recipe("fmt_10")};
GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/hello"}; GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/hello"};
@@ -108,7 +108,7 @@ TEST_CASE("flake_nix always emits the shared nixos-unstable nixpkgs input",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("hello"), {}, {}}; Manifest m{pkg("hello"), {}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; Lockfile lock{.version = 1, .packages = {root_pkg("hello", "0.1.0")}};
GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"};
auto out = flake_nix(in); auto out = flake_nix(in);
@@ -122,7 +122,7 @@ TEST_CASE("flake_nix always emits the shared nixos-unstable nixpkgs input",
TEST_CASE("flake_nix emits a per-pinned-dep nixpkgs input", "[codegen][flake]") { TEST_CASE("flake_nix emits a per-pinned-dep nixpkgs input", "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "10.2.1")}, {}}; Manifest m{pkg("app"), {dep("fmt", "10.2.1")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("fmt", "10.2.1", "abc123def456"), dep_pkg("fmt", "10.2.1", "abc123def456"),
}}; }};
@@ -146,7 +146,7 @@ TEST_CASE("flake_nix uses shared `pkgs` for unpinned deps",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "*")}, {}}; Manifest m{pkg("app"), {dep("fmt", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("app", "0.1.0"), dep_pkg("fmt", "*", std::nullopt)}}; Lockfile lock{.version = 1, .packages = {root_pkg("app", "0.1.0"), dep_pkg("fmt", "*", std::nullopt)}};
std::vector<Recipe> recipes = {recipe("fmt_10")}; std::vector<Recipe> recipes = {recipe("fmt_10")};
GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"}; GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"};
@@ -158,7 +158,7 @@ TEST_CASE("flake_nix uses shared `pkgs` for unpinned deps",
TEST_CASE("flake_nix mixes pinned and unpinned deps", "[codegen][flake]") { TEST_CASE("flake_nix mixes pinned and unpinned deps", "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("zlib", "*")}, {}}; Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("zlib", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("fmt", "10.2.1", "abc"), dep_pkg("fmt", "10.2.1", "abc"),
dep_pkg("zlib", "*", std::nullopt), dep_pkg("zlib", "*", std::nullopt),
@@ -175,7 +175,7 @@ TEST_CASE("flake_nix emits an empty buildInputs list when there are no deps",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("hello"), {}, {}}; Manifest m{pkg("hello"), {}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; Lockfile lock{.version = 1, .packages = {root_pkg("hello", "0.1.0")}};
GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"};
auto out = flake_nix(in); auto out = flake_nix(in);
@@ -188,7 +188,7 @@ TEST_CASE("flake_nix dedupes deps that share input + attr",
{dep("boost", "1.84.0"), dep("boost", "1.84.0")}, {dep("boost", "1.84.0"), dep("boost", "1.84.0")},
{}}; {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("boost", "1.84.0", "rev42"), dep_pkg("boost", "1.84.0", "rev42"),
}}; }};
@@ -205,7 +205,7 @@ TEST_CASE("flake_nix dedupes deps that share input + attr",
TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") { TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("spdlog", "*")}, {}}; Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("spdlog", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("fmt", "10.2.1", "abc"), dep_pkg("fmt", "10.2.1", "abc"),
dep_pkg("spdlog", "*", std::nullopt), dep_pkg("spdlog", "*", std::nullopt),
@@ -219,7 +219,7 @@ TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") {
TEST_CASE("flake_nix output ends with a newline", "[codegen][flake]") { TEST_CASE("flake_nix output ends with a newline", "[codegen][flake]") {
Manifest m{pkg("hello"), {}, {}}; Manifest m{pkg("hello"), {}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; Lockfile lock{.version = 1, .packages = {root_pkg("hello", "0.1.0")}};
GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"};
auto out = flake_nix(in); auto out = flake_nix(in);
@@ -231,7 +231,7 @@ TEST_CASE("flake_nix sanitizes hyphens and dots in dep names",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("app"), {dep("range-v3", "0.12.0")}, {}}; Manifest m{pkg("app"), {dep("range-v3", "0.12.0")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("range-v3", "0.12.0", "rev123"), dep_pkg("range-v3", "0.12.0", "rev123"),
}}; }};

View File

@@ -0,0 +1,7 @@
[package]
name = "e2e_demo"
version = "0.1.0"
edition = "cpp23"
[dependencies]
nlohmann_json = "*"

View File

@@ -0,0 +1,10 @@
{
description = "e2e buildCppPackage smoke";
inputs.cargoxx.url = "path:../../..";
outputs = { self, cargoxx }: {
packages.x86_64-linux.default =
cargoxx.lib.x86_64-linux.buildCppPackage { src = ./.; };
};
}

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${here}/../../.." && pwd)"
cargoxx_bin="${CARGOXX_BIN:-${repo}/build/debug/cargoxx}"
if [[ ! -x "${cargoxx_bin}" ]]; then
echo "error: cargoxx binary not found at ${cargoxx_bin}" >&2
echo "build it first: nix develop --command cmake --build build/debug" >&2
exit 1
fi
work="$(mktemp -d -t cargoxx-e2e-XXXXXX)"
trap 'rm -rf "${work}"' EXIT
cp -r "${here}/." "${work}/"
sed -i "s|path:\\.\\./\\.\\./\\.\\.|path:${repo}|" "${work}/flake.nix"
cd "${work}"
echo "=== cargoxx build --no-build"
"${cargoxx_bin}" build --no-build
[[ -f Cargoxx.lock ]] || { echo "Cargoxx.lock missing"; exit 1; }
[[ -f build/flake.nix ]] || { echo "build/flake.nix missing"; exit 1; }
echo "=== nix build .#default"
out="$(nix build .#default --no-link --print-out-paths \
--extra-experimental-features 'nix-command flakes')"
[[ -n "${out}" ]] || { echo "nix build produced no output path"; exit 1; }
[[ -x "${out}/bin/e2e_demo" ]] || { echo "missing ${out}/bin/e2e_demo"; exit 1; }
echo "=== execute"
"${out}/bin/e2e_demo"
echo "ok"

View File

@@ -0,0 +1,9 @@
#include <nlohmann/json.hpp>
import std;
int main() {
nlohmann::json j;
j["hello"] = "world";
std::println("Hello from {}!", j["hello"].get<std::string>());
return 0;
}

View File

@@ -54,14 +54,14 @@ auto round_trip(const Lockfile& l) -> Lockfile {
} // namespace } // namespace
TEST_CASE("write round-trips a minimal lockfile", "[lockfile]") { TEST_CASE("write round-trips a minimal lockfile", "[lockfile]") {
Lockfile l{1, {root_pkg("my-project", "0.1.0")}}; Lockfile l{.version = 1, .packages = {root_pkg("my-project", "0.1.0")}};
REQUIRE(round_trip(l) == l); REQUIRE(round_trip(l) == l);
} }
TEST_CASE("write round-trips a lockfile with deps", "[lockfile]") { TEST_CASE("write round-trips a lockfile with deps", "[lockfile]") {
Lockfile l{ Lockfile l{
1, .version = 1,
{ .packages = {
root_pkg("my-project", "0.1.0", {"fmt 10.2.1", "spdlog 1.13.0"}), root_pkg("my-project", "0.1.0", {"fmt 10.2.1", "spdlog 1.13.0"}),
dep_pkg("fmt", "10.2.1", "fmt_10", "8a3f...c2d1"), dep_pkg("fmt", "10.2.1", "fmt_10", "8a3f...c2d1"),
dep_pkg("spdlog", "1.13.0", "spdlog", "8a3f...c2d1"), dep_pkg("spdlog", "1.13.0", "spdlog", "8a3f...c2d1"),
@@ -70,10 +70,58 @@ TEST_CASE("write round-trips a lockfile with deps", "[lockfile]") {
REQUIRE(round_trip(l) == l); REQUIRE(round_trip(l) == l);
} }
TEST_CASE("write round-trips lockfile recipe fields", "[lockfile]") {
Lockfile l{
.version = 1,
.packages = {
LockfilePackage{
.name = "fmt",
.version = "10.2.1",
.dependencies = {},
.nixpkgs_attr = "fmt_10",
.nixpkgs_rev = std::nullopt,
.linkdb_source = "conan",
.find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"},
.pkg_config_module = std::nullopt,
.brute_force_libs = {},
.brute_force_includes = {},
},
LockfilePackage{
.name = "sqlite",
.version = "*",
.dependencies = {},
.nixpkgs_attr = "sqlite",
.nixpkgs_rev = std::nullopt,
.linkdb_source = "pkg-config",
.find_package = "PkgConfig REQUIRED",
.targets = {"PkgConfig::SQLITE3"},
.pkg_config_module = "sqlite3",
.brute_force_libs = {},
.brute_force_includes = {},
},
LockfilePackage{
.name = "obscure",
.version = "*",
.dependencies = {},
.nixpkgs_attr = "obscure",
.nixpkgs_rev = std::nullopt,
.linkdb_source = "brute-force",
.find_package = "",
.targets = {"obscure::obscure"},
.pkg_config_module = std::nullopt,
.brute_force_libs = {"/nix/store/abc/lib/libobscure.a"},
.brute_force_includes = {"/nix/store/abc/include"},
},
},
};
REQUIRE(round_trip(l) == l);
}
TEST_CASE("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") { TEST_CASE("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") {
Lockfile l{ Lockfile l{
1, .version = 1,
{ .packages = {
root_pkg("p", "0.1.0", {"fmt 10.2.1"}), root_pkg("p", "0.1.0", {"fmt 10.2.1"}),
dep_pkg("fmt", "10.2.1", "fmt_10", "abc123"), dep_pkg("fmt", "10.2.1", "fmt_10", "abc123"),
}, },
@@ -81,8 +129,19 @@ TEST_CASE("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") {
REQUIRE(l.nixpkgs_rev() == "abc123"); REQUIRE(l.nixpkgs_rev() == "abc123");
} }
TEST_CASE("write round-trips top-level nixpkgs_rev + flake_utils_rev pins",
"[lockfile]") {
Lockfile l{
.version = 1,
.nixpkgs_rev_pin = "549bd84d6279f9852cae6225e372cc67fb91a4c1",
.flake_utils_rev_pin = "11707dc2f618dd54ca8739b309ec4fc024de578b",
.packages = {root_pkg("p", "0.1.0")},
};
REQUIRE(round_trip(l) == l);
}
TEST_CASE("Lockfile::nixpkgs_rev is nullopt when no deps", "[lockfile]") { TEST_CASE("Lockfile::nixpkgs_rev is nullopt when no deps", "[lockfile]") {
Lockfile l{1, {root_pkg("p", "0.1.0")}}; Lockfile l{.version = 1, .packages = {root_pkg("p", "0.1.0")}};
REQUIRE_FALSE(l.nixpkgs_rev().has_value()); REQUIRE_FALSE(l.nixpkgs_rev().has_value());
} }