[M5+] add resolver::devbox_resolve (search.devbox.sh)
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -78,6 +78,19 @@ 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::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
|
||||
linkdb. On `LinkdbUnknownPackage`, `cmd_add` invokes
|
||||
`resolver::discover` which: probes `nixpkgs#<pkg>` to confirm the
|
||||
|
||||
@@ -58,6 +58,7 @@ target_sources(cargoxx
|
||||
src/resolver/vcpkg_probe.cpp
|
||||
src/resolver/verify_link.cpp
|
||||
src/resolver/discover.cpp
|
||||
src/resolver/search_devbox.cpp
|
||||
src/cli/cmd_new.cpp
|
||||
src/cli/cmd_build.cpp
|
||||
src/cli/cmd_run.cpp
|
||||
|
||||
@@ -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)
|
||||
-> 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
|
||||
|
||||
128
src/resolver/search_devbox.cpp
Normal file
128
src/resolver/search_devbox.cpp
Normal 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
|
||||
@@ -33,3 +33,5 @@ cargoxx_add_test(conan_probe_live)
|
||||
cargoxx_add_test(vcpkg_probe_parse)
|
||||
cargoxx_add_test(vcpkg_probe_live)
|
||||
cargoxx_add_test(verify_link_unit)
|
||||
cargoxx_add_test(devbox_resolve_parse)
|
||||
cargoxx_add_test(devbox_resolve_live)
|
||||
|
||||
44
tests/devbox_resolve_live.cpp
Normal file
44
tests/devbox_resolve_live.cpp
Normal 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);
|
||||
}
|
||||
85
tests/devbox_resolve_parse.cpp
Normal file
85
tests/devbox_resolve_parse.cpp
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user