From 219254a1dd6d1da444fbcd43508d5c9aa0683ed7 Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Fri, 8 May 2026 12:56:17 +0000 Subject: [PATCH] [M3] add cargoxx build --no-build --- CHANGELOG.md | 8 ++ CMakeLists.txt | 1 + src/cli/cli.cppm | 12 +++ src/cli/cmd_build.cpp | 139 +++++++++++++++++++++++++++++++++ src/cli/run.cpp | 33 ++++++-- tests/CMakeLists.txt | 1 + tests/cmd_build.cpp | 176 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 363 insertions(+), 7 deletions(-) create mode 100644 src/cli/cmd_build.cpp create mode 100644 tests/cmd_build.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 503a5c2..91d1cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,14 @@ All notable changes to cargoxx will be documented in this file. and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source paths emitted relative to `build/` (i.e. prefixed with `../`). Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases. +- `cargoxx build --no-build` end-to-end. Reads `Cargoxx.toml`, + discovers the layout, opens the curated linkdb, resolves a `Recipe` + per manifest dep, synthesizes a fresh `Cargoxx.lock`, and writes + `flake.nix`, `build/CMakeLists.txt`, and `Cargoxx.lock`. With no + resolver yet (M5), `nixpkgs_rev` is left null and the generated flake + falls back to the `nixos-unstable` branch. Without `--no-build`, the + command still generates files but returns a `NotImplemented` error + pointing at M4. `tests/cmd_build.cpp` covers 8 cases. - SQLite overlay: `Database::open(overlay_path)` now opens (and creates, if missing) a per-user `linkdb.sqlite` cache, applying the schema from `SPEC.md` §9 idempotently. Default path is diff --git a/CMakeLists.txt b/CMakeLists.txt index 364446f..ec94144 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,7 @@ target_sources(cargoxx src/codegen/flake.cpp src/codegen/cmake.cpp src/cli/cmd_new.cpp + src/cli/cmd_build.cpp src/cli/run.cpp PUBLIC FILE_SET CXX_MODULES FILES diff --git a/src/cli/cli.cppm b/src/cli/cli.cppm index 5c21f90..54cad3c 100644 --- a/src/cli/cli.cppm +++ b/src/cli/cli.cppm @@ -3,12 +3,24 @@ export module cargoxx.cli; import std; import cargoxx.util; import cargoxx.manifest; +import cargoxx.layout; +import cargoxx.linkdb; +import cargoxx.lockfile; +import cargoxx.codegen; export namespace cargoxx::cli { auto cmd_new(const std::string& name, bool lib_only, const std::filesystem::path& parent_dir) -> util::Result; +// Generates flake.nix, build/CMakeLists.txt, and Cargoxx.lock for the project +// rooted at `project_root`. With `no_build = true`, only generates files; the +// nix/cmake invocation lands at M4. `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, + std::optional overlay_path = std::nullopt) + -> util::Result; + auto run(int argc, char** argv) -> int; } // namespace cargoxx::cli diff --git a/src/cli/cmd_build.cpp b/src/cli/cmd_build.cpp new file mode 100644 index 0000000..9c539d4 --- /dev/null +++ b/src/cli/cmd_build.cpp @@ -0,0 +1,139 @@ +module cargoxx.cli; + +import std; +import cargoxx.util; +import cargoxx.manifest; +import cargoxx.layout; +import cargoxx.linkdb; +import cargoxx.lockfile; +import cargoxx.codegen; + +namespace cargoxx::cli { + +namespace { + +namespace fs = std::filesystem; + +auto io_error(std::string msg, fs::path path) -> util::Error { + return util::Error{ + util::ErrorCode::Internal, std::move(msg), "", std::move(path), std::nullopt, + }; +} + +auto write_text(const fs::path& path, std::string_view content) -> util::Result { + std::ofstream out{path}; + if (!out) { + return std::unexpected( + io_error(std::format("cannot open for writing: {}", path.string()), path)); + } + out << content; + if (!out) { + return std::unexpected(io_error(std::format("write failed: {}", path.string()), path)); + } + return {}; +} + +// Synthesizes a fresh lockfile from the manifest + resolved recipes. Without a +// resolver (M5), `nixpkgs_rev` is left null — flake codegen falls back to the +// `nixos-unstable` branch and the user gets a working but non-reproducible +// build. M5 will populate the rev. +auto synthesize_lockfile(const manifest::Manifest& m, + const std::vector& recipes) -> lockfile::Lockfile { + lockfile::Lockfile lock; + lock.version = 1; + + lockfile::LockfilePackage root{ + .name = m.package.name, + .version = m.package.version, + .dependencies = {}, + .nixpkgs_attr = std::nullopt, + .nixpkgs_rev = std::nullopt, + .linkdb_source = std::nullopt, + }; + for (const auto& dep : m.dependencies) { + root.dependencies.push_back(std::format("{} {}", dep.name, dep.version_spec)); + } + lock.packages.push_back(std::move(root)); + + for (std::size_t i = 0; i < m.dependencies.size(); ++i) { + const auto& dep = m.dependencies[i]; + const auto& rec = recipes[i]; + lock.packages.push_back(lockfile::LockfilePackage{ + .name = dep.name, + .version = dep.version_spec, + .dependencies = {}, + .nixpkgs_attr = rec.nixpkgs_attr, + .nixpkgs_rev = std::nullopt, + .linkdb_source = rec.source, + }); + } + return lock; +} + +} // namespace + +auto cmd_build(const fs::path& project_root, bool no_build, bool /*release*/, + std::optional overlay_path) -> util::Result { + auto manifest_path = project_root / "Cargoxx.toml"; + auto m = manifest::parse(manifest_path); + if (!m) { + return std::unexpected(m.error()); + } + + auto layout_result = layout::discover(project_root, m->package.name); + if (!layout_result) { + return std::unexpected(layout_result.error()); + } + + auto db = linkdb::Database::open(std::move(overlay_path)); + if (!db) { + return std::unexpected(db.error()); + } + + std::vector recipes; + recipes.reserve(m->dependencies.size()); + for (const auto& dep : m->dependencies) { + auto r = db->resolve(dep.name, dep.version_spec, dep.components); + if (!r) { + return std::unexpected(r.error()); + } + recipes.push_back(std::move(*r)); + } + + auto lock = synthesize_lockfile(*m, recipes); + + codegen::GenerateInputs in{*m, *layout_result, lock, recipes, project_root}; + auto flake_text = codegen::flake_nix(in); + auto cmake_text = codegen::cmake_lists(in); + + std::error_code ec; + fs::create_directories(project_root / "build", ec); + if (ec) { + return std::unexpected(io_error( + std::format("cannot create build directory: {}", ec.message()), + project_root / "build")); + } + + if (auto r = write_text(project_root / "flake.nix", flake_text); !r) { + return std::unexpected(r.error()); + } + if (auto r = write_text(project_root / "build" / "CMakeLists.txt", cmake_text); !r) { + return std::unexpected(r.error()); + } + if (auto r = lockfile::write(lock, project_root / "Cargoxx.lock"); !r) { + return std::unexpected(r.error()); + } + + if (!no_build) { + return std::unexpected(util::Error{ + util::ErrorCode::NotImplemented, + "cargoxx build invocation is not implemented in this milestone", + "rerun with --no-build to generate files only (full build lands at M4)", + std::nullopt, std::nullopt, + }); + } + + return {}; +} + +} // namespace cargoxx::cli diff --git a/src/cli/run.cpp b/src/cli/run.cpp index 723ce1c..d664c01 100644 --- a/src/cli/run.cpp +++ b/src/cli/run.cpp @@ -19,20 +19,29 @@ auto run(int argc, char** argv) -> int { new_cmd->add_option("name", new_name, "Project name")->required(); new_cmd->add_flag("--lib", new_lib, "Create a library project"); + auto* build_cmd = app.add_subcommand( + "build", "Generate flake.nix and build/CMakeLists.txt; build with nix+cmake"); + bool build_no_build = false; + bool build_release = false; + build_cmd->add_flag("--no-build", build_no_build, + "Generate files only; do not invoke nix/cmake"); + build_cmd->add_flag("--release", build_release, "Build the release profile"); + try { app.parse(argc, argv); } catch (const CLI::ParseError& e) { return app.exit(e); } + std::error_code ec; + auto cwd = std::filesystem::current_path(ec); + if (ec) { + std::cerr << std::format("error: cannot determine current working directory: {}\n", + ec.message()); + return 1; + } + if (*new_cmd) { - std::error_code ec; - auto cwd = std::filesystem::current_path(ec); - if (ec) { - std::cerr << std::format("error: cannot determine current working directory: {}\n", - ec.message()); - return 1; - } auto r = cmd_new(new_name, new_lib, cwd); if (!r) { std::cerr << util::format(r.error()); @@ -42,6 +51,16 @@ auto run(int argc, char** argv) -> int { return 0; } + if (*build_cmd) { + auto r = cmd_build(cwd, build_no_build, build_release); + if (!r) { + std::cerr << util::format(r.error()); + return 1; + } + std::cout << " Generated flake.nix, build/CMakeLists.txt, Cargoxx.lock\n"; + return 0; + } + return 0; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3e6ab28..e568e41 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,3 +18,4 @@ cargoxx_add_test(linkdb_overlay) cargoxx_add_test(codegen_flake) cargoxx_add_test(codegen_cmake) cargoxx_add_test(cmd_new) +cargoxx_add_test(cmd_build) diff --git a/tests/cmd_build.cpp b/tests/cmd_build.cpp new file mode 100644 index 0000000..0767878 --- /dev/null +++ b/tests/cmd_build.cpp @@ -0,0 +1,176 @@ +#include + +import cargoxx.cli; +import cargoxx.manifest; +import cargoxx.lockfile; +import cargoxx.util; +import std; + +using cargoxx::cli::cmd_build; +using cargoxx::cli::cmd_new; +using cargoxx::util::ErrorCode; +namespace manifest = cargoxx::manifest; +namespace lockfile = cargoxx::lockfile; + +namespace { + +auto fresh_dir() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-build-test-{}", std::random_device{}()); + std::filesystem::create_directories(d); + return d; +} + +auto overlay_path(const std::filesystem::path& dir) -> std::filesystem::path { + return dir / "overlay.sqlite"; +} + +auto read_file(const std::filesystem::path& p) -> std::string { + std::ifstream in{p}; + return std::string{std::istreambuf_iterator(in), {}}; +} + +auto add_dep(const std::filesystem::path& root, const std::string& name, + const std::string& version_spec, std::vector components = {}) { + auto path = root / "Cargoxx.toml"; + auto m = manifest::parse(path); + REQUIRE(m.has_value()); + m->dependencies.push_back(manifest::Dependency{ + .name = name, + .version_spec = version_spec, + .components = std::move(components), + }); + REQUIRE(manifest::write(*m, path).has_value()); +} + +} // namespace + +TEST_CASE("cmd_build generates files for a no-deps binary project", + "[cli][build]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("hello", false, parent).has_value()); + auto root = parent / "hello"; + + auto r = cmd_build(root, true, false, overlay_path(parent)); + REQUIRE(r.has_value()); + + REQUIRE(std::filesystem::exists(root / "flake.nix")); + REQUIRE(std::filesystem::exists(root / "build" / "CMakeLists.txt")); + REQUIRE(std::filesystem::exists(root / "Cargoxx.lock")); + + auto cmake_text = read_file(root / "build" / "CMakeLists.txt"); + REQUIRE(cmake_text.find("project(hello LANGUAGES CXX)") != std::string::npos); + REQUIRE(cmake_text.find("add_executable(hello_bin ../src/main.cpp)") != + std::string::npos); + + auto flake_text = read_file(root / "flake.nix"); + REQUIRE(flake_text.find("description = \"hello\";") != std::string::npos); + REQUIRE(flake_text.find("github:NixOS/nixpkgs/nixos-unstable") != std::string::npos); +} + +TEST_CASE("cmd_build generates files for a library project", "[cli][build]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("widget", true, parent).has_value()); + auto root = parent / "widget"; + + auto r = cmd_build(root, true, false, overlay_path(parent)); + REQUIRE(r.has_value()); + + auto cmake_text = read_file(root / "build" / "CMakeLists.txt"); + REQUIRE(cmake_text.find("add_library(widget STATIC)") != std::string::npos); + REQUIRE(cmake_text.find("../src/lib.cppm") != std::string::npos); + REQUIRE(cmake_text.find("add_executable") == std::string::npos); +} + +TEST_CASE("cmd_build resolves a curated dep into find_package + targets", + "[cli][build]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("app", false, parent).has_value()); + auto root = parent / "app"; + add_dep(root, "fmt", "10.2.0"); + + auto r = cmd_build(root, true, false, overlay_path(parent)); + REQUIRE(r.has_value()); + + auto cmake_text = read_file(root / "build" / "CMakeLists.txt"); + REQUIRE(cmake_text.find("find_package(fmt CONFIG REQUIRED)") != std::string::npos); + REQUIRE(cmake_text.find("fmt::fmt") != std::string::npos); + + auto flake_text = read_file(root / "flake.nix"); + REQUIRE(flake_text.find("pkgs.fmt_10") != std::string::npos); +} + +TEST_CASE("cmd_build resolves a componentized dep", "[cli][build]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("app", false, parent).has_value()); + auto root = parent / "app"; + add_dep(root, "boost", "1.84.0", {"filesystem", "system"}); + + auto r = cmd_build(root, true, false, overlay_path(parent)); + REQUIRE(r.has_value()); + + auto cmake_text = read_file(root / "build" / "CMakeLists.txt"); + REQUIRE(cmake_text.find("find_package(Boost REQUIRED COMPONENTS filesystem system)") != + std::string::npos); + REQUIRE(cmake_text.find("Boost::filesystem") != std::string::npos); + REQUIRE(cmake_text.find("Boost::system") != std::string::npos); +} + +TEST_CASE("cmd_build synthesizes a lockfile entry per dep", "[cli][build]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("app", false, parent).has_value()); + auto root = parent / "app"; + add_dep(root, "fmt", "10.2.0"); + add_dep(root, "spdlog", "1.13.0"); + + auto r = cmd_build(root, true, false, overlay_path(parent)); + REQUIRE(r.has_value()); + + auto lock = lockfile::parse(root / "Cargoxx.lock"); + REQUIRE(lock.has_value()); + REQUIRE(lock->packages.size() == 3); // root + fmt + spdlog + REQUIRE(lock->packages[0].name == "app"); + REQUIRE(lock->packages[0].dependencies.size() == 2); +} + +TEST_CASE("cmd_build fails for an unknown dep", "[cli][build]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("app", false, parent).has_value()); + auto root = parent / "app"; + add_dep(root, "obscurelib", "0.0.1"); + + auto r = cmd_build(root, true, false, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::LinkdbUnknownPackage); +} + +TEST_CASE("cmd_build without --no-build returns NotImplemented", "[cli][build]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("app", false, parent).has_value()); + auto root = parent / "app"; + + auto r = cmd_build(root, false, false, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::NotImplemented); + // Files are still generated before the build step would run + REQUIRE(std::filesystem::exists(root / "flake.nix")); + REQUIRE(std::filesystem::exists(root / "build" / "CMakeLists.txt")); +} + +TEST_CASE("cmd_build is idempotent — second run produces identical files", + "[cli][build]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("app", false, parent).has_value()); + auto root = parent / "app"; + add_dep(root, "fmt", "10.2.0"); + + REQUIRE(cmd_build(root, true, false, overlay_path(parent)).has_value()); + auto first_cmake = read_file(root / "build" / "CMakeLists.txt"); + auto first_flake = read_file(root / "flake.nix"); + auto first_lock = read_file(root / "Cargoxx.lock"); + + REQUIRE(cmd_build(root, true, false, overlay_path(parent)).has_value()); + REQUIRE(read_file(root / "build" / "CMakeLists.txt") == first_cmake); + REQUIRE(read_file(root / "flake.nix") == first_flake); + REQUIRE(read_file(root / "Cargoxx.lock") == first_lock); +}