diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b1d60..21a0f40 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.resolver::nixpkgs_probe(attr)` — runs + `nix eval nixpkgs# --json --apply 'p: { version, path }'` via + `exec::run` and returns a `NixpkgsInfo { attr, version, out_path }`. + Distinguishes missing attributes (`ResolutionUnknownPackage`) from + evaluator/network errors (`ResolutionNetworkError`). The `path` field + name avoids nix's `outPath`-driven derivation coercion in `--json` + mode (which would otherwise serialize the result as a bare path + string instead of a JSON object). Pure parser + `parse_nix_eval_json(attr, text)` exposed for unit tests; live tests + in `tests/nixpkgs_probe_live.cpp` are gated behind + `CARGOXX_NETWORK_TESTS=1` (verified locally against `hello`). - `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 diff --git a/CMakeLists.txt b/CMakeLists.txt index ecd2422..5cac850 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,7 @@ target_sources(cargoxx src/codegen/flake.cpp src/codegen/cmake.cpp src/exec/subprocess.cpp + src/resolver/nixpkgs_probe.cpp src/cli/cmd_new.cpp src/cli/cmd_build.cpp src/cli/cmd_run.cpp diff --git a/src/resolver/nixpkgs_probe.cpp b/src/resolver/nixpkgs_probe.cpp new file mode 100644 index 0000000..6876cc0 --- /dev/null +++ b/src/resolver/nixpkgs_probe.cpp @@ -0,0 +1,106 @@ +module; + +#include + +module cargoxx.resolver; + +import std; +import cargoxx.util; +import cargoxx.exec; + +namespace cargoxx::resolver { + +namespace { + +// `outPath` is a magic attribute name that triggers nix's derivation +// coercion in --json mode (the attrset gets serialized as a bare path +// string). Renaming the field to `path` keeps it a proper JSON object. +constexpr std::string_view APPLY_FN = + "p: { version = p.version or \"\"; path = p.outPath; }"; + +auto make_error(util::ErrorCode code, std::string msg) -> util::Error { + return util::Error{code, std::move(msg), "", std::nullopt, std::nullopt}; +} + +// nix eval emits these markers when an attribute is missing on the flake. +auto looks_like_missing_attribute(std::string_view stderr_text) -> bool { + return stderr_text.find("does not provide attribute") != std::string_view::npos || + stderr_text.find("attribute '") != std::string_view::npos && + stderr_text.find("missing") != std::string_view::npos; +} + +} // namespace + +auto parse_nix_eval_json(std::string_view attr, std::string_view json) + -> util::Result { + nlohmann::json j; + try { + j = nlohmann::json::parse(json); + } catch (const nlohmann::json::parse_error& e) { + return std::unexpected(make_error( + util::ErrorCode::ResolutionNetworkError, + std::format("nix eval emitted unparseable JSON: {}", e.what()))); + } + + if (!j.is_object()) { + return std::unexpected(make_error( + util::ErrorCode::ResolutionNetworkError, + "nix eval JSON is not an object")); + } + + NixpkgsInfo info{.attr = std::string{attr}, .version = {}, .out_path = {}}; + + if (j.contains("path") && j["path"].is_string()) { + info.out_path = j["path"].get(); + } else { + return std::unexpected(make_error( + util::ErrorCode::ResolutionNetworkError, + "nix eval JSON lacks 'path'")); + } + + if (j.contains("version") && j["version"].is_string()) { + info.version = j["version"].get(); + } + + return info; +} + +auto nixpkgs_probe(const std::string& attr) -> util::Result { + if (attr.empty()) { + return std::unexpected(make_error(util::ErrorCode::ResolutionUnknownPackage, + "package name is empty")); + } + + std::vector args{ + "--extra-experimental-features", "nix-command flakes", + "eval", std::format("nixpkgs#{}", attr), "--json", "--apply", + std::string{APPLY_FN}, + }; + + auto r = exec::run("nix", args, + exec::ExecOptions{ + .cwd = {}, + .env_overrides = {}, + .timeout = std::chrono::seconds{60}, + .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 '{}'", attr))); + } + return std::unexpected(make_error( + util::ErrorCode::ResolutionNetworkError, + std::format("nix eval failed (exit {}): {}", r->exit_code, + r->stderr_text))); + } + + return parse_nix_eval_json(attr, r->stdout_text); +} + +} // namespace cargoxx::resolver diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index fc5f046..bea3886 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -1,5 +1,29 @@ export module cargoxx.resolver; +import std; import cargoxx.util; import cargoxx.exec; import cargoxx.linkdb; + +export namespace cargoxx::resolver { + +// What `nix eval nixpkgs#` reports for a package: a confirmation that +// the attribute exists, a best-effort version string, and the realized +// nix-store path so later probes can scan its installed CMake configs. +struct NixpkgsInfo { + std::string attr; // the queried name, e.g. "simdjson" + std::string version; // empty when the derivation has no version + std::string out_path; // absolute /nix/store/... path +}; + +// Pure parser exposed for unit testing. Accepts the raw JSON returned by +// `nix eval --json --apply 'p: { ... }'` and extracts NixpkgsInfo. +auto parse_nix_eval_json(std::string_view attr, std::string_view json) + -> util::Result; + +// Runs `nix eval nixpkgs# --json --apply ...` via exec::run. Returns +// `ResolutionUnknownPackage` when the attribute is missing, +// `ResolutionNetworkError` on timeout or evaluator errors. +auto nixpkgs_probe(const std::string& attr) -> util::Result; + +} // namespace cargoxx::resolver diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 27be4ff..fd37f7f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,3 +24,5 @@ cargoxx_add_test(cmd_run) cargoxx_add_test(cmd_clean) cargoxx_add_test(cmd_add) cargoxx_add_test(cmd_remove) +cargoxx_add_test(nixpkgs_probe_parse) +cargoxx_add_test(nixpkgs_probe_live) diff --git a/tests/nixpkgs_probe_live.cpp b/tests/nixpkgs_probe_live.cpp new file mode 100644 index 0000000..4ea1bf1 --- /dev/null +++ b/tests/nixpkgs_probe_live.cpp @@ -0,0 +1,40 @@ +// Network/nix-eval-gated integration test for resolver::nixpkgs_probe. +// Skipped unless CARGOXX_NETWORK_TESTS=1 is set in the environment. + +#include + +import cargoxx.resolver; +import cargoxx.util; +import std; + +namespace { + +auto network_tests_enabled() -> bool { + auto* env = std::getenv("CARGOXX_NETWORK_TESTS"); + return env != nullptr && std::string_view{env} == "1"; +} + +} // namespace + +TEST_CASE("nixpkgs_probe finds 'hello'", "[resolver][network]") { + if (!network_tests_enabled()) { + SKIP("CARGOXX_NETWORK_TESTS != 1"); + } + auto r = cargoxx::resolver::nixpkgs_probe("hello"); + REQUIRE(r.has_value()); + REQUIRE(r->attr == "hello"); + REQUIRE_FALSE(r->out_path.empty()); + REQUIRE(r->out_path.starts_with("/nix/store/")); + // GNU hello has a stable, conventional version field. + REQUIRE_FALSE(r->version.empty()); +} + +TEST_CASE("nixpkgs_probe rejects an unknown attribute", "[resolver][network]") { + if (!network_tests_enabled()) { + SKIP("CARGOXX_NETWORK_TESTS != 1"); + } + auto r = cargoxx::resolver::nixpkgs_probe( + "definitely_not_a_real_pkg_cargoxx_xyzzy"); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == cargoxx::util::ErrorCode::ResolutionUnknownPackage); +} diff --git a/tests/nixpkgs_probe_parse.cpp b/tests/nixpkgs_probe_parse.cpp new file mode 100644 index 0000000..8b98fbf --- /dev/null +++ b/tests/nixpkgs_probe_parse.cpp @@ -0,0 +1,55 @@ +#include + +import cargoxx.resolver; +import cargoxx.util; +import std; + +using cargoxx::resolver::parse_nix_eval_json; +using cargoxx::util::ErrorCode; + +TEST_CASE("parse_nix_eval_json extracts version and outPath", "[resolver][nixpkgs]") { + constexpr std::string_view input = R"({ + "version": "3.7.0", + "path": "/nix/store/abc-simdjson-3.7.0" + })"; + + auto r = parse_nix_eval_json("simdjson", input); + REQUIRE(r.has_value()); + REQUIRE(r->attr == "simdjson"); + REQUIRE(r->version == "3.7.0"); + REQUIRE(r->out_path == "/nix/store/abc-simdjson-3.7.0"); +} + +TEST_CASE("parse_nix_eval_json accepts an empty version field", + "[resolver][nixpkgs]") { + constexpr std::string_view input = R"({ + "version": "", + "path": "/nix/store/xyz-foo" + })"; + + auto r = parse_nix_eval_json("foo", input); + REQUIRE(r.has_value()); + REQUIRE(r->version.empty()); + REQUIRE(r->out_path == "/nix/store/xyz-foo"); +} + +TEST_CASE("parse_nix_eval_json fails when outPath is missing", + "[resolver][nixpkgs]") { + constexpr std::string_view input = R"({"version": "1.0"})"; + auto r = parse_nix_eval_json("foo", input); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ResolutionNetworkError); +} + +TEST_CASE("parse_nix_eval_json fails on garbage input", "[resolver][nixpkgs]") { + auto r = parse_nix_eval_json("foo", "not-json-at-all"); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ResolutionNetworkError); +} + +TEST_CASE("parse_nix_eval_json fails on a non-object root", + "[resolver][nixpkgs]") { + auto r = parse_nix_eval_json("foo", "[]"); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ResolutionNetworkError); +}