[M5+] add resolver::verify_link + provisional overlay lifecycle

This commit is contained in:
2026-05-10 10:32:58 +00:00
parent 941d5b3284
commit 816ec993cd
9 changed files with 490 additions and 2 deletions

View File

@@ -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/<name>/usage`
and feeds it through `parse_vcpkg_usage`. The pure parser extracts

View File

@@ -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

View File

@@ -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

View File

@@ -71,6 +71,25 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
const cargoxx::linkdb::Recipe& r)
-> cargoxx::util::Result<void>;
// 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<void>;
auto overlay_confirm_provisional(OverlayState& state, const std::string& package,
const std::string& version_range,
const std::string& source)
-> cargoxx::util::Result<void>;
auto overlay_delete_recipe(OverlayState& state, const std::string& package,
const std::string& version_range,
const std::string& source)
-> cargoxx::util::Result<void>;
auto overlay_query(OverlayState& state, const std::string& package)
-> cargoxx::util::Result<std::vector<OverlayRow>>;
@@ -92,6 +111,20 @@ class Database {
auto add_manual(const std::string& package, const std::string& version_range,
const Recipe& r) -> util::Result<void>;
// 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<void>;
auto confirm_provisional(const std::string& package,
const std::string& version_range,
const std::string& source) -> util::Result<void>;
auto abort_provisional(const std::string& package,
const std::string& version_range,
const std::string& source) -> util::Result<void>;
private:
Database() = default;
std::map<std::string, std::vector<detail::CuratedRecipe>> curated_;

View File

@@ -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<void> {
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<void> {
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<void> {
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::seconds>(
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<void> {
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<std::vector<OverlayRow>> {
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<detail::OverlayState>& o)
-> util::Result<void> {
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<void> {
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<void> {
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<void> {
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

View File

@@ -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<VcpkgRecipe>;
// 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<util::Result<void>(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<std::string> 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<void>;
} // namespace cargoxx::resolver

View File

@@ -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<void> {
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<void> {
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

View File

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

118
tests/verify_link_unit.cpp Normal file
View File

@@ -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 <catch2/catch_test_macros.hpp>
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<void>{};
});
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<void>{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<void>{};
});
REQUIRE_FALSE(captured.empty());
REQUIRE_FALSE(std::filesystem::exists(captured));
}