[M5+] drop curated linkdb JSON; SQLite overlay is the single source

This commit is contained in:
2026-05-13 23:28:36 +00:00
parent 5db915e576
commit 653b9fbb8d
12 changed files with 193 additions and 656 deletions

View File

@@ -35,9 +35,6 @@ find_package(reproc REQUIRED)
# ----- cargoxx library: module units + implementation units ----- # ----- cargoxx library: module units + implementation units -----
add_library(cargoxx STATIC) add_library(cargoxx STATIC)
target_include_directories(cargoxx SYSTEM PRIVATE third_party) target_include_directories(cargoxx SYSTEM PRIVATE third_party)
target_compile_definitions(cargoxx PRIVATE
CARGOXX_LINKDB_DEFAULT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/data/linkdb.json"
)
target_sources(cargoxx target_sources(cargoxx
PRIVATE PRIVATE
src/util/error.cpp src/util/error.cpp
@@ -46,8 +43,7 @@ target_sources(cargoxx
src/manifest/writer.cpp src/manifest/writer.cpp
src/layout/layout.cpp src/layout/layout.cpp
src/lockfile/lockfile.cpp src/lockfile/lockfile.cpp
src/linkdb/recipe.cpp src/linkdb/database.cpp
src/linkdb/curated.cpp
src/linkdb/overlay.cpp src/linkdb/overlay.cpp
src/codegen/flake.cpp src/codegen/flake.cpp
src/codegen/cmake.cpp src/codegen/cmake.cpp

View File

@@ -1,212 +0,0 @@
{
"version": 1,
"packages": {
"fmt": [
{
"version": ">=10.0.0",
"nixpkgs_attr": "fmt_10",
"find_package": "fmt CONFIG REQUIRED",
"targets": ["fmt::fmt"]
},
{
"version": ">=8.0.0,<10.0.0",
"nixpkgs_attr": "fmt_8",
"find_package": "fmt CONFIG REQUIRED",
"targets": ["fmt::fmt"]
}
],
"spdlog": [
{
"version": "*",
"nixpkgs_attr": "spdlog",
"find_package": "spdlog CONFIG REQUIRED",
"targets": ["spdlog::spdlog"]
}
],
"nlohmann_json": [
{
"version": "*",
"nixpkgs_attr": "nlohmann_json",
"find_package": "nlohmann_json CONFIG REQUIRED",
"targets": ["nlohmann_json::nlohmann_json"]
}
],
"boost": [
{
"version": ">=1.70.0",
"nixpkgs_attr": "boost",
"find_package": "Boost REQUIRED",
"targets": ["Boost::headers"]
}
],
"openssl": [
{
"version": "*",
"nixpkgs_attr": "openssl",
"find_package": "OpenSSL REQUIRED",
"targets": ["OpenSSL::SSL", "OpenSSL::Crypto"]
}
],
"zlib": [
{
"version": "*",
"nixpkgs_attr": "zlib",
"find_package": "ZLIB REQUIRED",
"targets": ["ZLIB::ZLIB"]
}
],
"sqlite3": [
{
"version": "*",
"nixpkgs_attr": "sqlite",
"find_package": "SQLite3 REQUIRED",
"targets": ["SQLite::SQLite3"]
}
],
"curl": [
{
"version": "*",
"nixpkgs_attr": "curl",
"find_package": "CURL REQUIRED",
"targets": ["CURL::libcurl"]
}
],
"protobuf": [
{
"version": "*",
"nixpkgs_attr": "protobuf",
"find_package": "Protobuf REQUIRED",
"targets": ["protobuf::libprotobuf"]
}
],
"grpc": [
{
"version": "*",
"nixpkgs_attr": "grpc",
"find_package": "gRPC CONFIG REQUIRED",
"targets": ["gRPC::grpc++"]
}
],
"abseil-cpp": [
{
"version": "*",
"nixpkgs_attr": "abseil-cpp",
"find_package": "absl CONFIG REQUIRED",
"targets": ["absl::{{component}}"],
"components": "supported"
}
],
"gtest": [
{
"version": "*",
"nixpkgs_attr": "gtest",
"find_package": "GTest CONFIG REQUIRED",
"targets": ["GTest::gtest", "GTest::gtest_main"]
}
],
"catch2": [
{
"version": "*",
"nixpkgs_attr": "catch2_3",
"find_package": "Catch2 CONFIG REQUIRED",
"targets": ["Catch2::Catch2WithMain"]
}
],
"eigen": [
{
"version": "*",
"nixpkgs_attr": "eigen",
"find_package": "Eigen3 CONFIG REQUIRED",
"targets": ["Eigen3::Eigen"]
}
],
"tbb": [
{
"version": "*",
"nixpkgs_attr": "tbb",
"find_package": "TBB CONFIG REQUIRED",
"targets": ["TBB::tbb"]
}
],
"libpng": [
{
"version": "*",
"nixpkgs_attr": "libpng",
"find_package": "PNG REQUIRED",
"targets": ["PNG::PNG"]
}
],
"libjpeg": [
{
"version": "*",
"nixpkgs_attr": "libjpeg",
"find_package": "JPEG REQUIRED",
"targets": ["JPEG::JPEG"]
}
],
"freetype": [
{
"version": "*",
"nixpkgs_attr": "freetype",
"find_package": "Freetype REQUIRED",
"targets": ["Freetype::Freetype"]
}
],
"glfw": [
{
"version": "*",
"nixpkgs_attr": "glfw",
"find_package": "glfw3 CONFIG REQUIRED",
"targets": ["glfw"]
}
],
"glm": [
{
"version": "*",
"nixpkgs_attr": "glm",
"find_package": "glm CONFIG REQUIRED",
"targets": ["glm::glm"]
}
],
"sdl2": [
{
"version": "*",
"nixpkgs_attr": "SDL2",
"find_package": "SDL2 CONFIG REQUIRED",
"targets": ["SDL2::SDL2"]
}
],
"cli11": [
{
"version": "*",
"nixpkgs_attr": "cli11",
"find_package": "CLI11 CONFIG REQUIRED",
"targets": ["CLI11::CLI11"]
}
],
"cxxopts": [
{
"version": "*",
"nixpkgs_attr": "cxxopts",
"find_package": "cxxopts CONFIG REQUIRED",
"targets": ["cxxopts::cxxopts"]
}
],
"range-v3": [
{
"version": "*",
"nixpkgs_attr": "range-v3",
"find_package": "range-v3 CONFIG REQUIRED",
"targets": ["range-v3::range-v3"]
}
],
"magic_enum": [
{
"version": "*",
"nixpkgs_attr": "magic-enum",
"find_package": "magic_enum CONFIG REQUIRED",
"targets": ["magic_enum::magic_enum"]
}
]
}
}

View File

@@ -1,195 +0,0 @@
module;
#include <json.hpp>
module cargoxx.linkdb;
import std;
import cargoxx.util;
#ifndef CARGOXX_LINKDB_DEFAULT_PATH
#define CARGOXX_LINKDB_DEFAULT_PATH ""
#endif
namespace cargoxx::linkdb {
namespace {
auto curated_source_error(std::string msg) -> util::Error {
return util::Error{
util::ErrorCode::LinkdbCorrupt, std::move(msg), "", std::nullopt, std::nullopt,
};
}
auto load_curated(const std::filesystem::path& path)
-> util::Result<std::map<std::string, std::vector<detail::CuratedRecipe>>> {
std::ifstream in{path};
if (!in) {
return std::unexpected(curated_source_error(
std::format("cannot open curated linkdb at '{}'", path.string())));
}
nlohmann::json j;
try {
in >> j;
} catch (const nlohmann::json::parse_error& e) {
return std::unexpected(curated_source_error(
std::format("curated linkdb is not valid JSON: {}", e.what())));
}
if (!j.is_object() || !j.contains("packages") || !j["packages"].is_object()) {
return std::unexpected(
curated_source_error("curated linkdb missing top-level 'packages' object"));
}
std::map<std::string, std::vector<detail::CuratedRecipe>> out;
for (const auto& [name, recipes] : j["packages"].items()) {
if (!recipes.is_array()) {
return std::unexpected(curated_source_error(
std::format("packages.{} must be an array", name)));
}
std::vector<detail::CuratedRecipe> bucket;
bucket.reserve(recipes.size());
for (const auto& r : recipes) {
detail::CuratedRecipe rec;
try {
rec.version_range = r.at("version").get<std::string>();
rec.nixpkgs_attr = r.at("nixpkgs_attr").get<std::string>();
rec.find_package = r.at("find_package").get<std::string>();
rec.targets = r.at("targets").get<std::vector<std::string>>();
} catch (const nlohmann::json::exception& e) {
return std::unexpected(curated_source_error(std::format(
"packages.{} has a malformed recipe: {}", name, e.what())));
}
if (r.contains("components") && r["components"] == "supported") {
rec.components_supported = true;
}
bucket.push_back(std::move(rec));
}
out.emplace(name, std::move(bucket));
}
return out;
}
auto resolve_curated(const std::map<std::string, std::vector<detail::CuratedRecipe>>& curated,
const std::string& package, const std::string& version,
const std::vector<std::string>& components) -> util::Result<Recipe> {
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),
"file an issue at <repo>/issues/new, or add a manual recipe via cargoxx linkdb add",
std::nullopt, std::nullopt,
});
}
const detail::CuratedRecipe* match = nullptr;
for (const auto& r : it->second) {
if (util::satisfies(version, r.version_range)) {
match = &r;
break;
}
}
if (!match) {
return std::unexpected(util::Error{
util::ErrorCode::LinkdbUnknownPackage,
std::format("no curated recipe for {} {} matches", package, version),
"", std::nullopt, std::nullopt,
});
}
if (match->components_supported && components.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::LinkdbComponentNotSupported,
std::format("package '{}' requires at least one component", package),
"specify components in Cargoxx.toml: { version = \"...\", components = [\"...\"] }",
std::nullopt, std::nullopt,
});
}
if (!match->components_supported && !components.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::LinkdbComponentNotSupported,
std::format("package '{}' does not declare component support", package),
"", std::nullopt, std::nullopt,
});
}
return Recipe{
.nixpkgs_attr = match->nixpkgs_attr,
.find_package = substitute_components(match->find_package, components),
.targets = expand_targets(match->targets, components),
.source = "curated",
};
}
} // namespace
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";
}
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::open(std::optional<std::filesystem::path> overlay_path) -> util::Result<Database> {
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<std::string>& components) -> util::Result<Recipe> {
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::seconds>(
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

78
src/linkdb/database.cpp Normal file
View File

@@ -0,0 +1,78 @@
module cargoxx.linkdb;
import std;
import cargoxx.util;
namespace cargoxx::linkdb {
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";
}
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::open(std::optional<std::filesystem::path> overlay_path)
-> util::Result<Database> {
Database db;
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<std::string>& components)
-> util::Result<Recipe> {
if (!overlay_) {
return std::unexpected(util::Error{
util::ErrorCode::Internal, "no overlay database is open", "",
std::nullopt, std::nullopt,
});
}
auto rows = detail::overlay_query(*overlay_, package);
if (!rows) {
return std::unexpected(rows.error());
}
const auto now = std::chrono::duration_cast<std::chrono::seconds>(
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 std::unexpected(util::Error{
util::ErrorCode::LinkdbUnknownPackage,
std::format("package '{}' has no known CMake link recipe", package),
"run `cargoxx add {0}` to discover and verify a recipe via the resolver, "
"or add a manual recipe via `cargoxx linkdb add`",
std::nullopt, std::nullopt,
});
}
} // namespace cargoxx::linkdb

View File

@@ -22,14 +22,6 @@ struct Recipe {
namespace cargoxx::linkdb::detail { namespace cargoxx::linkdb::detail {
struct CuratedRecipe {
std::string version_range;
std::string nixpkgs_attr;
std::string find_package;
std::vector<std::string> targets;
bool components_supported = false;
};
struct OverlayRow { struct OverlayRow {
std::string version_range; std::string version_range;
std::string nixpkgs_attr; std::string nixpkgs_attr;
@@ -140,7 +132,6 @@ class Database {
private: private:
Database() = default; Database() = default;
std::map<std::string, std::vector<detail::CuratedRecipe>> curated_;
std::unique_ptr<detail::OverlayState> overlay_; std::unique_ptr<detail::OverlayState> overlay_;
}; };
@@ -150,11 +141,4 @@ class Database {
// <cwd>/.cargoxx-linkdb.sqlite (final fallback) // <cwd>/.cargoxx-linkdb.sqlite (final fallback)
auto default_overlay_path() -> std::filesystem::path; auto default_overlay_path() -> std::filesystem::path;
// Pure helpers exported for unit testing.
auto substitute_components(std::string find_package, const std::vector<std::string>& components)
-> std::string;
auto expand_targets(const std::vector<std::string>& templates,
const std::vector<std::string>& components) -> std::vector<std::string>;
} // namespace cargoxx::linkdb } // namespace cargoxx::linkdb

View File

@@ -1,55 +0,0 @@
module cargoxx.linkdb;
import std;
namespace cargoxx::linkdb {
namespace {
auto replace_all(std::string s, std::string_view marker, std::string_view value) -> std::string {
std::string out;
out.reserve(s.size());
std::size_t pos = 0;
while (pos < s.size()) {
auto next = s.find(marker, pos);
if (next == std::string::npos) {
out.append(s, pos);
break;
}
out.append(s, pos, next - pos);
out.append(value);
pos = next + marker.size();
}
return out;
}
} // namespace
auto substitute_components(std::string find_package, const std::vector<std::string>& components)
-> std::string {
std::string joined;
for (std::size_t i = 0; i < components.size(); ++i) {
if (i > 0) {
joined += ' ';
}
joined += components[i];
}
return replace_all(std::move(find_package), "{{components}}", joined);
}
auto expand_targets(const std::vector<std::string>& templates,
const std::vector<std::string>& components) -> std::vector<std::string> {
std::vector<std::string> out;
for (const auto& t : templates) {
if (t.find("{{component}}") != std::string::npos) {
for (const auto& c : components) {
out.push_back(replace_all(t, "{{component}}", c));
}
} else {
out.push_back(t);
}
}
return out;
}
} // namespace cargoxx::linkdb

View File

@@ -1,12 +1,15 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
import cargoxx.cli; import cargoxx.cli;
import cargoxx.linkdb;
import cargoxx.manifest; import cargoxx.manifest;
import cargoxx.util; import cargoxx.util;
import std; import std;
using cargoxx::cli::cmd_add; using cargoxx::cli::cmd_add;
using cargoxx::cli::cmd_new; using cargoxx::cli::cmd_new;
using cargoxx::linkdb::Database;
using cargoxx::linkdb::Recipe;
using cargoxx::util::ErrorCode; using cargoxx::util::ErrorCode;
namespace manifest = cargoxx::manifest; namespace manifest = cargoxx::manifest;
@@ -36,11 +39,25 @@ auto scaffold(const std::filesystem::path& parent) -> std::filesystem::path {
return parent / "app"; return parent / "app";
} }
auto seed_fmt(const std::filesystem::path& overlay) {
auto db = Database::open(overlay);
REQUIRE(db.has_value());
REQUIRE(db->add_manual("fmt", "*",
Recipe{
.nixpkgs_attr = "fmt_10",
.find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"},
.source = "manual",
})
.has_value());
}
} // namespace } // namespace
TEST_CASE("cmd_add appends a string-form dependency", "[cli][add]") { TEST_CASE("cmd_add appends a string-form dependency", "[cli][add]") {
auto parent = fresh_dir(); auto parent = fresh_dir();
auto root = scaffold(parent); auto root = scaffold(parent);
seed_fmt(overlay_path(parent));
auto r = cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)); auto r = cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent));
REQUIRE(r.has_value()); REQUIRE(r.has_value());
@@ -53,25 +70,10 @@ TEST_CASE("cmd_add appends a string-form dependency", "[cli][add]") {
REQUIRE(m->dependencies[0].components.empty()); REQUIRE(m->dependencies[0].components.empty());
} }
TEST_CASE("cmd_add stores components when provided", "[cli][add]") {
auto parent = fresh_dir();
auto root = scaffold(parent);
auto r = cmd_add(root, "abseil-cpp", "20240116.0", {"strings", "base"},
overlay_path(parent));
REQUIRE(r.has_value());
auto m = manifest::parse(root / "Cargoxx.toml");
REQUIRE(m.has_value());
REQUIRE(m->dependencies.size() == 1);
REQUIRE(m->dependencies[0].name == "abseil-cpp");
REQUIRE(m->dependencies[0].components ==
std::vector<std::string>{"strings", "base"});
}
TEST_CASE("cmd_add accepts an empty version and stores '*'", "[cli][add]") { TEST_CASE("cmd_add accepts an empty version and stores '*'", "[cli][add]") {
auto parent = fresh_dir(); auto parent = fresh_dir();
auto root = scaffold(parent); auto root = scaffold(parent);
seed_fmt(overlay_path(parent));
auto r = cmd_add(root, "fmt", "", {}, overlay_path(parent)); auto r = cmd_add(root, "fmt", "", {}, overlay_path(parent));
REQUIRE(r.has_value()); REQUIRE(r.has_value());
@@ -105,6 +107,7 @@ TEST_CASE("cmd_add rejects an unknown package", "[cli][add]") {
TEST_CASE("cmd_add rejects an already-declared dep", "[cli][add]") { TEST_CASE("cmd_add rejects an already-declared dep", "[cli][add]") {
auto parent = fresh_dir(); auto parent = fresh_dir();
auto root = scaffold(parent); auto root = scaffold(parent);
seed_fmt(overlay_path(parent));
REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value()); REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value());
@@ -112,13 +115,3 @@ TEST_CASE("cmd_add rejects an already-declared dep", "[cli][add]") {
REQUIRE_FALSE(r.has_value()); REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField); REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
} }
TEST_CASE("cmd_add rejects componentized package without components",
"[cli][add]") {
auto parent = fresh_dir();
auto root = scaffold(parent);
auto r = cmd_add(root, "abseil-cpp", "20240116.0", {}, overlay_path(parent));
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::LinkdbComponentNotSupported);
}

View File

@@ -1,6 +1,7 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
import cargoxx.cli; import cargoxx.cli;
import cargoxx.linkdb;
import cargoxx.manifest; import cargoxx.manifest;
import cargoxx.lockfile; import cargoxx.lockfile;
import cargoxx.util; import cargoxx.util;
@@ -8,6 +9,8 @@ import std;
using cargoxx::cli::cmd_build; using cargoxx::cli::cmd_build;
using cargoxx::cli::cmd_new; using cargoxx::cli::cmd_new;
using cargoxx::linkdb::Database;
using cargoxx::linkdb::Recipe;
using cargoxx::util::ErrorCode; using cargoxx::util::ErrorCode;
namespace manifest = cargoxx::manifest; namespace manifest = cargoxx::manifest;
namespace lockfile = cargoxx::lockfile; namespace lockfile = cargoxx::lockfile;
@@ -43,6 +46,13 @@ auto add_dep(const std::filesystem::path& root, const std::string& name,
REQUIRE(manifest::write(*m, path).has_value()); REQUIRE(manifest::write(*m, path).has_value());
} }
auto seed_recipe(const std::filesystem::path& overlay, const std::string& package,
const std::string& version_range, const Recipe& r) {
auto db = Database::open(overlay);
REQUIRE(db.has_value());
REQUIRE(db->add_manual(package, version_range, r).has_value());
}
} // namespace } // namespace
TEST_CASE("cmd_build generates files for a no-deps binary project", TEST_CASE("cmd_build generates files for a no-deps binary project",
@@ -82,12 +92,19 @@ TEST_CASE("cmd_build generates files for a library project", "[cli][build]") {
REQUIRE(cmake_text.find("add_executable") == std::string::npos); REQUIRE(cmake_text.find("add_executable") == std::string::npos);
} }
TEST_CASE("cmd_build resolves a curated dep into find_package + targets", TEST_CASE("cmd_build resolves a manually-seeded dep into find_package + targets",
"[cli][build]") { "[cli][build]") {
auto parent = fresh_dir(); auto parent = fresh_dir();
REQUIRE(cmd_new("app", false, parent).has_value()); REQUIRE(cmd_new("app", false, parent).has_value());
auto root = parent / "app"; auto root = parent / "app";
add_dep(root, "fmt", "10.2.0"); add_dep(root, "fmt", "10.2.0");
seed_recipe(overlay_path(parent), "fmt", ">=10.0.0",
Recipe{
.nixpkgs_attr = "fmt_10",
.find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"},
.source = "manual",
});
auto r = cmd_build(root, true, false, std::nullopt, overlay_path(parent)); auto r = cmd_build(root, true, false, std::nullopt, overlay_path(parent));
REQUIRE(r.has_value()); REQUIRE(r.has_value());
@@ -100,35 +117,33 @@ TEST_CASE("cmd_build resolves a curated dep into find_package + targets",
REQUIRE(flake_text.find("pkgs.fmt_10") != std::string::npos); REQUIRE(flake_text.find("pkgs.fmt_10") != std::string::npos);
} }
TEST_CASE("cmd_build resolves a componentized dep", "[cli][build]") {
auto parent = fresh_dir();
REQUIRE(cmd_new("app", false, parent).has_value());
auto root = parent / "app";
add_dep(root, "abseil-cpp", "20240116.0", {"strings", "base"});
auto r = cmd_build(root, true, false, std::nullopt, overlay_path(parent));
REQUIRE(r.has_value());
auto cmake_text = read_file(root / "build" / "CMakeLists.txt");
REQUIRE(cmake_text.find("find_package(absl CONFIG REQUIRED)") !=
std::string::npos);
REQUIRE(cmake_text.find("absl::strings") != std::string::npos);
REQUIRE(cmake_text.find("absl::base") != std::string::npos);
}
TEST_CASE("cmd_build synthesizes a lockfile entry per dep", "[cli][build]") { TEST_CASE("cmd_build synthesizes a lockfile entry per dep", "[cli][build]") {
auto parent = fresh_dir(); auto parent = fresh_dir();
REQUIRE(cmd_new("app", false, parent).has_value()); REQUIRE(cmd_new("app", false, parent).has_value());
auto root = parent / "app"; auto root = parent / "app";
add_dep(root, "fmt", "10.2.0"); add_dep(root, "fmt", "10.2.0");
add_dep(root, "spdlog", "1.13.0"); add_dep(root, "spdlog", "1.13.0");
seed_recipe(overlay_path(parent), "fmt", ">=10.0.0",
Recipe{
.nixpkgs_attr = "fmt_10",
.find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"},
.source = "manual",
});
seed_recipe(overlay_path(parent), "spdlog", "*",
Recipe{
.nixpkgs_attr = "spdlog",
.find_package = "spdlog CONFIG REQUIRED",
.targets = {"spdlog::spdlog"},
.source = "manual",
});
auto r = cmd_build(root, true, false, std::nullopt, overlay_path(parent)); auto r = cmd_build(root, true, false, std::nullopt, overlay_path(parent));
REQUIRE(r.has_value()); REQUIRE(r.has_value());
auto lock = lockfile::parse(root / "Cargoxx.lock"); auto lock = lockfile::parse(root / "Cargoxx.lock");
REQUIRE(lock.has_value()); REQUIRE(lock.has_value());
REQUIRE(lock->packages.size() == 3); // root + fmt + spdlog REQUIRE(lock->packages.size() == 3);
REQUIRE(lock->packages[0].name == "app"); REQUIRE(lock->packages[0].name == "app");
REQUIRE(lock->packages[0].dependencies.size() == 2); REQUIRE(lock->packages[0].dependencies.size() == 2);
} }
@@ -172,6 +187,13 @@ TEST_CASE("cmd_build is idempotent — second run produces identical files",
REQUIRE(cmd_new("app", false, parent).has_value()); REQUIRE(cmd_new("app", false, parent).has_value());
auto root = parent / "app"; auto root = parent / "app";
add_dep(root, "fmt", "10.2.0"); add_dep(root, "fmt", "10.2.0");
seed_recipe(overlay_path(parent), "fmt", ">=10.0.0",
Recipe{
.nixpkgs_attr = "fmt_10",
.find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"},
.source = "manual",
});
REQUIRE(cmd_build(root, true, false, std::nullopt, overlay_path(parent)).has_value()); REQUIRE(cmd_build(root, true, false, std::nullopt, overlay_path(parent)).has_value());
auto first_cmake = read_file(root / "build" / "CMakeLists.txt"); auto first_cmake = read_file(root / "build" / "CMakeLists.txt");

View File

@@ -1,6 +1,7 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
import cargoxx.cli; import cargoxx.cli;
import cargoxx.linkdb;
import cargoxx.manifest; import cargoxx.manifest;
import cargoxx.util; import cargoxx.util;
import std; import std;
@@ -8,6 +9,8 @@ import std;
using cargoxx::cli::cmd_add; using cargoxx::cli::cmd_add;
using cargoxx::cli::cmd_new; using cargoxx::cli::cmd_new;
using cargoxx::cli::cmd_remove; using cargoxx::cli::cmd_remove;
using cargoxx::linkdb::Database;
using cargoxx::linkdb::Recipe;
using cargoxx::util::ErrorCode; using cargoxx::util::ErrorCode;
namespace manifest = cargoxx::manifest; namespace manifest = cargoxx::manifest;
@@ -36,11 +39,26 @@ auto scaffold(const std::filesystem::path& parent) -> std::filesystem::path {
return parent / "app"; return parent / "app";
} }
auto seed_recipe(const std::filesystem::path& overlay, const std::string& name,
const std::string& nixpkgs_attr) {
auto db = Database::open(overlay);
REQUIRE(db.has_value());
REQUIRE(db->add_manual(name, "*",
Recipe{
.nixpkgs_attr = nixpkgs_attr,
.find_package = std::format("{} CONFIG REQUIRED", name),
.targets = {std::format("{}::{}", name, name)},
.source = "manual",
})
.has_value());
}
} // namespace } // namespace
TEST_CASE("cmd_remove drops the dependency", "[cli][remove]") { TEST_CASE("cmd_remove drops the dependency", "[cli][remove]") {
auto parent = fresh_dir(); auto parent = fresh_dir();
auto root = scaffold(parent); auto root = scaffold(parent);
seed_recipe(overlay_path(parent), "fmt", "fmt_10");
REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value()); REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value());
REQUIRE(cmd_remove(root, "fmt").has_value()); REQUIRE(cmd_remove(root, "fmt").has_value());
@@ -53,6 +71,8 @@ TEST_CASE("cmd_remove drops the dependency", "[cli][remove]") {
TEST_CASE("cmd_remove leaves other deps in place", "[cli][remove]") { TEST_CASE("cmd_remove leaves other deps in place", "[cli][remove]") {
auto parent = fresh_dir(); auto parent = fresh_dir();
auto root = scaffold(parent); auto root = scaffold(parent);
seed_recipe(overlay_path(parent), "fmt", "fmt_10");
seed_recipe(overlay_path(parent), "spdlog", "spdlog");
REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value()); REQUIRE(cmd_add(root, "fmt", "10.2.0", {}, overlay_path(parent)).has_value());
REQUIRE(cmd_add(root, "spdlog", "1.13.0", {}, overlay_path(parent)).has_value()); REQUIRE(cmd_add(root, "spdlog", "1.13.0", {}, overlay_path(parent)).has_value());

View File

@@ -6,8 +6,6 @@ import std;
using cargoxx::linkdb::Database; using cargoxx::linkdb::Database;
using cargoxx::linkdb::Recipe; using cargoxx::linkdb::Recipe;
using cargoxx::linkdb::expand_targets;
using cargoxx::linkdb::substitute_components;
using cargoxx::util::ErrorCode; using cargoxx::util::ErrorCode;
namespace { namespace {
@@ -27,134 +25,48 @@ auto open_db() -> Database {
} // namespace } // namespace
TEST_CASE("Database::open loads the curated linkdb", "[linkdb]") { TEST_CASE("Database::open succeeds against a fresh overlay path", "[linkdb]") {
auto db = open_db(); auto db = open_db();
(void)db; (void)db;
} }
TEST_CASE("resolve returns the curated recipe for fmt 10", "[linkdb]") { TEST_CASE("resolve fails on an empty database", "[linkdb]") {
auto db = open_db(); auto db = open_db();
auto rec = db.resolve("fmt", "10.2.0");
REQUIRE_FALSE(rec.has_value());
REQUIRE(rec.error().code == ErrorCode::LinkdbUnknownPackage);
}
TEST_CASE("resolve returns a manually-added recipe", "[linkdb]") {
auto db = open_db();
auto add = db.add_manual("fmt", ">=10.0.0",
Recipe{
.nixpkgs_attr = "fmt_10",
.find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"},
.source = "manual",
});
REQUIRE(add.has_value());
auto rec = db.resolve("fmt", "10.2.0"); auto rec = db.resolve("fmt", "10.2.0");
REQUIRE(rec.has_value()); REQUIRE(rec.has_value());
REQUIRE(rec->nixpkgs_attr == "fmt_10"); REQUIRE(rec->nixpkgs_attr == "fmt_10");
REQUIRE(rec->find_package == "fmt CONFIG REQUIRED"); REQUIRE(rec->find_package == "fmt CONFIG REQUIRED");
REQUIRE(rec->targets == std::vector<std::string>{"fmt::fmt"}); REQUIRE(rec->targets == std::vector<std::string>{"fmt::fmt"});
REQUIRE(rec->source == "curated"); REQUIRE(rec->source == "manual");
} }
TEST_CASE("resolve returns the older fmt recipe for fmt 8", "[linkdb]") { TEST_CASE("resolve fails when components are passed but the row is non-componentized",
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 = 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 abseil-cpp components", "[linkdb]") {
auto db = open_db();
auto rec = db.resolve("abseil-cpp", "20240116.0", {"strings", "base"});
REQUIRE(rec.has_value());
REQUIRE(rec->find_package == "absl CONFIG REQUIRED");
REQUIRE(rec->targets ==
std::vector<std::string>{"absl::strings", "absl::base"});
}
TEST_CASE("resolve fails when a componentized package gets no components",
"[linkdb]") {
auto db = open_db();
auto rec = db.resolve("abseil-cpp", "20240116.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]") { "[linkdb]") {
auto db = open_db(); auto db = open_db();
(void)db.add_manual("fmt", "*",
Recipe{
.nixpkgs_attr = "fmt_10",
.find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"},
.source = "manual",
});
auto rec = db.resolve("fmt", "10.2.0", {"core"}); auto rec = db.resolve("fmt", "10.2.0", {"core"});
REQUIRE_FALSE(rec.has_value()); REQUIRE_FALSE(rec.has_value());
REQUIRE(rec.error().code == ErrorCode::LinkdbComponentNotSupported); REQUIRE(rec.error().code == ErrorCode::LinkdbComponentNotSupported);
} }
TEST_CASE("resolve handles wildcard versions", "[linkdb]") {
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 ==
std::vector<std::string>{"OpenSSL::SSL", "OpenSSL::Crypto"});
}
TEST_CASE("resolve covers all 25 curated packages", "[linkdb]") {
auto db = open_db();
struct Sample {
std::string name;
std::string version;
std::vector<std::string> components;
};
const std::vector<Sample> samples = {
{"fmt", "10.2.0", {}},
{"spdlog", "1.13.0", {}},
{"nlohmann_json", "3.11.0", {}},
{"boost", "1.84.0", {}},
{"openssl", "3.2.0", {}},
{"zlib", "1.3.0", {}},
{"sqlite3", "3.45.0", {}},
{"curl", "8.5.0", {}},
{"protobuf", "25.0.0", {}},
{"grpc", "1.60.0", {}},
{"abseil-cpp", "20240116.0", {"strings"}},
{"gtest", "1.14.0", {}},
{"catch2", "3.5.0", {}},
{"eigen", "3.4.0", {}},
{"tbb", "2021.10.0", {}},
{"libpng", "1.6.40", {}},
{"libjpeg", "3.0.1", {}},
{"freetype", "2.13.2", {}},
{"glfw", "3.3.9", {}},
{"glm", "0.9.9.8", {}},
{"sdl2", "2.28.5", {}},
{"cli11", "2.4.1", {}},
{"cxxopts", "3.2.0", {}},
{"range-v3", "0.12.0", {}},
{"magic_enum", "0.9.5", {}},
};
for (const auto& s : samples) {
auto rec = db.resolve(s.name, s.version, s.components);
INFO("resolving " << s.name);
REQUIRE(rec.has_value());
REQUIRE_FALSE(rec->nixpkgs_attr.empty());
REQUIRE_FALSE(rec->find_package.empty());
REQUIRE_FALSE(rec->targets.empty());
}
}
TEST_CASE("substitute_components is a no-op when marker is absent",
"[linkdb][substitute]") {
REQUIRE(substitute_components("foo bar", {"a", "b"}) == "foo bar");
}
TEST_CASE("substitute_components joins components with spaces",
"[linkdb][substitute]") {
REQUIRE(substitute_components("X {{components}} Y", {"a", "b", "c"}) ==
"X a b c Y");
}
TEST_CASE("expand_targets fans out per-component templates",
"[linkdb][substitute]") {
REQUIRE(expand_targets({"Boost::{{component}}"}, {"filesystem", "system"}) ==
std::vector<std::string>{"Boost::filesystem", "Boost::system"});
}
TEST_CASE("expand_targets keeps non-templated targets verbatim",
"[linkdb][substitute]") {
REQUIRE(expand_targets({"OpenSSL::SSL", "OpenSSL::Crypto"}, {}) ==
std::vector<std::string>{"OpenSSL::SSL", "OpenSSL::Crypto"});
}

View File

@@ -47,18 +47,17 @@ TEST_CASE("add_manual then resolve returns the manual recipe",
REQUIRE(got->source == "manual"); REQUIRE(got->source == "manual");
} }
TEST_CASE("manual entry overrides curated for the same package", TEST_CASE("manual entry resolves on subsequent open", "[linkdb][overlay]") {
"[linkdb][overlay]") {
auto db = Database::open(fresh_overlay()); auto db = Database::open(fresh_overlay());
REQUIRE(db.has_value()); REQUIRE(db.has_value());
Recipe override_r{ Recipe r{
.nixpkgs_attr = "fmt_pinned", .nixpkgs_attr = "fmt_pinned",
.find_package = "fmt CONFIG REQUIRED", .find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"}, .targets = {"fmt::fmt"},
.source = "manual", .source = "manual",
}; };
REQUIRE(db->add_manual("fmt", ">=10.0.0", override_r).has_value()); REQUIRE(db->add_manual("fmt", ">=10.0.0", r).has_value());
auto got = db->resolve("fmt", "10.2.0"); auto got = db->resolve("fmt", "10.2.0");
REQUIRE(got.has_value()); REQUIRE(got.has_value());
@@ -79,13 +78,10 @@ TEST_CASE("manual entry is constrained by version_range",
}; };
REQUIRE(db->add_manual("fmt", ">=11.0.0", r).has_value()); REQUIRE(db->add_manual("fmt", ">=11.0.0", r).has_value());
// 10.x falls outside the manual range and falls through to curated auto miss = db->resolve("fmt", "10.2.0");
auto curated = db->resolve("fmt", "10.2.0"); REQUIRE_FALSE(miss.has_value());
REQUIRE(curated.has_value()); REQUIRE(miss.error().code == ErrorCode::LinkdbUnknownPackage);
REQUIRE(curated->source == "curated");
REQUIRE(curated->nixpkgs_attr == "fmt_10");
// 11.x matches the manual range
auto manual = db->resolve("fmt", "11.0.0"); auto manual = db->resolve("fmt", "11.0.0");
REQUIRE(manual.has_value()); REQUIRE(manual.has_value());
REQUIRE(manual->source == "manual"); REQUIRE(manual->source == "manual");

View File

@@ -94,13 +94,11 @@ TEST_CASE("verify_link rolls the provisional row back when the build fails",
REQUIRE_FALSE(r.has_value()); REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == cargoxx::util::ErrorCode::BuildCmakeFailed); 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); auto db = Database::open(req.overlay_path);
REQUIRE(db.has_value()); REQUIRE(db.has_value());
auto rec = db->resolve("fmt", "10.2.0", {}); auto rec = db->resolve("fmt", "10.2.0", {});
REQUIRE(rec.has_value()); REQUIRE_FALSE(rec.has_value());
REQUIRE(rec->source == "curated"); REQUIRE(rec.error().code == cargoxx::util::ErrorCode::LinkdbUnknownPackage);
} }
TEST_CASE("verify_link cleans up its scratch project", "[resolver][verify_link]") { TEST_CASE("verify_link cleans up its scratch project", "[resolver][verify_link]") {