From 816ec993cd3bf3559fbd0dbae1c640d8b343ff19 Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Sun, 10 May 2026 10:32:58 +0000 Subject: [PATCH] [M5+] add resolver::verify_link + provisional overlay lifecycle --- CHANGELOG.md | 14 +++ CMakeLists.txt | 1 + docs/auto-resolution.md | 4 +- src/linkdb/linkdb.cppm | 33 +++++++ src/linkdb/overlay.cpp | 133 ++++++++++++++++++++++++++++ src/resolver/resolver.cppm | 26 ++++++ src/resolver/verify_link.cpp | 162 +++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/verify_link_unit.cpp | 118 +++++++++++++++++++++++++ 9 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 src/resolver/verify_link.cpp create mode 100644 tests/verify_link_unit.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f9abaa..52380f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,20 @@ 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.linkdb::Database::insert_provisional`, + `confirm_provisional`, and `abort_provisional` — three-step lifecycle + for non-`manual` overlay rows: insert with `verified_at = 0`, run a + build, then either bump `verified_at = now` on success or `DELETE` + the row on failure. +- `cargoxx.resolver::verify_link(req, build_fn)` — scaffolds a tiny + `Cargoxx.toml` + `int main() {}` project under a scratch dir, writes + a provisional overlay row pointing at the candidate `Recipe`, runs + the caller-supplied `BuildFn` (typically `cli::cmd_build` injected + to avoid a resolver-on-cli dep cycle), and on success/failure + confirms or aborts the provisional row. Cleans the scratch project + via RAII regardless of outcome. `tests/verify_link_unit.cpp` exercises + both success and failure paths against a mock `BuildFn`, verifying + that the overlay row's lifecycle matches the build outcome. - `cargoxx.resolver::vcpkg_probe(name)` — fetches `https://raw.githubusercontent.com/microsoft/vcpkg/master/ports//usage` and feeds it through `parse_vcpkg_usage`. The pure parser extracts diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c26fe2..91ad100 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,7 @@ target_sources(cargoxx src/resolver/nix_cmake_scan.cpp src/resolver/conan_probe.cpp src/resolver/vcpkg_probe.cpp + src/resolver/verify_link.cpp src/cli/cmd_new.cpp src/cli/cmd_build.cpp src/cli/cmd_run.cpp diff --git a/docs/auto-resolution.md b/docs/auto-resolution.md index cabefcd..373d0a2 100644 --- a/docs/auto-resolution.md +++ b/docs/auto-resolution.md @@ -195,8 +195,8 @@ auto verify_link(const Recipe& candidate, | 1. nixpkgs_probe + JSON parser | ✅ | `1c7ff39` | | 2. nix_cmake_scan | ✅ | `e63ac69` | | 3. conan_probe + parse_conanfile | ✅ | `e5c173b` | -| 4. vcpkg_probe + parse_vcpkg_usage | ✅ | (this commit) | -| 5. verify_link (tmp project + cmd_build) | pending | — | +| 4. vcpkg_probe + parse_vcpkg_usage | ✅ | `941d5b3` | +| 5. verify_link (tmp project + cmd_build) | ✅ | (this commit) | | 6. Database::discover + cmd_add wire-up + failure caching | pending | — | ## Testing strategy diff --git a/src/linkdb/linkdb.cppm b/src/linkdb/linkdb.cppm index 9976086..3f8c711 100644 --- a/src/linkdb/linkdb.cppm +++ b/src/linkdb/linkdb.cppm @@ -71,6 +71,25 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package, const cargoxx::linkdb::Recipe& r) -> cargoxx::util::Result; +// Insert a row from a non-manual probe. `verified_at = 0` flags the row as +// provisional; the verify-link step bumps it to the current epoch on +// success or deletes the row on failure. +auto overlay_insert_provisional(OverlayState& state, const std::string& package, + const std::string& version_range, + const cargoxx::linkdb::Recipe& r, + const std::string& source) + -> cargoxx::util::Result; + +auto overlay_confirm_provisional(OverlayState& state, const std::string& package, + const std::string& version_range, + const std::string& source) + -> cargoxx::util::Result; + +auto overlay_delete_recipe(OverlayState& state, const std::string& package, + const std::string& version_range, + const std::string& source) + -> cargoxx::util::Result; + auto overlay_query(OverlayState& state, const std::string& package) -> cargoxx::util::Result>; @@ -92,6 +111,20 @@ class Database { auto add_manual(const std::string& package, const std::string& version_range, const Recipe& r) -> util::Result; + // Provisional-recipe lifecycle, used by the resolver's verify-link step. + // `source` should be one of "conan", "vcpkg", "nix-probe", or any other + // probe identifier (NOT "manual"/"curated", which have their own + // contracts). + auto insert_provisional(const std::string& package, + const std::string& version_range, const Recipe& r, + const std::string& source) -> util::Result; + auto confirm_provisional(const std::string& package, + const std::string& version_range, + const std::string& source) -> util::Result; + auto abort_provisional(const std::string& package, + const std::string& version_range, + const std::string& source) -> util::Result; + private: Database() = default; std::map> curated_; diff --git a/src/linkdb/overlay.cpp b/src/linkdb/overlay.cpp index 6472cb9..c2ee385 100644 --- a/src/linkdb/overlay.cpp +++ b/src/linkdb/overlay.cpp @@ -128,6 +128,98 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package, return {}; } +namespace { + +auto overlay_insert(OverlayState& state, const std::string& package, + const std::string& version_range, const Recipe& r, + const std::string& source, std::int64_t verified_at) + -> util::Result { + constexpr const char* SQL = + "INSERT OR REPLACE INTO recipes " + "(package, version_range, nixpkgs_attr, find_package, targets, components, source, " + " verified_at) " + "VALUES (?, ?, ?, ?, ?, NULL, ?, ?)"; + + sqlite3* db = state.handle(); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, SQL, -1, &stmt, nullptr) != SQLITE_OK) { + return std::unexpected(sqlite_error(db, "prepare insert")); + } + + auto targets_str = nlohmann::json(r.targets).dump(); + + sqlite3_bind_text(stmt, 1, package.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, version_range.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, r.nixpkgs_attr.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, r.find_package.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 6, source.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(stmt, 7, verified_at); + + auto rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return std::unexpected(sqlite_error(db, "step insert")); + } + return {}; +} + +} // namespace + +auto overlay_insert_provisional(OverlayState& state, const std::string& package, + const std::string& version_range, const Recipe& r, + const std::string& source) -> util::Result { + return overlay_insert(state, package, version_range, r, source, /*verified_at=*/0); +} + +auto overlay_confirm_provisional(OverlayState& state, const std::string& package, + const std::string& version_range, + const std::string& source) -> util::Result { + constexpr const char* SQL = + "UPDATE recipes SET verified_at = ? " + "WHERE package = ? AND version_range = ? AND source = ?"; + + sqlite3* db = state.handle(); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, SQL, -1, &stmt, nullptr) != SQLITE_OK) { + return std::unexpected(sqlite_error(db, "prepare update")); + } + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + sqlite3_bind_int64(stmt, 1, now); + sqlite3_bind_text(stmt, 2, package.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, version_range.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, source.c_str(), -1, SQLITE_TRANSIENT); + auto rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return std::unexpected(sqlite_error(db, "step update")); + } + return {}; +} + +auto overlay_delete_recipe(OverlayState& state, const std::string& package, + const std::string& version_range, + const std::string& source) -> util::Result { + constexpr const char* SQL = + "DELETE FROM recipes WHERE package = ? AND version_range = ? AND source = ?"; + sqlite3* db = state.handle(); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, SQL, -1, &stmt, nullptr) != SQLITE_OK) { + return std::unexpected(sqlite_error(db, "prepare delete")); + } + sqlite3_bind_text(stmt, 1, package.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, version_range.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, source.c_str(), -1, SQLITE_TRANSIENT); + auto rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return std::unexpected(sqlite_error(db, "step delete")); + } + return {}; +} + auto overlay_query(OverlayState& state, const std::string& package) -> util::Result> { constexpr const char* SQL = @@ -192,4 +284,45 @@ auto Database::add_manual(const std::string& package, const std::string& version return detail::overlay_insert_manual(*overlay_, package, version_range, r); } +namespace { +auto require_overlay(const std::unique_ptr& o) + -> util::Result { + if (!o) { + return std::unexpected(util::Error{ + util::ErrorCode::Internal, "no overlay database is open", "", + std::nullopt, std::nullopt, + }); + } + return {}; +} +} // namespace + +auto Database::insert_provisional(const std::string& package, + const std::string& version_range, + const Recipe& r, const std::string& source) + -> util::Result { + if (auto ok = require_overlay(overlay_); !ok) { + return std::unexpected(ok.error()); + } + return detail::overlay_insert_provisional(*overlay_, package, version_range, r, source); +} + +auto Database::confirm_provisional(const std::string& package, + const std::string& version_range, + const std::string& source) -> util::Result { + if (auto ok = require_overlay(overlay_); !ok) { + return std::unexpected(ok.error()); + } + return detail::overlay_confirm_provisional(*overlay_, package, version_range, source); +} + +auto Database::abort_provisional(const std::string& package, + const std::string& version_range, + const std::string& source) -> util::Result { + if (auto ok = require_overlay(overlay_); !ok) { + return std::unexpected(ok.error()); + } + return detail::overlay_delete_recipe(*overlay_, package, version_range, source); +} + } // namespace cargoxx::linkdb diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index 6c1a6ee..5d3d2bd 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -4,6 +4,7 @@ import std; import cargoxx.util; import cargoxx.exec; import cargoxx.linkdb; +import cargoxx.manifest; export namespace cargoxx::resolver { @@ -89,4 +90,29 @@ auto parse_vcpkg_usage(std::string_view usage_text) // ResolutionUnknownPackage; transport errors → ResolutionNetworkError. auto vcpkg_probe(const std::string& name) -> util::Result; +// Caller-supplied closure that runs `cargoxx build` (or any equivalent +// build) on a project rooted at the given path. Injected so the resolver +// stays decoupled from `cargoxx.cli`. +using BuildFn = + std::function(const std::filesystem::path& project_root)>; + +struct VerifyLinkRequest { + linkdb::Recipe candidate; // recipe under test + std::string source; // "conan" | "vcpkg" | "nix-probe" + std::string package_name; + std::string version_spec; // user-supplied spec (e.g. "*", "1.2") + std::vector components; + std::filesystem::path overlay_path; // sqlite file we read/write + std::filesystem::path scratch_root; // parent dir for the tmp project +}; + +// Scaffolds a tiny Cargoxx project under `req.scratch_root`, writes a +// provisional overlay row pointing at `req.candidate`, runs `build_fn` on +// the project (typically `cli::cmd_build`), and depending on the build +// result either confirms the provisional row (verified_at = now) or +// deletes it. Cleans the tmp project regardless. Returns success only +// when the build itself succeeded. +auto verify_link(const VerifyLinkRequest& req, const BuildFn& build_fn) + -> util::Result; + } // namespace cargoxx::resolver diff --git a/src/resolver/verify_link.cpp b/src/resolver/verify_link.cpp new file mode 100644 index 0000000..3917927 --- /dev/null +++ b/src/resolver/verify_link.cpp @@ -0,0 +1,162 @@ +module cargoxx.resolver; + +import std; +import cargoxx.util; +import cargoxx.linkdb; +import cargoxx.manifest; + +namespace cargoxx::resolver { + +namespace fs = std::filesystem; + +namespace { + +auto io_error(std::string msg, fs::path path) -> util::Error { + return util::Error{ + util::ErrorCode::Internal, std::move(msg), "", std::move(path), std::nullopt, + }; +} + +auto write_text(const fs::path& path, std::string_view content) -> util::Result { + std::ofstream out{path}; + if (!out) { + return std::unexpected( + io_error(std::format("cannot open for writing: {}", path.string()), path)); + } + out << content; + if (!out) { + return std::unexpected( + io_error(std::format("write failed: {}", path.string()), path)); + } + return {}; +} + +class TmpProject { + public: + explicit TmpProject(fs::path root) : root_(std::move(root)) {} + ~TmpProject() { + std::error_code ec; + fs::remove_all(root_, ec); + } + TmpProject(const TmpProject&) = delete; + TmpProject& operator=(const TmpProject&) = delete; + TmpProject(TmpProject&&) = delete; + TmpProject& operator=(TmpProject&&) = delete; + + [[nodiscard]] auto path() const -> const fs::path& { return root_; } + + private: + fs::path root_; +}; + +} // namespace + +auto verify_link(const VerifyLinkRequest& req, const BuildFn& build_fn) + -> util::Result { + if (req.package_name.empty()) { + return std::unexpected(util::Error{ + util::ErrorCode::Internal, + "verify_link: package_name is empty", + "", std::nullopt, std::nullopt, + }); + } + + // Insert the provisional overlay row. It persists through the build's + // own Database::open() call, which is how the candidate recipe gets + // surfaced to cmake_lists codegen via Database::resolve. + { + auto db = linkdb::Database::open(req.overlay_path); + if (!db) { + return std::unexpected(db.error()); + } + if (auto r = db->insert_provisional(req.package_name, req.version_spec, + req.candidate, req.source); + !r) { + return std::unexpected(r.error()); + } + } // close db before re-opening it inside build_fn + + // Scaffold a tmp project. We bypass cargoxx::cli::cmd_new to avoid a + // resolver-on-cli dependency cycle; the manifest + src/main.cpp are + // exactly what cmd_build needs for its codegen. + std::error_code ec; + fs::create_directories(req.scratch_root, ec); + if (ec) { + return std::unexpected(io_error( + std::format("cannot create scratch root: {}", ec.message()), + req.scratch_root)); + } + auto proj_root = req.scratch_root / + std::format("cargoxx-verify-{}-{}", req.package_name, + std::random_device{}()); + TmpProject scope{proj_root}; + + fs::create_directories(proj_root / "src", ec); + if (ec) { + // Roll back the provisional row before bailing. + auto db = linkdb::Database::open(req.overlay_path); + if (db) { + (void)db->abort_provisional(req.package_name, req.version_spec, + req.source); + } + return std::unexpected(io_error( + std::format("cannot create '{}': {}", (proj_root / "src").string(), + ec.message()), + proj_root)); + } + + manifest::Manifest m{ + .package = manifest::Package{ + .name = req.package_name + "_verify", + .version = "0.0.0", + .edition = manifest::Edition::Cpp23, + .authors = {}, + .license = std::nullopt, + }, + .dependencies = {manifest::Dependency{ + .name = req.package_name, + .version_spec = req.version_spec, + .components = req.components, + }}, + .build = {}, + }; + if (auto r = manifest::write(m, proj_root / "Cargoxx.toml"); !r) { + auto db = linkdb::Database::open(req.overlay_path); + if (db) { + (void)db->abort_provisional(req.package_name, req.version_spec, + req.source); + } + return std::unexpected(r.error()); + } + + // Empty main — exercises find_package + target + linker without + // requiring per-package symbol knowledge. + if (auto r = write_text(proj_root / "src" / "main.cpp", "int main() {}\n"); !r) { + auto db = linkdb::Database::open(req.overlay_path); + if (db) { + (void)db->abort_provisional(req.package_name, req.version_spec, + req.source); + } + return std::unexpected(r.error()); + } + + auto build_result = build_fn(proj_root); + + // Always re-open the db to flip the provisional row's fate. + auto db = linkdb::Database::open(req.overlay_path); + if (!db) { + return std::unexpected(db.error()); + } + if (build_result) { + if (auto r = db->confirm_provisional(req.package_name, req.version_spec, + req.source); + !r) { + return std::unexpected(r.error()); + } + return {}; + } + (void)db->abort_provisional(req.package_name, req.version_spec, req.source); + return std::unexpected(build_result.error()); +} + +} // namespace cargoxx::resolver diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 422634a..425bfa8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,3 +32,4 @@ cargoxx_add_test(conan_probe_parse) cargoxx_add_test(conan_probe_live) cargoxx_add_test(vcpkg_probe_parse) cargoxx_add_test(vcpkg_probe_live) +cargoxx_add_test(verify_link_unit) diff --git a/tests/verify_link_unit.cpp b/tests/verify_link_unit.cpp new file mode 100644 index 0000000..3edd4c2 --- /dev/null +++ b/tests/verify_link_unit.cpp @@ -0,0 +1,118 @@ +// Unit tests for resolver::verify_link with a mocked BuildFn that never +// invokes nix or cmake. Covers the provisional-row lifecycle and the +// scratch-project scaffolding without paying the cost of a real build. + +#include + +import cargoxx.resolver; +import cargoxx.linkdb; +import cargoxx.manifest; +import cargoxx.util; +import std; + +using cargoxx::resolver::verify_link; +using cargoxx::resolver::VerifyLinkRequest; +using cargoxx::linkdb::Database; +using cargoxx::linkdb::Recipe; + +namespace { + +auto fresh_dir() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-verify-link-test-{}", std::random_device{}()); + std::filesystem::create_directories(d); + return d; +} + +auto sample_recipe() -> Recipe { + return Recipe{ + .nixpkgs_attr = "fmt", + .find_package = "fmt CONFIG REQUIRED", + .targets = {"fmt::fmt"}, + .source = "conan", + }; +} + +auto make_request(const std::filesystem::path& parent) -> VerifyLinkRequest { + return VerifyLinkRequest{ + .candidate = sample_recipe(), + .source = "conan", + .package_name = "fmt", + .version_spec = "*", + .components = {}, + .overlay_path = parent / "overlay.sqlite", + .scratch_root = parent / "scratch", + }; +} + +} // namespace + +TEST_CASE("verify_link confirms the provisional row when the build succeeds", + "[resolver][verify_link]") { + auto parent = fresh_dir(); + auto req = make_request(parent); + + bool build_called = false; + auto r = verify_link(req, [&](const std::filesystem::path& root) { + build_called = true; + // Sanity-check the scaffold: the manifest and main.cpp must exist + // in the dir handed to the build function. + REQUIRE(std::filesystem::exists(root / "Cargoxx.toml")); + REQUIRE(std::filesystem::exists(root / "src" / "main.cpp")); + auto m = cargoxx::manifest::parse(root / "Cargoxx.toml"); + REQUIRE(m.has_value()); + REQUIRE(m->dependencies.size() == 1); + REQUIRE(m->dependencies[0].name == "fmt"); + return cargoxx::util::Result{}; + }); + REQUIRE(build_called); + REQUIRE(r.has_value()); + + // Overlay row should now be confirmed (verified_at != 0). The simplest + // way to check is to resolve through the Database — confirmed rows + // satisfy `overlay_is_fresh` and surface as the matching recipe. + auto db = Database::open(req.overlay_path); + REQUIRE(db.has_value()); + auto rec = db->resolve("fmt", "*", {}); + REQUIRE(rec.has_value()); + REQUIRE(rec->source == "conan"); + REQUIRE(rec->find_package == "fmt CONFIG REQUIRED"); +} + +TEST_CASE("verify_link rolls the provisional row back when the build fails", + "[resolver][verify_link]") { + auto parent = fresh_dir(); + auto req = make_request(parent); + + auto r = verify_link(req, [&](const std::filesystem::path&) { + return cargoxx::util::Result{std::unexpected(cargoxx::util::Error{ + cargoxx::util::ErrorCode::BuildCmakeFailed, + "fake build failure", + "", std::nullopt, std::nullopt, + })}; + }); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == cargoxx::util::ErrorCode::BuildCmakeFailed); + + // The conan-source row must be gone; resolve falls through to the + // curated linkdb (which has its own fmt recipe with source = "curated"). + auto db = Database::open(req.overlay_path); + REQUIRE(db.has_value()); + auto rec = db->resolve("fmt", "10.2.0", {}); + REQUIRE(rec.has_value()); + REQUIRE(rec->source == "curated"); +} + +TEST_CASE("verify_link cleans up its scratch project", "[resolver][verify_link]") { + auto parent = fresh_dir(); + auto req = make_request(parent); + std::filesystem::path captured; + + (void)verify_link(req, [&](const std::filesystem::path& root) { + captured = root; + return cargoxx::util::Result{}; + }); + + REQUIRE_FALSE(captured.empty()); + REQUIRE_FALSE(std::filesystem::exists(captured)); +}