[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
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#<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`,
validates the new dep against the curated linkdb (so unknown packages
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/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

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;
import std;
import cargoxx.util;
import cargoxx.exec;
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_add)
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);
}