diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8a033..691a10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,17 @@ 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 add @ [--components a,b]` edits `Cargoxx.toml`, + validates the new dep against the curated linkdb (so unknown packages + and missing components fail before any disk write), and rejects + already-declared deps. Auto-resolution against nixhub.io/lazamar is + deferred — for v0.1 the version is required. +- `cargoxx remove ` drops a declared dep from `Cargoxx.toml`, + errors when the dep is not declared. Other deps are preserved. +- End-to-end verified on a freshly-scaffolded project. `tests/cmd_add.cpp` + and `tests/cmd_remove.cpp` cover 9 cases. Known cosmetic issue: + toml++ writes top-level sections alphabetically, so `[package]` may + end up after `[dependencies]` post-mutation; logged for M6 polish. - `cargoxx run [--release] [--bin ] [-- ...]`, `cargoxx test [--release]`, and `cargoxx clean`. `run` builds first, picks the binary (errors with the available list when the project diff --git a/CMakeLists.txt b/CMakeLists.txt index 272e90f..ecd2422 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -57,6 +57,8 @@ target_sources(cargoxx src/cli/cmd_run.cpp src/cli/cmd_test.cpp src/cli/cmd_clean.cpp + src/cli/cmd_add.cpp + src/cli/cmd_remove.cpp src/cli/run.cpp PUBLIC FILE_SET CXX_MODULES FILES diff --git a/src/cli/cli.cppm b/src/cli/cli.cppm index 0316ed8..d32b390 100644 --- a/src/cli/cli.cppm +++ b/src/cli/cli.cppm @@ -40,6 +40,19 @@ auto cmd_test(const std::filesystem::path& project_root, bool release, // Removes /build/. Leaves Cargoxx.lock and flake.lock alone. auto cmd_clean(const std::filesystem::path& project_root) -> util::Result; +// Adds a dependency to Cargoxx.toml. The version is required for v0.1 +// (auto-resolution against nixhub.io is deferred). The package and version +// are validated against the linkdb so the user gets an immediate error when +// the recipe is unknown. +auto cmd_add(const std::filesystem::path& project_root, const std::string& name, + const std::string& version_spec, std::vector components, + std::optional overlay_path = std::nullopt) + -> util::Result; + +// Removes a dependency from Cargoxx.toml. Errors if the dep is not declared. +auto cmd_remove(const std::filesystem::path& project_root, const std::string& name) + -> util::Result; + auto run(int argc, char** argv) -> int; } // namespace cargoxx::cli diff --git a/src/cli/cmd_add.cpp b/src/cli/cmd_add.cpp new file mode 100644 index 0000000..4f8bbad --- /dev/null +++ b/src/cli/cmd_add.cpp @@ -0,0 +1,66 @@ +module cargoxx.cli; + +import std; +import cargoxx.util; +import cargoxx.manifest; +import cargoxx.linkdb; + +namespace cargoxx::cli { + +namespace fs = std::filesystem; + +auto cmd_add(const fs::path& project_root, const std::string& name, + const std::string& version_spec, std::vector components, + std::optional overlay_path) -> util::Result { + if (name.empty()) { + return std::unexpected(util::Error{ + util::ErrorCode::ManifestInvalidField, + "package name is required", + "", std::nullopt, std::nullopt, + }); + } + if (version_spec.empty()) { + return std::unexpected(util::Error{ + util::ErrorCode::ManifestVersionInvalid, + std::format("version required for '{}'", name), + "use the form 'cargoxx add @' " + "(auto-resolve against nixhub is deferred)", + std::nullopt, std::nullopt, + }); + } + + auto manifest_path = project_root / "Cargoxx.toml"; + auto m = manifest::parse(manifest_path); + if (!m) { + return std::unexpected(m.error()); + } + + for (const auto& d : m->dependencies) { + if (d.name == name) { + return std::unexpected(util::Error{ + util::ErrorCode::ManifestInvalidField, + std::format("dependency '{}' is already declared", name), + "edit Cargoxx.toml directly to change the version", + manifest_path, std::nullopt, + }); + } + } + + auto db = linkdb::Database::open(std::move(overlay_path)); + if (!db) { + return std::unexpected(db.error()); + } + if (auto check = db->resolve(name, version_spec, components); !check) { + return std::unexpected(check.error()); + } + + m->dependencies.push_back(manifest::Dependency{ + .name = name, + .version_spec = version_spec, + .components = std::move(components), + }); + + return manifest::write(*m, manifest_path); +} + +} // namespace cargoxx::cli diff --git a/src/cli/cmd_remove.cpp b/src/cli/cmd_remove.cpp new file mode 100644 index 0000000..297105f --- /dev/null +++ b/src/cli/cmd_remove.cpp @@ -0,0 +1,33 @@ +module cargoxx.cli; + +import std; +import cargoxx.util; +import cargoxx.manifest; + +namespace cargoxx::cli { + +namespace fs = std::filesystem; + +auto cmd_remove(const fs::path& project_root, const std::string& name) + -> util::Result { + auto manifest_path = project_root / "Cargoxx.toml"; + auto m = manifest::parse(manifest_path); + if (!m) { + return std::unexpected(m.error()); + } + + auto before = m->dependencies.size(); + std::erase_if(m->dependencies, + [&](const manifest::Dependency& d) { return d.name == name; }); + if (m->dependencies.size() == before) { + return std::unexpected(util::Error{ + util::ErrorCode::ManifestInvalidField, + std::format("dependency '{}' is not declared", name), + "", manifest_path, std::nullopt, + }); + } + + return manifest::write(*m, manifest_path); +} + +} // namespace cargoxx::cli diff --git a/src/cli/run.cpp b/src/cli/run.cpp index dd4e9cc..ba87274 100644 --- a/src/cli/run.cpp +++ b/src/cli/run.cpp @@ -45,6 +45,19 @@ auto run(int argc, char** argv) -> int { auto* clean_cmd = app.add_subcommand("clean", "Remove the build/ directory"); + auto* add_cmd = app.add_subcommand( + "add", "Add a dependency to Cargoxx.toml (e.g. cargoxx add fmt@10.2)"); + std::string add_spec; + std::string add_components; + add_cmd->add_option("spec", add_spec, "Package spec: @")->required(); + add_cmd->add_option("--components", add_components, + "Comma-separated components (e.g. filesystem,system)"); + + auto* remove_cmd = + app.add_subcommand("remove", "Remove a dependency from Cargoxx.toml"); + std::string remove_name; + remove_cmd->add_option("name", remove_name, "Package name to remove")->required(); + try { app.parse(argc, argv); } catch (const CLI::ParseError& e) { @@ -119,6 +132,49 @@ auto run(int argc, char** argv) -> int { return 0; } + if (*add_cmd) { + std::string name = add_spec; + std::string version; + if (auto at = add_spec.find('@'); at != std::string::npos) { + name = add_spec.substr(0, at); + version = add_spec.substr(at + 1); + } + std::vector components; + if (!add_components.empty()) { + std::size_t pos = 0; + while (pos <= add_components.size()) { + auto comma = add_components.find(',', pos); + auto piece = add_components.substr( + pos, comma == std::string::npos ? add_components.size() - pos + : comma - pos); + if (!piece.empty()) { + components.push_back(std::move(piece)); + } + if (comma == std::string::npos) { + break; + } + pos = comma + 1; + } + } + auto r = cmd_add(cwd, name, version, std::move(components)); + if (!r) { + std::cerr << util::format(r.error()); + return 1; + } + std::cout << std::format(" Added {} {}\n", name, version); + return 0; + } + + if (*remove_cmd) { + auto r = cmd_remove(cwd, remove_name); + if (!r) { + std::cerr << util::format(r.error()); + return 1; + } + std::cout << std::format(" Removed {}\n", remove_name); + return 0; + } + return 0; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f10bf97..27be4ff 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,3 +22,5 @@ cargoxx_add_test(cmd_new) cargoxx_add_test(cmd_build) cargoxx_add_test(cmd_run) cargoxx_add_test(cmd_clean) +cargoxx_add_test(cmd_add) +cargoxx_add_test(cmd_remove) diff --git a/tests/cmd_add.cpp b/tests/cmd_add.cpp new file mode 100644 index 0000000..e69072a --- /dev/null +++ b/tests/cmd_add.cpp @@ -0,0 +1,101 @@ +#include + +import cargoxx.cli; +import cargoxx.manifest; +import cargoxx.util; +import std; + +using cargoxx::cli::cmd_add; +using cargoxx::cli::cmd_new; +using cargoxx::util::ErrorCode; +namespace manifest = cargoxx::manifest; + +namespace { + +auto fresh_dir() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-add-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 scaffold(const std::filesystem::path& parent) -> std::filesystem::path { + REQUIRE(cmd_new("app", false, parent).has_value()); + return parent / "app"; +} + +} // namespace + +TEST_CASE("cmd_add appends a string-form dependency", "[cli][add]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + + auto r = cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)); + REQUIRE(r.has_value()); + + auto m = manifest::parse(root / "Cargoxx.toml"); + REQUIRE(m.has_value()); + REQUIRE(m->dependencies.size() == 1); + REQUIRE(m->dependencies[0].name == "fmt"); + REQUIRE(m->dependencies[0].version_spec == "10.2.0"); + REQUIRE(m->dependencies[0].components.empty()); +} + +TEST_CASE("cmd_add stores components when provided", "[cli][add]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + + auto r = cmd_add(root, "boost", "1.84.0", {"filesystem", "system"}, + overlay_path(parent)); + REQUIRE(r.has_value()); + + auto m = manifest::parse(root / "Cargoxx.toml"); + REQUIRE(m.has_value()); + REQUIRE(m->dependencies.size() == 1); + REQUIRE(m->dependencies[0].name == "boost"); + REQUIRE(m->dependencies[0].components == + std::vector{"filesystem", "system"}); +} + +TEST_CASE("cmd_add rejects an empty version", "[cli][add]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + + auto r = cmd_add(root, "fmt", "", {}, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ManifestVersionInvalid); +} + +TEST_CASE("cmd_add rejects an unknown package", "[cli][add]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + + auto r = cmd_add(root, "obscurelib", "0.0.1", {}, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::LinkdbUnknownPackage); +} + +TEST_CASE("cmd_add rejects an already-declared dep", "[cli][add]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + + REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value()); + + auto r = cmd_add(root, "fmt", "10.3.0", {}, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ManifestInvalidField); +} + +TEST_CASE("cmd_add rejects componentized package without components", + "[cli][add]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + + auto r = cmd_add(root, "boost", "1.84.0", {}, overlay_path(parent)); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::LinkdbComponentNotSupported); +} diff --git a/tests/cmd_remove.cpp b/tests/cmd_remove.cpp new file mode 100644 index 0000000..f828536 --- /dev/null +++ b/tests/cmd_remove.cpp @@ -0,0 +1,67 @@ +#include + +import cargoxx.cli; +import cargoxx.manifest; +import cargoxx.util; +import std; + +using cargoxx::cli::cmd_add; +using cargoxx::cli::cmd_new; +using cargoxx::cli::cmd_remove; +using cargoxx::util::ErrorCode; +namespace manifest = cargoxx::manifest; + +namespace { + +auto fresh_dir() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-remove-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 scaffold(const std::filesystem::path& parent) -> std::filesystem::path { + REQUIRE(cmd_new("app", false, parent).has_value()); + return parent / "app"; +} + +} // namespace + +TEST_CASE("cmd_remove drops the dependency", "[cli][remove]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value()); + + REQUIRE(cmd_remove(root, "fmt").has_value()); + + auto m = manifest::parse(root / "Cargoxx.toml"); + REQUIRE(m.has_value()); + REQUIRE(m->dependencies.empty()); +} + +TEST_CASE("cmd_remove leaves other deps in place", "[cli][remove]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value()); + REQUIRE(cmd_add(root, "spdlog", "1.13.0", {}, overlay_path(parent)).has_value()); + + REQUIRE(cmd_remove(root, "fmt").has_value()); + + auto m = manifest::parse(root / "Cargoxx.toml"); + REQUIRE(m.has_value()); + REQUIRE(m->dependencies.size() == 1); + REQUIRE(m->dependencies[0].name == "spdlog"); +} + +TEST_CASE("cmd_remove errors on unknown dep", "[cli][remove]") { + auto parent = fresh_dir(); + auto root = scaffold(parent); + + auto r = cmd_remove(root, "fmt"); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ManifestInvalidField); +}