[M5+] add resolver::devbox_resolve (search.devbox.sh)

This commit is contained in:
2026-05-10 12:09:58 +00:00
parent afe1856e11
commit df2c25b559
7 changed files with 295 additions and 0 deletions

View File

@@ -78,6 +78,19 @@ 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::devbox_resolve(name, version)` and pure parser
`parse_devbox_resolve(json)` — port of devbox's
`internal/searcher/client.go` Resolve method. Hits
`https://search.devbox.sh/v1/resolve?name=<n>&version=<v>` via curl
and pulls out `name`, `version`, `commit_hash`, `attr_paths`. Falls
back to the per-system `systems.<plat>.commit_hash` when the
top-level field is empty (older response shapes). 404 →
`ResolutionUnknownPackage`, missing commit_hash →
`ResolutionVersionNotFound`, transport / parse errors →
`ResolutionNetworkError`. `tests/devbox_resolve_parse.cpp` covers
6 cases against fixtures derived from a real fmt 10.2.1 response;
`tests/devbox_resolve_live.cpp` (gated by `CARGOXX_NETWORK_TESTS=1`)
hits the live API.
- `cargoxx add <pkg>` now auto-resolves packages outside the curated - `cargoxx add <pkg>` now auto-resolves packages outside the curated
linkdb. On `LinkdbUnknownPackage`, `cmd_add` invokes linkdb. On `LinkdbUnknownPackage`, `cmd_add` invokes
`resolver::discover` which: probes `nixpkgs#<pkg>` to confirm the `resolver::discover` which: probes `nixpkgs#<pkg>` to confirm the

View File

@@ -58,6 +58,7 @@ target_sources(cargoxx
src/resolver/vcpkg_probe.cpp src/resolver/vcpkg_probe.cpp
src/resolver/verify_link.cpp src/resolver/verify_link.cpp
src/resolver/discover.cpp src/resolver/discover.cpp
src/resolver/search_devbox.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

@@ -136,4 +136,26 @@ auto discover(const std::string& name, const std::string& version_spec,
const std::filesystem::path& scratch_root, const BuildFn& build_fn) const std::filesystem::path& scratch_root, const BuildFn& build_fn)
-> util::Result<Discovered>; -> util::Result<Discovered>;
// Output of devbox's /v1/resolve API. We capture only the fields cargoxx
// uses; the response carries far more metadata (license, summary, per-
// system store hashes) that we deliberately ignore.
struct DevboxResolution {
std::string name;
std::string version;
std::string commit_hash;
std::vector<std::string> attr_paths;
};
// Pure: parse the JSON body of GET https://search.devbox.sh/v1/resolve.
// `commit_hash` must be present and non-empty; other fields tolerate
// absence by leaving themselves blank.
auto parse_devbox_resolve(std::string_view json)
-> util::Result<DevboxResolution>;
// Calls GET https://search.devbox.sh/v1/resolve?name=<n>&version=<v>
// via curl. 404 → ResolutionUnknownPackage; transport / parse errors →
// ResolutionNetworkError.
auto devbox_resolve(const std::string& name, const std::string& version)
-> util::Result<DevboxResolution>;
} // namespace cargoxx::resolver } // namespace cargoxx::resolver

View File

@@ -0,0 +1,128 @@
module;
#include <json.hpp>
module cargoxx.resolver;
import std;
import cargoxx.util;
import cargoxx.exec;
namespace cargoxx::resolver {
namespace {
auto error(util::ErrorCode code, std::string msg) -> util::Error {
return util::Error{code, std::move(msg), "", std::nullopt, std::nullopt};
}
} // namespace
auto parse_devbox_resolve(std::string_view json) -> util::Result<DevboxResolution> {
nlohmann::json j;
try {
j = nlohmann::json::parse(json);
} catch (const nlohmann::json::parse_error& e) {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
std::format("devbox /v1/resolve returned unparseable JSON: {}", e.what())));
}
if (!j.is_object()) {
return std::unexpected(error(util::ErrorCode::ResolutionNetworkError,
"devbox /v1/resolve response is not an object"));
}
DevboxResolution out;
if (auto it = j.find("name"); it != j.end() && it->is_string()) {
out.name = it->get<std::string>();
}
if (auto it = j.find("version"); it != j.end() && it->is_string()) {
out.version = it->get<std::string>();
}
if (auto it = j.find("commit_hash"); it != j.end() && it->is_string()) {
out.commit_hash = it->get<std::string>();
}
if (out.commit_hash.empty()) {
// Some responses omit the top-level commit_hash and surface it only
// per-system (`systems.<plat>.commit_hash`). Fall back to the first
// non-empty value we find under `systems`.
if (auto sys_it = j.find("systems");
sys_it != j.end() && sys_it->is_object()) {
for (const auto& [_, sys] : sys_it->items()) {
if (auto ch = sys.find("commit_hash");
ch != sys.end() && ch->is_string()) {
out.commit_hash = ch->get<std::string>();
if (!out.commit_hash.empty()) {
break;
}
}
}
}
}
if (auto it = j.find("attr_paths");
it != j.end() && it->is_array()) {
for (const auto& el : *it) {
if (el.is_string()) {
out.attr_paths.push_back(el.get<std::string>());
}
}
}
if (out.attr_paths.empty()) {
// attr_paths sometimes only appear under `systems.<plat>`.
if (auto sys_it = j.find("systems");
sys_it != j.end() && sys_it->is_object() && !sys_it->empty()) {
const auto& any_sys = sys_it->begin().value();
if (auto ap = any_sys.find("attr_paths");
ap != any_sys.end() && ap->is_array()) {
for (const auto& el : *ap) {
if (el.is_string()) {
out.attr_paths.push_back(el.get<std::string>());
}
}
}
}
}
if (out.commit_hash.empty()) {
return std::unexpected(error(
util::ErrorCode::ResolutionVersionNotFound,
"devbox response carried no commit_hash"));
}
return out;
}
auto devbox_resolve(const std::string& name, const std::string& version)
-> util::Result<DevboxResolution> {
if (name.empty() || version.empty()) {
return std::unexpected(error(util::ErrorCode::ResolutionUnknownPackage,
"devbox_resolve: name and version are required"));
}
auto url = std::format(
"https://search.devbox.sh/v1/resolve?name={}&version={}", name, version);
auto r = exec::run("curl", {"-fsSL", "--max-time", "10", url},
exec::ExecOptions{
.cwd = {},
.env_overrides = {},
.timeout = std::chrono::seconds{15},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code == 22) {
return std::unexpected(error(
util::ErrorCode::ResolutionUnknownPackage,
std::format("devbox /v1/resolve has no entry for {} {}", name, version)));
}
if (r->exit_code != 0) {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
std::format("curl failed (exit {}): {}", r->exit_code, r->stderr_text)));
}
return parse_devbox_resolve(r->stdout_text);
}
} // namespace cargoxx::resolver

