[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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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_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)
|
||||||
|
|||||||
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