From 0a398d1c319e10f2f96f948ee2707a86bd6d1274 Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Sun, 10 May 2026 00:12:25 +0000 Subject: [PATCH] [M4] add cargoxx run/test/clean --- CHANGELOG.md | 9 ++++ CMakeLists.txt | 3 ++ src/cli/cli.cppm | 17 ++++++++ src/cli/cmd_clean.cpp | 27 ++++++++++++ src/cli/cmd_run.cpp | 99 +++++++++++++++++++++++++++++++++++++++++++ src/cli/cmd_test.cpp | 36 ++++++++++++++++ src/cli/run.cpp | 47 ++++++++++++++++++++ tests/CMakeLists.txt | 2 + tests/cmd_clean.cpp | 44 +++++++++++++++++++ tests/cmd_run.cpp | 65 ++++++++++++++++++++++++++++ 10 files changed, 349 insertions(+) create mode 100644 src/cli/cmd_clean.cpp create mode 100644 src/cli/cmd_run.cpp create mode 100644 src/cli/cmd_test.cpp create mode 100644 tests/cmd_clean.cpp create mode 100644 tests/cmd_run.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c93c2..4e8a033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,15 @@ 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 run [--release] [--bin ] [-- ...]`, + `cargoxx test [--release]`, and `cargoxx clean`. `run` builds first, + picks the binary (errors with the available list when the project + has multiple bins and `--bin` is omitted), and execs it with the + process exit code propagated. `test` invokes + `nix develop -c ctest --test-dir build/ --output-on-failure`. + `clean` removes `build/` while leaving `Cargoxx.lock` and `flake.lock` + intact. End-to-end verified against a freshly-scaffolded project. + `tests/cmd_run.cpp` and `tests/cmd_clean.cpp` cover 6 cases. - `cargoxx build` (without `--no-build`) now drives the full build: `nix develop -c cmake -B build/ -S build -G Ninja -DCMAKE_BUILD_TYPE=` then `nix develop -c cmake --build` diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a0aee4..272e90f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,9 @@ target_sources(cargoxx src/exec/subprocess.cpp src/cli/cmd_new.cpp src/cli/cmd_build.cpp + src/cli/cmd_run.cpp + src/cli/cmd_test.cpp + src/cli/cmd_clean.cpp src/cli/run.cpp PUBLIC FILE_SET CXX_MODULES FILES diff --git a/src/cli/cli.cppm b/src/cli/cli.cppm index 310e139..0316ed8 100644 --- a/src/cli/cli.cppm +++ b/src/cli/cli.cppm @@ -23,6 +23,23 @@ auto cmd_build(const std::filesystem::path& project_root, bool no_build, bool re std::optional overlay_path = std::nullopt) -> util::Result; +// Builds the project, picks a binary target, and execs it with `args`. +// `bin_name` is required when the project declares more than one binary. +// Returns the binary's exit code, or an Error if selection or build fails. +auto cmd_run(const std::filesystem::path& project_root, bool release, + std::optional bin_name, const std::vector& args, + std::optional overlay_path = std::nullopt) + -> util::Result; + +// Builds the project then runs `ctest --output-on-failure` inside the +// profile-specific binary directory. Returns ctest's exit code. +auto cmd_test(const std::filesystem::path& project_root, bool release, + std::optional overlay_path = std::nullopt) + -> util::Result; + +// Removes /build/. Leaves Cargoxx.lock and flake.lock alone. +auto cmd_clean(const std::filesystem::path& project_root) -> util::Result; + auto run(int argc, char** argv) -> int; } // namespace cargoxx::cli diff --git a/src/cli/cmd_clean.cpp b/src/cli/cmd_clean.cpp new file mode 100644 index 0000000..4edacb7 --- /dev/null +++ b/src/cli/cmd_clean.cpp @@ -0,0 +1,27 @@ +module cargoxx.cli; + +import std; +import cargoxx.util; + +namespace cargoxx::cli { + +namespace fs = std::filesystem; + +auto cmd_clean(const fs::path& project_root) -> util::Result { + const auto build_dir = project_root / "build"; + std::error_code ec; + if (!fs::exists(build_dir, ec)) { + return {}; + } + fs::remove_all(build_dir, ec); + if (ec) { + return std::unexpected(util::Error{ + util::ErrorCode::Internal, + std::format("cannot remove '{}': {}", build_dir.string(), ec.message()), + "", build_dir, std::nullopt, + }); + } + return {}; +} + +} // namespace cargoxx::cli diff --git a/src/cli/cmd_run.cpp b/src/cli/cmd_run.cpp new file mode 100644 index 0000000..179c2ff --- /dev/null +++ b/src/cli/cmd_run.cpp @@ -0,0 +1,99 @@ +module cargoxx.cli; + +import std; +import cargoxx.util; +import cargoxx.manifest; +import cargoxx.layout; +import cargoxx.exec; + +namespace cargoxx::cli { + +namespace { + +namespace fs = std::filesystem; + +auto join_names(const std::vector& bins) -> std::string { + std::string out; + for (std::size_t i = 0; i < bins.size(); ++i) { + if (i > 0) { + out += ", "; + } + out += bins[i].name; + } + return out; +} + +} // namespace + +auto cmd_run(const fs::path& project_root, bool release, + std::optional bin_name, const std::vector& args, + 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()); + } + + if (layout_result->binaries.empty()) { + return std::unexpected(util::Error{ + util::ErrorCode::LayoutNoTarget, + "no binary to run", + "add src/main.cpp or a src/bin/.cpp file", + project_root, std::nullopt, + }); + } + + const layout::Target* selected = nullptr; + if (bin_name) { + for (const auto& b : layout_result->binaries) { + if (b.name == *bin_name) { + selected = &b; + break; + } + } + if (!selected) { + return std::unexpected(util::Error{ + util::ErrorCode::LayoutNoTarget, + std::format("binary '{}' not found", *bin_name), + std::format("available: {}", join_names(layout_result->binaries)), + project_root, std::nullopt, + }); + } + } else { + if (layout_result->binaries.size() > 1) { + return std::unexpected(util::Error{ + util::ErrorCode::LayoutNoTarget, + "multiple binaries; --bin is required", + std::format("available: {}", join_names(layout_result->binaries)), + project_root, std::nullopt, + }); + } + selected = &layout_result->binaries.front(); + } + + if (auto r = cmd_build(project_root, false, release, std::nullopt, overlay_path); !r) { + return std::unexpected(r.error()); + } + + const std::string profile = release ? "release" : "debug"; + auto bin_path = project_root / "build" / profile / selected->name; + + auto r = exec::run(bin_path.string(), args, + exec::ExecOptions{ + .cwd = project_root, + .env_overrides = {}, + .timeout = std::nullopt, + .inherit_stdio = true, + }); + if (!r) { + return std::unexpected(r.error()); + } + return r->exit_code; +} + +} // namespace cargoxx::cli diff --git a/src/cli/cmd_test.cpp b/src/cli/cmd_test.cpp new file mode 100644 index 0000000..f35f89b --- /dev/null +++ b/src/cli/cmd_test.cpp @@ -0,0 +1,36 @@ +module cargoxx.cli; + +import std; +import cargoxx.util; +import cargoxx.exec; + +namespace cargoxx::cli { + +namespace fs = std::filesystem; + +auto cmd_test(const fs::path& project_root, bool release, + std::optional overlay_path) -> util::Result { + if (auto r = cmd_build(project_root, false, release, std::nullopt, std::move(overlay_path)); + !r) { + return std::unexpected(r.error()); + } + + const std::string profile = release ? "release" : "debug"; + const auto build_dir = std::format("build/{}", profile); + + auto r = exec::run("nix", + {"develop", "--command", "ctest", "--test-dir", build_dir, + "--output-on-failure"}, + exec::ExecOptions{ + .cwd = project_root, + .env_overrides = {}, + .timeout = std::nullopt, + .inherit_stdio = true, + }); + if (!r) { + return std::unexpected(r.error()); + } + return r->exit_code; +} + +} // namespace cargoxx::cli diff --git a/src/cli/run.cpp b/src/cli/run.cpp index 8dcc4e8..dd4e9cc 100644 --- a/src/cli/run.cpp +++ b/src/cli/run.cpp @@ -30,6 +30,21 @@ auto run(int argc, char** argv) -> int { build_cmd->add_option("--target", build_target, "Build a specific target (passed to cmake --build)"); + auto* run_cmd = app.add_subcommand("run", "Build and run a binary target"); + bool run_release = false; + std::string run_bin; + std::vector run_args; + run_cmd->add_flag("--release", run_release, "Run the release profile"); + run_cmd->add_option("--bin", run_bin, "Binary target to run"); + run_cmd->add_option("args", run_args, "Arguments passed to the binary") + ->expected(0, -1); + + auto* test_cmd = app.add_subcommand("test", "Build and run all test targets via ctest"); + bool test_release = false; + test_cmd->add_flag("--release", test_release, "Test the release profile"); + + auto* clean_cmd = app.add_subcommand("clean", "Remove the build/ directory"); + try { app.parse(argc, argv); } catch (const CLI::ParseError& e) { @@ -72,6 +87,38 @@ auto run(int argc, char** argv) -> int { return 0; } + if (*run_cmd) { + std::optional bin; + if (!run_bin.empty()) { + bin = run_bin; + } + auto r = cmd_run(cwd, run_release, bin, run_args); + if (!r) { + std::cerr << util::format(r.error()); + return 1; + } + return *r; + } + + if (*test_cmd) { + auto r = cmd_test(cwd, test_release); + if (!r) { + std::cerr << util::format(r.error()); + return 1; + } + return *r; + } + + if (*clean_cmd) { + auto r = cmd_clean(cwd); + if (!r) { + std::cerr << util::format(r.error()); + return 1; + } + std::cout << " Cleaned build/\n"; + return 0; + } + return 0; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f50ea33..f10bf97 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,3 +20,5 @@ cargoxx_add_test(codegen_flake) cargoxx_add_test(codegen_cmake) cargoxx_add_test(cmd_new) cargoxx_add_test(cmd_build) +cargoxx_add_test(cmd_run) +cargoxx_add_test(cmd_clean) diff --git a/tests/cmd_clean.cpp b/tests/cmd_clean.cpp new file mode 100644 index 0000000..e0b87a3 --- /dev/null +++ b/tests/cmd_clean.cpp @@ -0,0 +1,44 @@ +#include + +import cargoxx.cli; +import cargoxx.util; +import std; + +using cargoxx::cli::cmd_clean; + +namespace { + +auto fresh_dir() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-clean-test-{}", std::random_device{}()); + std::filesystem::create_directories(d); + return d; +} + +} // namespace + +TEST_CASE("cmd_clean removes the build directory", "[cli][clean]") { + auto root = fresh_dir(); + std::filesystem::create_directories(root / "build" / "debug"); + std::ofstream{root / "build" / "debug" / "stale.txt"} << "x"; + + REQUIRE(cmd_clean(root).has_value()); + REQUIRE_FALSE(std::filesystem::exists(root / "build")); +} + +TEST_CASE("cmd_clean is a no-op when build/ does not exist", "[cli][clean]") { + auto root = fresh_dir(); + REQUIRE(cmd_clean(root).has_value()); +} + +TEST_CASE("cmd_clean leaves Cargoxx.lock and flake.lock alone", "[cli][clean]") { + auto root = fresh_dir(); + std::filesystem::create_directories(root / "build"); + std::ofstream{root / "Cargoxx.lock"} << "version = 1\n"; + std::ofstream{root / "flake.lock"} << "{}\n"; + + REQUIRE(cmd_clean(root).has_value()); + REQUIRE_FALSE(std::filesystem::exists(root / "build")); + REQUIRE(std::filesystem::exists(root / "Cargoxx.lock")); + REQUIRE(std::filesystem::exists(root / "flake.lock")); +} diff --git a/tests/cmd_run.cpp b/tests/cmd_run.cpp new file mode 100644 index 0000000..e09121c --- /dev/null +++ b/tests/cmd_run.cpp @@ -0,0 +1,65 @@ +#include + +import cargoxx.cli; +import cargoxx.util; +import std; + +using cargoxx::cli::cmd_new; +using cargoxx::cli::cmd_run; +using cargoxx::util::ErrorCode; + +namespace { + +auto fresh_dir() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-run-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 add_extra_bin(const std::filesystem::path& root, std::string_view name) { + auto p = root / "src" / "bin" / std::format("{}.cpp", name); + std::filesystem::create_directories(p.parent_path()); + std::ofstream{p} << "int main() { return 0; }\n"; +} + +} // namespace + +TEST_CASE("cmd_run errors when no binary is present", "[cli][run]") { + // A library-only project has no binary to run. + auto parent = fresh_dir(); + REQUIRE(cmd_new("widget", true, parent).has_value()); + auto root = parent / "widget"; + + auto r = cmd_run(root, false, std::nullopt, {}, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::LayoutNoTarget); +} + +TEST_CASE("cmd_run errors when --bin doesn't match any target", "[cli][run]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("app", false, parent).has_value()); + auto root = parent / "app"; + + auto r = cmd_run(root, false, std::string{"missing"}, {}, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::LayoutNoTarget); +} + +TEST_CASE("cmd_run requires --bin when multiple binaries exist", "[cli][run]") { + auto parent = fresh_dir(); + REQUIRE(cmd_new("app", false, parent).has_value()); + auto root = parent / "app"; + add_extra_bin(root, "tool"); + + auto r = cmd_run(root, false, std::nullopt, {}, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::LayoutNoTarget); + // Hint should list both target names. + REQUIRE(r.error().hint.find("app") != std::string::npos); + REQUIRE(r.error().hint.find("tool") != std::string::npos); +}