[M3] add cargoxx build --no-build

This commit is contained in:
2026-05-08 12:56:17 +00:00
parent 4380c5181c
commit 219254a1dd
7 changed files with 363 additions and 7 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<void>;
// 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<std::filesystem::path> overlay_path = std::nullopt)
-> util::Result<void>;
auto run(int argc, char** argv) -> int;
} // namespace cargoxx::cli

139
src/cli/cmd_build.cpp Normal file
View File

@@ -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<void> {
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<linkdb::Recipe>& 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<fs::path> overlay_path) -> util::Result<void> {
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<linkdb::Recipe> 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

View File

@@ -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;
}

View File

@@ -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)

176
tests/cmd_build.cpp Normal file
View File

@@ -0,0 +1,176 @@
#include <catch2/catch_test_macros.hpp>
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<char>(in), {}};
}
auto add_dep(const std::filesystem::path& root, const std::string& name,
const std::string& version_spec, std::vector<std::string> 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);
}