From cafa403a581a0f3576c8a6df27a0f6d0cca94ae0 Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Fri, 8 May 2026 12:14:24 +0000 Subject: [PATCH] [M2] add SQLite overlay + add_manual --- CHANGELOG.md | 13 +++ CMakeLists.txt | 5 + src/linkdb/curated.cpp | 91 +++++++++++++----- src/linkdb/linkdb.cppm | 54 ++++++++++- src/linkdb/overlay.cpp | 195 +++++++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/linkdb_lookup.cpp | 61 ++++++------ tests/linkdb_overlay.cpp | 153 ++++++++++++++++++++++++++++++ 8 files changed, 524 insertions(+), 49 deletions(-) create mode 100644 src/linkdb/overlay.cpp create mode 100644 tests/linkdb_overlay.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index ab05763..7a64430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,3 +57,16 @@ All notable changes to cargoxx will be documented in this file. are deferred to the M2 follow-up commit. `tests/linkdb_lookup.cpp` covers 13 cases including a smoke test that resolves all 25 curated packages. +- SQLite overlay: `Database::open(overlay_path)` now opens (and creates, + if missing) a per-user `linkdb.sqlite` cache, applying the schema from + `SPEC.md` §9 idempotently. Default path is + `$XDG_CACHE_HOME/cargoxx/linkdb.sqlite` (falling back to + `$HOME/.cache/cargoxx/...`); tests pass an explicit temp path so they + never touch the user cache. `Database::add_manual(pkg, range, recipe)` + inserts a row with `source = 'manual'` and `verified_at = now()`; + `resolve()` consults the overlay first and falls back to curated when + no overlay row matches the requested version range. Manual entries + never expire; `nix-probe` (v0.2) entries respect a 30-day freshness + window. `tests/linkdb_overlay.cpp` covers 7 cases (insert/persist, + override-curated, version-range gating, components rejection, + move semantics). diff --git a/CMakeLists.txt b/CMakeLists.txt index 5610c2a..ffa5aa1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,8 @@ if(CARGOXX_WERROR) add_compile_options(-Werror) endif() +find_package(SQLite3 REQUIRED) + # ----- cargoxx library: module units + implementation units ----- add_library(cargoxx STATIC) target_include_directories(cargoxx SYSTEM PRIVATE third_party) @@ -44,6 +46,7 @@ target_sources(cargoxx src/layout/layout.cpp src/linkdb/recipe.cpp src/linkdb/curated.cpp + src/linkdb/overlay.cpp src/cli/cmd_new.cpp src/cli/run.cpp PUBLIC @@ -60,6 +63,8 @@ target_sources(cargoxx src/cli/cli.cppm ) +target_link_libraries(cargoxx PRIVATE SQLite::SQLite3) + # ----- cargoxx binary ----- add_executable(cargoxx_bin src/main.cpp) set_target_properties(cargoxx_bin PROPERTIES OUTPUT_NAME cargoxx) diff --git a/src/linkdb/curated.cpp b/src/linkdb/curated.cpp index 2cbf38c..bf70401 100644 --- a/src/linkdb/curated.cpp +++ b/src/linkdb/curated.cpp @@ -71,22 +71,22 @@ auto load_curated(const std::filesystem::path& path) return out; } -} // namespace - -auto Database::open() -> util::Result { - Database db; - auto curated = load_curated(CARGOXX_LINKDB_DEFAULT_PATH); - if (!curated) { - return std::unexpected(curated.error()); +auto default_overlay_path() -> std::filesystem::path { + namespace fs = std::filesystem; + if (auto* xdg = std::getenv("XDG_CACHE_HOME"); xdg && *xdg) { + return fs::path{xdg} / "cargoxx" / "linkdb.sqlite"; } - db.curated_ = std::move(*curated); - return db; + if (auto* home = std::getenv("HOME"); home && *home) { + return fs::path{home} / ".cache" / "cargoxx" / "linkdb.sqlite"; + } + return fs::current_path() / ".cargoxx-linkdb.sqlite"; } -auto Database::resolve(const std::string& package, const std::string& version, - const std::vector& components) -> util::Result { - auto it = curated_.find(package); - if (it == curated_.end() || it->second.empty()) { +auto resolve_curated(const std::map>& curated, + const std::string& package, const std::string& version, + const std::vector& components) -> util::Result { + auto it = curated.find(package); + if (it == curated.end() || it->second.empty()) { return std::unexpected(util::Error{ util::ErrorCode::LinkdbUnknownPackage, std::format("package '{}' has no known CMake link recipe", package), @@ -126,22 +126,69 @@ auto Database::resolve(const std::string& package, const std::string& version, }); } - Recipe out{ + return Recipe{ .nixpkgs_attr = match->nixpkgs_attr, .find_package = substitute_components(match->find_package, components), .targets = expand_targets(match->targets, components), .source = "curated", }; - return out; } -auto Database::add_manual(const std::string&, const std::string&, const Recipe&) - -> util::Result { - return std::unexpected(util::Error{ - util::ErrorCode::NotImplemented, - "manual link recipes are not implemented in this milestone", - "", std::nullopt, std::nullopt, - }); +} // namespace + +auto Database::open(std::optional overlay_path) -> util::Result { + Database db; + + auto curated = load_curated(CARGOXX_LINKDB_DEFAULT_PATH); + if (!curated) { + return std::unexpected(curated.error()); + } + db.curated_ = std::move(*curated); + + auto path = overlay_path.value_or(default_overlay_path()); + auto handle = detail::overlay_open(path); + if (!handle) { + return std::unexpected(handle.error()); + } + db.overlay_ = std::move(*handle); + + return db; +} + +auto Database::resolve(const std::string& package, const std::string& version, + const std::vector& components) -> util::Result { + if (overlay_) { + auto rows = detail::overlay_query(*overlay_, package); + if (!rows) { + return std::unexpected(rows.error()); + } + const auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + for (const auto& row : *rows) { + if (!detail::overlay_is_fresh(row, now)) { + continue; + } + if (!util::satisfies(version, row.version_range)) { + continue; + } + if (!components.empty()) { + return std::unexpected(util::Error{ + util::ErrorCode::LinkdbComponentNotSupported, + std::format("overlay recipe for '{}' does not support components", package), + "", std::nullopt, std::nullopt, + }); + } + return Recipe{ + .nixpkgs_attr = row.nixpkgs_attr, + .find_package = row.find_package, + .targets = row.targets, + .source = row.source, + }; + } + } + + return resolve_curated(curated_, package, version, components); } } // namespace cargoxx::linkdb diff --git a/src/linkdb/linkdb.cppm b/src/linkdb/linkdb.cppm index 757d11d..9976086 100644 --- a/src/linkdb/linkdb.cppm +++ b/src/linkdb/linkdb.cppm @@ -1,3 +1,7 @@ +module; + +#include + export module cargoxx.linkdb; import std; @@ -26,13 +30,60 @@ struct CuratedRecipe { bool components_supported = false; }; +struct OverlayRow { + std::string version_range; + std::string nixpkgs_attr; + std::string find_package; + std::vector targets; + std::string source; + std::int64_t verified_at = 0; +}; + +// RAII wrapper for an open sqlite3 connection used by the overlay database. +// Move-only would be redundant — the holder (Database::overlay_) is a +// std::unique_ptr, so we delete copy and move outright. +class OverlayState { + public: + OverlayState() = default; + explicit OverlayState(sqlite3* handle) noexcept : db_(handle) {} + ~OverlayState() { + if (db_) { + sqlite3_close(db_); + } + } + + OverlayState(const OverlayState&) = delete; + OverlayState& operator=(const OverlayState&) = delete; + OverlayState(OverlayState&&) = delete; + OverlayState& operator=(OverlayState&&) = delete; + + [[nodiscard]] auto handle() const noexcept -> sqlite3* { return db_; } + + private: + sqlite3* db_ = nullptr; +}; + +auto overlay_open(const std::filesystem::path& path) + -> cargoxx::util::Result>; + +auto overlay_insert_manual(OverlayState& state, const std::string& package, + const std::string& version_range, + const cargoxx::linkdb::Recipe& r) + -> cargoxx::util::Result; + +auto overlay_query(OverlayState& state, const std::string& package) + -> cargoxx::util::Result>; + +auto overlay_is_fresh(const OverlayRow& row, std::int64_t now_epoch_seconds) -> bool; + } // namespace cargoxx::linkdb::detail export namespace cargoxx::linkdb { class Database { public: - static auto open() -> util::Result; + static auto open(std::optional overlay_path = std::nullopt) + -> util::Result; auto resolve(const std::string& package, const std::string& version, const std::vector& components = {}) @@ -44,6 +95,7 @@ class Database { private: Database() = default; std::map> curated_; + std::unique_ptr overlay_; }; // Pure helpers exported for unit testing. diff --git a/src/linkdb/overlay.cpp b/src/linkdb/overlay.cpp new file mode 100644 index 0000000..6472cb9 --- /dev/null +++ b/src/linkdb/overlay.cpp @@ -0,0 +1,195 @@ +module; + +#include +#include + +module cargoxx.linkdb; + +import std; +import cargoxx.util; + +namespace cargoxx::linkdb { + +namespace { + +constexpr const char* SCHEMA_SQL = R"( +CREATE TABLE IF NOT EXISTS recipes ( + package TEXT NOT NULL, + version_range TEXT NOT NULL, + nixpkgs_attr TEXT NOT NULL, + find_package TEXT NOT NULL, + targets TEXT NOT NULL, + components TEXT, + source TEXT NOT NULL, + verified_at INTEGER NOT NULL, + PRIMARY KEY (package, version_range, source) +); + +CREATE TABLE IF NOT EXISTS resolution_failures ( + package TEXT NOT NULL, + version TEXT NOT NULL, + last_attempt INTEGER NOT NULL, + error TEXT NOT NULL, + PRIMARY KEY (package, version) +); +)"; + +constexpr std::int64_t THIRTY_DAYS_SECONDS = 30LL * 24 * 60 * 60; + +auto sqlite_error(sqlite3* db, std::string_view ctx) -> util::Error { + return util::Error{ + util::ErrorCode::LinkdbCorrupt, + std::format("{}: {}", ctx, sqlite3_errmsg(db)), + "", std::nullopt, std::nullopt, + }; +} + +auto column_text(sqlite3_stmt* stmt, int idx) -> std::string { + const auto* p = sqlite3_column_text(stmt, idx); + if (!p) { + return {}; + } + return std::string{reinterpret_cast(p)}; +} + +} // namespace + +namespace detail { + +auto overlay_open(const std::filesystem::path& path) + -> util::Result> { + std::error_code ec; + if (path.has_parent_path()) { + std::filesystem::create_directories(path.parent_path(), ec); + // ec is non-fatal; sqlite3_open will report a clearer error if it matters + } + + sqlite3* db = nullptr; + auto rc = sqlite3_open(path.string().c_str(), &db); + if (rc != SQLITE_OK) { + std::string msg = std::format("cannot open overlay '{}': {}", path.string(), + db ? sqlite3_errmsg(db) : sqlite3_errstr(rc)); + if (db) { + sqlite3_close(db); + } + return std::unexpected(util::Error{ + util::ErrorCode::LinkdbCorrupt, std::move(msg), "", path, std::nullopt, + }); + } + + auto state = std::make_unique(db); + + char* errmsg = nullptr; + rc = sqlite3_exec(state->handle(), SCHEMA_SQL, nullptr, nullptr, &errmsg); + if (rc != SQLITE_OK) { + std::string msg = + std::format("cannot create overlay schema: {}", errmsg ? errmsg : "?"); + sqlite3_free(errmsg); + return std::unexpected(util::Error{ + util::ErrorCode::LinkdbCorrupt, std::move(msg), "", path, std::nullopt, + }); + } + + return state; +} + +auto overlay_insert_manual(OverlayState& state, const std::string& package, + const std::string& version_range, const Recipe& r) + -> 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, 'manual', ?)"; + + 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(); + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + 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_int64(stmt, 6, now); + + auto rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return std::unexpected(sqlite_error(db, "step insert")); + } + return {}; +} + +auto overlay_query(OverlayState& state, const std::string& package) + -> util::Result> { + constexpr const char* SQL = + "SELECT version_range, nixpkgs_attr, find_package, targets, source, verified_at " + "FROM recipes WHERE package = ?"; + + 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 select")); + } + sqlite3_bind_text(stmt, 1, package.c_str(), -1, SQLITE_TRANSIENT); + + std::vector out; + int rc; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + OverlayRow row; + row.version_range = column_text(stmt, 0); + row.nixpkgs_attr = column_text(stmt, 1); + row.find_package = column_text(stmt, 2); + auto targets_text = column_text(stmt, 3); + try { + row.targets = + nlohmann::json::parse(targets_text).get>(); + } catch (const nlohmann::json::exception&) { + sqlite3_finalize(stmt); + return std::unexpected(util::Error{ + util::ErrorCode::LinkdbCorrupt, + std::format("overlay row for '{}' has malformed targets JSON", package), + "", std::nullopt, std::nullopt, + }); + } + row.source = column_text(stmt, 4); + row.verified_at = sqlite3_column_int64(stmt, 5); + out.push_back(std::move(row)); + } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return std::unexpected(sqlite_error(db, "step select")); + } + return out; +} + +auto overlay_is_fresh(const OverlayRow& row, std::int64_t now) -> bool { + if (row.source == "manual" || row.source == "curated") { + return true; + } + return (now - row.verified_at) < THIRTY_DAYS_SECONDS; +} + +} // namespace detail + +auto Database::add_manual(const std::string& package, const std::string& version_range, + const Recipe& r) -> util::Result { + if (!overlay_) { + return std::unexpected(util::Error{ + util::ErrorCode::Internal, + "no overlay database is open", + "", std::nullopt, std::nullopt, + }); + } + return detail::overlay_insert_manual(*overlay_, package, version_range, r); +} + +} // namespace cargoxx::linkdb diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index af8b82b..ddb86f8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,4 +13,5 @@ cargoxx_add_test(manifest_parse) cargoxx_add_test(manifest_write) cargoxx_add_test(layout_discovery) cargoxx_add_test(linkdb_lookup) +cargoxx_add_test(linkdb_overlay) cargoxx_add_test(cmd_new) diff --git a/tests/linkdb_lookup.cpp b/tests/linkdb_lookup.cpp index ea2a4ab..1c2e0f1 100644 --- a/tests/linkdb_lookup.cpp +++ b/tests/linkdb_lookup.cpp @@ -10,15 +10,31 @@ using cargoxx::linkdb::expand_targets; using cargoxx::linkdb::substitute_components; using cargoxx::util::ErrorCode; -TEST_CASE("Database::open loads the curated linkdb", "[linkdb]") { - auto r = Database::open(); +namespace { + +auto fresh_overlay() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-linkdb-test-{}", std::random_device{}()); + std::filesystem::create_directories(d); + return d / "overlay.sqlite"; +} + +auto open_db() -> Database { + auto r = Database::open(fresh_overlay()); REQUIRE(r.has_value()); + return std::move(*r); +} + +} // namespace + +TEST_CASE("Database::open loads the curated linkdb", "[linkdb]") { + auto db = open_db(); + (void)db; } TEST_CASE("resolve returns the curated recipe for fmt 10", "[linkdb]") { - auto db = Database::open(); - REQUIRE(db.has_value()); - auto rec = db->resolve("fmt", "10.2.0"); + auto db = open_db(); + auto rec = db.resolve("fmt", "10.2.0"); REQUIRE(rec.has_value()); REQUIRE(rec->nixpkgs_attr == "fmt_10"); REQUIRE(rec->find_package == "fmt CONFIG REQUIRED"); @@ -27,25 +43,22 @@ TEST_CASE("resolve returns the curated recipe for fmt 10", "[linkdb]") { } TEST_CASE("resolve returns the older fmt recipe for fmt 8", "[linkdb]") { - auto db = Database::open(); - REQUIRE(db.has_value()); - auto rec = db->resolve("fmt", "8.1.0"); + auto db = open_db(); + auto rec = db.resolve("fmt", "8.1.0"); REQUIRE(rec.has_value()); REQUIRE(rec->nixpkgs_attr == "fmt_8"); } TEST_CASE("resolve fails for an unknown package", "[linkdb]") { - auto db = Database::open(); - REQUIRE(db.has_value()); - auto rec = db->resolve("obscurelib", "0.0.1"); + auto db = open_db(); + auto rec = db.resolve("obscurelib", "0.0.1"); REQUIRE_FALSE(rec.has_value()); REQUIRE(rec.error().code == ErrorCode::LinkdbUnknownPackage); } TEST_CASE("resolve substitutes boost components", "[linkdb]") { - auto db = Database::open(); - REQUIRE(db.has_value()); - auto rec = db->resolve("boost", "1.84.0", {"filesystem", "system"}); + auto db = open_db(); + auto rec = db.resolve("boost", "1.84.0", {"filesystem", "system"}); REQUIRE(rec.has_value()); REQUIRE(rec->find_package == "Boost REQUIRED COMPONENTS filesystem system"); REQUIRE(rec->targets == @@ -54,26 +67,23 @@ TEST_CASE("resolve substitutes boost components", "[linkdb]") { TEST_CASE("resolve fails when a componentized package gets no components", "[linkdb]") { - auto db = Database::open(); - REQUIRE(db.has_value()); - auto rec = db->resolve("boost", "1.84.0"); + auto db = open_db(); + auto rec = db.resolve("boost", "1.84.0"); REQUIRE_FALSE(rec.has_value()); REQUIRE(rec.error().code == ErrorCode::LinkdbComponentNotSupported); } TEST_CASE("resolve fails when components are passed to a non-componentized package", "[linkdb]") { - auto db = Database::open(); - REQUIRE(db.has_value()); - auto rec = db->resolve("fmt", "10.2.0", {"core"}); + auto db = open_db(); + auto rec = db.resolve("fmt", "10.2.0", {"core"}); REQUIRE_FALSE(rec.has_value()); REQUIRE(rec.error().code == ErrorCode::LinkdbComponentNotSupported); } TEST_CASE("resolve handles wildcard versions", "[linkdb]") { - auto db = Database::open(); - REQUIRE(db.has_value()); - auto rec = db->resolve("openssl", "3.2.0"); + auto db = open_db(); + auto rec = db.resolve("openssl", "3.2.0"); REQUIRE(rec.has_value()); REQUIRE(rec->find_package == "OpenSSL REQUIRED"); REQUIRE(rec->targets == @@ -81,8 +91,7 @@ TEST_CASE("resolve handles wildcard versions", "[linkdb]") { } TEST_CASE("resolve covers all 25 curated packages", "[linkdb]") { - auto db = Database::open(); - REQUIRE(db.has_value()); + auto db = open_db(); struct Sample { std::string name; @@ -118,7 +127,7 @@ TEST_CASE("resolve covers all 25 curated packages", "[linkdb]") { }; for (const auto& s : samples) { - auto rec = db->resolve(s.name, s.version, s.components); + auto rec = db.resolve(s.name, s.version, s.components); INFO("resolving " << s.name); REQUIRE(rec.has_value()); REQUIRE_FALSE(rec->nixpkgs_attr.empty()); diff --git a/tests/linkdb_overlay.cpp b/tests/linkdb_overlay.cpp new file mode 100644 index 0000000..ff9f109 --- /dev/null +++ b/tests/linkdb_overlay.cpp @@ -0,0 +1,153 @@ +#include + +import cargoxx.linkdb; +import cargoxx.util; +import std; + +using cargoxx::linkdb::Database; +using cargoxx::linkdb::Recipe; +using cargoxx::util::ErrorCode; + +namespace { + +auto fresh_overlay() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-overlay-test-{}", std::random_device{}()); + std::filesystem::create_directories(d); + return d / "overlay.sqlite"; +} + +} // namespace + +TEST_CASE("open creates the overlay schema and file", "[linkdb][overlay]") { + auto path = fresh_overlay(); + auto db = Database::open(path); + REQUIRE(db.has_value()); + REQUIRE(std::filesystem::exists(path)); +} + +TEST_CASE("add_manual then resolve returns the manual recipe", + "[linkdb][overlay]") { + auto db = Database::open(fresh_overlay()); + REQUIRE(db.has_value()); + + Recipe r{ + .nixpkgs_attr = "obscurelib", + .find_package = "obscurelib CONFIG REQUIRED", + .targets = {"obscurelib::obscurelib"}, + .source = "manual", + }; + REQUIRE(db->add_manual("obscurelib", "*", r).has_value()); + + auto got = db->resolve("obscurelib", "1.0.0"); + REQUIRE(got.has_value()); + REQUIRE(got->nixpkgs_attr == "obscurelib"); + REQUIRE(got->find_package == "obscurelib CONFIG REQUIRED"); + REQUIRE(got->targets == std::vector{"obscurelib::obscurelib"}); + REQUIRE(got->source == "manual"); +} + +TEST_CASE("manual entry overrides curated for the same package", + "[linkdb][overlay]") { + auto db = Database::open(fresh_overlay()); + REQUIRE(db.has_value()); + + Recipe override_r{ + .nixpkgs_attr = "fmt_pinned", + .find_package = "fmt CONFIG REQUIRED", + .targets = {"fmt::fmt"}, + .source = "manual", + }; + REQUIRE(db->add_manual("fmt", ">=10.0.0", override_r).has_value()); + + auto got = db->resolve("fmt", "10.2.0"); + REQUIRE(got.has_value()); + REQUIRE(got->nixpkgs_attr == "fmt_pinned"); + REQUIRE(got->source == "manual"); +} + +TEST_CASE("manual entry is constrained by version_range", + "[linkdb][overlay]") { + auto db = Database::open(fresh_overlay()); + REQUIRE(db.has_value()); + + Recipe r{ + .nixpkgs_attr = "fmt_v11_only", + .find_package = "fmt CONFIG REQUIRED", + .targets = {"fmt::fmt"}, + .source = "manual", + }; + REQUIRE(db->add_manual("fmt", ">=11.0.0", r).has_value()); + + // 10.x falls outside the manual range and falls through to curated + auto curated = db->resolve("fmt", "10.2.0"); + REQUIRE(curated.has_value()); + REQUIRE(curated->source == "curated"); + REQUIRE(curated->nixpkgs_attr == "fmt_10"); + + // 11.x matches the manual range + auto manual = db->resolve("fmt", "11.0.0"); + REQUIRE(manual.has_value()); + REQUIRE(manual->source == "manual"); + REQUIRE(manual->nixpkgs_attr == "fmt_v11_only"); +} + +TEST_CASE("manual recipes persist across reopen", "[linkdb][overlay]") { + auto path = fresh_overlay(); + + { + auto db = Database::open(path); + REQUIRE(db.has_value()); + Recipe r{ + .nixpkgs_attr = "persistlib", + .find_package = "persistlib CONFIG REQUIRED", + .targets = {"persistlib::persistlib"}, + .source = "manual", + }; + REQUIRE(db->add_manual("persistlib", "*", r).has_value()); + } + + auto db = Database::open(path); + REQUIRE(db.has_value()); + auto got = db->resolve("persistlib", "0.0.1"); + REQUIRE(got.has_value()); + REQUIRE(got->nixpkgs_attr == "persistlib"); + REQUIRE(got->source == "manual"); +} + +TEST_CASE("resolve with components on a manual recipe is rejected", + "[linkdb][overlay]") { + auto db = Database::open(fresh_overlay()); + REQUIRE(db.has_value()); + + Recipe r{ + .nixpkgs_attr = "weirdlib", + .find_package = "weirdlib CONFIG REQUIRED", + .targets = {"weirdlib::weirdlib"}, + .source = "manual", + }; + REQUIRE(db->add_manual("weirdlib", "*", r).has_value()); + + auto got = db->resolve("weirdlib", "1.0.0", {"some_component"}); + REQUIRE_FALSE(got.has_value()); + REQUIRE(got.error().code == ErrorCode::LinkdbComponentNotSupported); +} + +TEST_CASE("Database is move-constructible without leaking the handle", + "[linkdb][overlay]") { + auto db = Database::open(fresh_overlay()); + REQUIRE(db.has_value()); + Database moved = std::move(*db); + + Recipe r{ + .nixpkgs_attr = "movelib", + .find_package = "movelib CONFIG REQUIRED", + .targets = {"movelib::movelib"}, + .source = "manual", + }; + REQUIRE(moved.add_manual("movelib", "*", r).has_value()); + + auto got = moved.resolve("movelib", "1.0.0"); + REQUIRE(got.has_value()); + REQUIRE(got->source == "manual"); +}