From df2c25b5599efec966d17bf3c148c3f9c61df341 Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Sun, 10 May 2026 12:09:58 +0000 Subject: [PATCH] [M5+] add resolver::devbox_resolve (search.devbox.sh) --- CHANGELOG.md | 13 ++++ CMakeLists.txt | 1 + src/resolver/resolver.cppm | 22 ++++++ src/resolver/search_devbox.cpp | 128 +++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 2 + tests/devbox_resolve_live.cpp | 44 ++++++++++++ tests/devbox_resolve_parse.cpp | 85 ++++++++++++++++++++++ 7 files changed, 295 insertions(+) create mode 100644 src/resolver/search_devbox.cpp create mode 100644 tests/devbox_resolve_live.cpp create mode 100644 tests/devbox_resolve_parse.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 898afd9..7ad51e2 100644 --- a/CHANGELOG.md +++ b/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=&version=` via curl + and pulls out `name`, `version`, `commit_hash`, `attr_paths`. Falls + back to the per-system `systems..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 ` now auto-resolves packages outside the curated linkdb. On `LinkdbUnknownPackage`, `cmd_add` invokes `resolver::discover` which: probes `nixpkgs#` to confirm the diff --git a/CMakeLists.txt b/CMakeLists.txt index eb146e0..f992808 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index ad7219d..0443396 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -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; +// 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 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; + +// Calls GET https://search.devbox.sh/v1/resolve?name=&version= +// via curl. 404 → ResolutionUnknownPackage; transport / parse errors → +// ResolutionNetworkError. +auto devbox_resolve(const std::string& name, const std::string& version) + -> util::Result; + } // namespace cargoxx::resolver diff --git a/src/resolver/search_devbox.cpp b/src/resolver/search_devbox.cpp new file mode 100644 index 0000000..21c4d6c --- /dev/null +++ b/src/resolver/search_devbox.cpp @@ -0,0 +1,128 @@ +module; + +#include + +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 { + 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(); + } + if (auto it = j.find("version"); it != j.end() && it->is_string()) { + out.version = it->get(); + } + if (auto it = j.find("commit_hash"); it != j.end() && it->is_string()) { + out.commit_hash = it->get(); + } + if (out.commit_hash.empty()) { + // Some responses omit the top-level commit_hash and surface it only + // per-system (`systems..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(); + 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()); + } + } + } + if (out.attr_paths.empty()) { + // attr_paths sometimes only appear under `systems.`. + 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()); + } + } + } + } + } + + 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 { + 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 425bfa8..9493129 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/devbox_resolve_live.cpp b/tests/devbox_resolve_live.cpp new file mode 100644 index 0000000..c58c524 --- /dev/null +++ b/tests/devbox_resolve_live.cpp @@ -0,0 +1,44 @@ +// Network-gated integration test for resolver::devbox_resolve. + +#include + +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); +} diff --git a/tests/devbox_resolve_parse.cpp b/tests/devbox_resolve_parse.cpp new file mode 100644 index 0000000..0c5c9b3 --- /dev/null +++ b/tests/devbox_resolve_parse.cpp @@ -0,0 +1,85 @@ +#include + +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{"fmt"}); +} + +TEST_CASE("parse_devbox_resolve falls back to systems..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{"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"); +}