[M5+] add resolver::nixpkgs_probe (nix eval wrapper)

This commit is contained in:
2026-05-10 09:52:06 +00:00
parent f3d18b7939
commit 1c7ff39f64
7 changed files with 239 additions and 0 deletions

View File

@@ -78,6 +78,17 @@ All notable changes to cargoxx will be documented in this file.
and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source
paths emitted relative to `build/` (i.e. prefixed with `../`). paths emitted relative to `build/` (i.e. prefixed with `../`).
Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases. Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases.
- `cargoxx.resolver::nixpkgs_probe(attr)` — runs
`nix eval nixpkgs#<attr> --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 <pkg>[@<version>] [--components a,b]` edits `Cargoxx.toml`, - `cargoxx add <pkg>[@<version>] [--components a,b]` edits `Cargoxx.toml`,
validates the new dep against the curated linkdb (so unknown packages validates the new dep against the curated linkdb (so unknown packages
and missing components fail before any disk write), and rejects and missing components fail before any disk write), and rejects

View File

@@ -52,6 +52,7 @@ target_sources(cargoxx
src/codegen/flake.cpp src/codegen/flake.cpp
src/codegen/cmake.cpp src/codegen/cmake.cpp
src/exec/subprocess.cpp src/exec/subprocess.cpp
src/resolver/nixpkgs_probe.cpp
src/cli/cmd_new.cpp src/cli/cmd_new.cpp
src/cli/cmd_build.cpp src/cli/cmd_build.cpp
src/cli/cmd_run.cpp src/cli/cmd_run.cpp

View File

@@ -0,0 +1,106 @@
module;
#include <json.hpp>
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<NixpkgsInfo> {
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<std::string>();
} 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<std::string>();
}
return info;
}
auto nixpkgs_probe(const std::string& attr) -> util::Result<NixpkgsInfo> {
if (attr.empty()) {
return std::unexpected(make_error(util::ErrorCode::ResolutionUnknownPackage,
"package name is empty"));
}
std::vector<std::string> 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

View File

@@ -1,5 +1,29 @@
export module cargoxx.resolver; export module cargoxx.resolver;
import std;
import cargoxx.util; import cargoxx.util;
import cargoxx.exec; import cargoxx.exec;
import cargoxx.linkdb; import cargoxx.linkdb;
export namespace cargoxx::resolver {
// What `nix eval nixpkgs#<pkg>` 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<NixpkgsInfo>;
// Runs `nix eval nixpkgs#<attr> --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<NixpkgsInfo>;
} // namespace cargoxx::resolver

View File

@@ -24,3 +24,5 @@ cargoxx_add_test(cmd_run)
cargoxx_add_test(cmd_clean) cargoxx_add_test(cmd_clean)
cargoxx_add_test(cmd_add) cargoxx_add_test(cmd_add)
cargoxx_add_test(cmd_remove) cargoxx_add_test(cmd_remove)
cargoxx_add_test(nixpkgs_probe_parse)
cargoxx_add_test(nixpkgs_probe_live)

View File

@@ -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 <catch2/catch_test_macros.hpp>
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);
}

View File

@@ -0,0 +1,55 @@
#include <catch2/catch_test_macros.hpp>
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);
}