View File

@@ -33,3 +33,5 @@ cargoxx_add_test(conan_probe_live)
cargoxx_add_test(vcpkg_probe_parse) cargoxx_add_test(vcpkg_probe_parse)
cargoxx_add_test(vcpkg_probe_live) cargoxx_add_test(vcpkg_probe_live)
cargoxx_add_test(verify_link_unit) cargoxx_add_test(verify_link_unit)
cargoxx_add_test(devbox_resolve_parse)
cargoxx_add_test(devbox_resolve_live)

View File

@@ -0,0 +1,44 @@
// Network-gated integration test for resolver::devbox_resolve.
#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("devbox_resolve returns a commit_hash for fmt 10.2.1",
"[resolver][network]") {
if (!network_tests_enabled()) {
SKIP("CARGOXX_NETWORK_TESTS != 1");
}
auto r = cargoxx::resolver::devbox_resolve("fmt", "10.2.1");
REQUIRE(r.has_value());
REQUIRE(r->name == "fmt");
REQUIRE(r->version == "10.2.1");
REQUIRE(r->commit_hash.size() == 40); // git sha
// attr_paths should at minimum include the package name itself.
REQUIRE(std::ranges::find(r->attr_paths, std::string{"fmt"}) !=
r->attr_paths.end());
}
TEST_CASE("devbox_resolve returns ResolutionUnknownPackage on 404",
"[resolver][network]") {
if (!network_tests_enabled()) {
SKIP("CARGOXX_NETWORK_TESTS != 1");
}
auto r = cargoxx::resolver::devbox_resolve(
"definitely_not_a_real_pkg_cargoxx_xyzzy", "1.0");
REQUIRE_FALSE(r.has_value());
INFO("error msg: " << r.error().message);
REQUIRE(r.error().code ==
cargoxx::util::ErrorCode::ResolutionUnknownPackage);
}

View File

@@ -0,0 +1,85 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.resolver;
import cargoxx.util;
import std;
using cargoxx::resolver::parse_devbox_resolve;
using cargoxx::util::ErrorCode;
TEST_CASE("parse_devbox_resolve extracts the canonical fields",
"[resolver][devbox]") {
// Trimmed shape of the real response for `fmt 10.2.1` from
// https://search.devbox.sh/v1/resolve?name=fmt&version=10.2.1
constexpr std::string_view input = R"({
"commit_hash": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde",
"version": "10.2.1",
"name": "fmt",
"attr_paths": ["fmt"]
})";
auto r = parse_devbox_resolve(input);
REQUIRE(r.has_value());
REQUIRE(r->name == "fmt");
REQUIRE(r->version == "10.2.1");
REQUIRE(r->commit_hash == "f4b140d5b253f5e2a1ff4e5506edbf8267724bde");
REQUIRE(r->attr_paths == std::vector<std::string>{"fmt"});
}
TEST_CASE("parse_devbox_resolve falls back to systems.<plat>.commit_hash",
"[resolver][devbox]") {
// Older / partial responses omit the top-level commit_hash but still
// ship per-system entries.
constexpr std::string_view input = R"({
"name": "spdlog",
"version": "1.13.0",
"systems": {
"x86_64-linux": {
"commit_hash": "abc123def456",
"attr_paths": ["spdlog"]
}
}
})";
auto r = parse_devbox_resolve(input);
REQUIRE(r.has_value());
REQUIRE(r->commit_hash == "abc123def456");
REQUIRE(r->attr_paths == std::vector<std::string>{"spdlog"});
}
TEST_CASE("parse_devbox_resolve fails on missing commit_hash",
"[resolver][devbox]") {
constexpr std::string_view input = R"({
"name": "fmt",
"version": "10.2.1"
})";
auto r = parse_devbox_resolve(input);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionVersionNotFound);
}
TEST_CASE("parse_devbox_resolve fails on garbage input", "[resolver][devbox]") {
auto r = parse_devbox_resolve("not-json");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionNetworkError);
}
TEST_CASE("parse_devbox_resolve fails on non-object root", "[resolver][devbox]") {
auto r = parse_devbox_resolve("[]");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionNetworkError);
}
TEST_CASE("parse_devbox_resolve tolerates an empty attr_paths array",
"[resolver][devbox]") {
constexpr std::string_view input = R"({
"name": "fmt",
"version": "10.2.1",
"commit_hash": "abc",
"attr_paths": []
})";
auto r = parse_devbox_resolve(input);
REQUIRE(r.has_value());
REQUIRE(r->attr_paths.empty());
REQUIRE(r->commit_hash == "abc");
}