[M6] brute-force probe + INTERFACE IMPORTED target codegen

This commit is contained in:
2026-05-15 14:26:06 +00:00
parent 01b3c28d6c
commit 94e658fdf1
10 changed files with 250 additions and 57 deletions

View File

@@ -12,7 +12,7 @@ nixpkgs_attr = 'reproc'
version = '*'
[[package]]
linkdb_source = 'pkg-config'
linkdb_source = 'cmake-findmodule'
name = 'sqlite'
nixpkgs_attr = 'sqlite'
version = '*'

View File

@@ -19,8 +19,7 @@ add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)
# ----- dependencies -----
find_package(reproc CONFIG REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(SQLITE3 REQUIRED IMPORTED_TARGET sqlite3)
find_package(SQLite3 REQUIRED)
find_package(Catch2 CONFIG REQUIRED)
# ----- library target -----
@@ -57,6 +56,7 @@ target_sources(cargoxx
../src/lockfile/lockfile.cpp
../src/manifest/parser.cpp
../src/manifest/writer.cpp
../src/resolver/brute_scan.cpp
../src/resolver/conan_probe.cpp
../src/resolver/discover.cpp
../src/resolver/findmodule_scan.cpp
@@ -76,7 +76,7 @@ target_sources(cargoxx
target_include_directories(cargoxx SYSTEM PRIVATE ../third_party)
target_link_libraries(cargoxx PUBLIC
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
)
# ----- binary target -----
@@ -85,7 +85,7 @@ set_target_properties(cargoxx_bin PROPERTIES OUTPUT_NAME cargoxx)
target_link_libraries(cargoxx_bin PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
)
# ----- tests -----
@@ -95,7 +95,7 @@ add_executable(test_cmd_add ../tests/cmd_add.cpp)
target_link_libraries(test_cmd_add PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -104,7 +104,7 @@ add_executable(test_cmd_build ../tests/cmd_build.cpp)
target_link_libraries(test_cmd_build PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -113,7 +113,7 @@ add_executable(test_cmd_clean ../tests/cmd_clean.cpp)
target_link_libraries(test_cmd_clean PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -122,7 +122,7 @@ add_executable(test_cmd_new ../tests/cmd_new.cpp)
target_link_libraries(test_cmd_new PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -131,7 +131,7 @@ add_executable(test_cmd_remove ../tests/cmd_remove.cpp)
target_link_libraries(test_cmd_remove PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -140,7 +140,7 @@ add_executable(test_cmd_run ../tests/cmd_run.cpp)
target_link_libraries(test_cmd_run PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -149,7 +149,7 @@ add_executable(test_codegen_cmake ../tests/codegen_cmake.cpp)
target_link_libraries(test_codegen_cmake PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -158,7 +158,7 @@ add_executable(test_codegen_flake ../tests/codegen_flake.cpp)
target_link_libraries(test_codegen_flake PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -167,7 +167,7 @@ add_executable(test_conan_probe_live ../tests/conan_probe_live.cpp)
target_link_libraries(test_conan_probe_live PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -176,7 +176,7 @@ add_executable(test_conan_probe_parse ../tests/conan_probe_parse.cpp)
target_link_libraries(test_conan_probe_parse PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -185,7 +185,7 @@ add_executable(test_devbox_resolve_live ../tests/devbox_resolve_live.cpp)
target_link_libraries(test_devbox_resolve_live PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -194,7 +194,7 @@ add_executable(test_devbox_resolve_parse ../tests/devbox_resolve_parse.cpp)
target_link_libraries(test_devbox_resolve_parse PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -203,7 +203,7 @@ add_executable(test_exec_run ../tests/exec_run.cpp)
target_link_libraries(test_exec_run PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -212,7 +212,7 @@ add_executable(test_layout_discovery ../tests/layout_discovery.cpp)
target_link_libraries(test_layout_discovery PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -221,7 +221,7 @@ add_executable(test_linkdb_lookup ../tests/linkdb_lookup.cpp)
target_link_libraries(test_linkdb_lookup PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -230,7 +230,7 @@ add_executable(test_linkdb_overlay ../tests/linkdb_overlay.cpp)
target_link_libraries(test_linkdb_overlay PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -239,7 +239,7 @@ add_executable(test_lockfile_round_trip ../tests/lockfile_round_trip.cpp)
target_link_libraries(test_lockfile_round_trip PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -248,7 +248,7 @@ add_executable(test_manifest_parse ../tests/manifest_parse.cpp)
target_link_libraries(test_manifest_parse PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -257,7 +257,7 @@ add_executable(test_manifest_write ../tests/manifest_write.cpp)
target_link_libraries(test_manifest_write PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -266,7 +266,7 @@ add_executable(test_nix_cmake_scan_live ../tests/nix_cmake_scan_live.cpp)
target_link_libraries(test_nix_cmake_scan_live PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -275,7 +275,7 @@ add_executable(test_nix_cmake_scan_parse ../tests/nix_cmake_scan_parse.cpp)
target_link_libraries(test_nix_cmake_scan_parse PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -284,7 +284,7 @@ add_executable(test_nixpkgs_git_resolve ../tests/nixpkgs_git_resolve.cpp)
target_link_libraries(test_nixpkgs_git_resolve PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -293,7 +293,7 @@ add_executable(test_nixpkgs_probe_live ../tests/nixpkgs_probe_live.cpp)
target_link_libraries(test_nixpkgs_probe_live PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -302,7 +302,7 @@ add_executable(test_nixpkgs_probe_parse ../tests/nixpkgs_probe_parse.cpp)
target_link_libraries(test_nixpkgs_probe_parse PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -311,7 +311,7 @@ add_executable(test_semver_satisfies ../tests/semver_satisfies.cpp)
target_link_libraries(test_semver_satisfies PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -320,7 +320,7 @@ add_executable(test_util_error ../tests/util_error.cpp)
target_link_libraries(test_util_error PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -329,7 +329,7 @@ add_executable(test_vcpkg_probe_live ../tests/vcpkg_probe_live.cpp)
target_link_libraries(test_vcpkg_probe_live PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -338,7 +338,7 @@ add_executable(test_vcpkg_probe_parse ../tests/vcpkg_probe_parse.cpp)
target_link_libraries(test_vcpkg_probe_parse PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
@@ -347,7 +347,7 @@ add_executable(test_verify_link_unit ../tests/verify_link_unit.cpp)
target_link_libraries(test_verify_link_unit PRIVATE
cargoxx
reproc
PkgConfig::SQLITE3
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)

View File

@@ -17,7 +17,6 @@
nativeBuildInputs = [
pkgs.ninja
pkgs.cmake
pkgs.pkg-config
];
buildInputs = [
pkgs.reproc

View File

@@ -89,7 +89,34 @@ auto emit_find_packages(const std::vector<linkdb::Recipe>& recipes,
bool pkgconfig_emitted = false;
auto emit_one = [&](const linkdb::Recipe& r) {
if (r.pkg_config_module && !r.pkg_config_module->empty()) {
if (!r.brute_force_libs.empty() || !r.brute_force_includes.empty()) {
// Synthesize a single INTERFACE IMPORTED target named after
// the first entry in `targets` (e.g. `<pkg>::<pkg>`). No
// find_package — every artifact path is baked in.
if (r.targets.empty()) {
return;
}
const auto& target = r.targets.front();
out += std::format("add_library({} INTERFACE IMPORTED)\n", target);
if (!r.brute_force_libs.empty()) {
out += std::format("set_property(TARGET {} APPEND PROPERTY "
"INTERFACE_LINK_LIBRARIES",
target);
for (const auto& l : r.brute_force_libs) {
out += std::format("\n \"{}\"", l);
}
out += ")\n";
}
if (!r.brute_force_includes.empty()) {
out += std::format("set_property(TARGET {} APPEND PROPERTY "
"INTERFACE_INCLUDE_DIRECTORIES",
target);
for (const auto& i : r.brute_force_includes) {
out += std::format("\n \"{}\"", i);
}
out += ")\n";
}
} else if (r.pkg_config_module && !r.pkg_config_module->empty()) {
if (!pkgconfig_emitted) {
out += "find_package(PkgConfig REQUIRED)\n";
pkgconfig_emitted = true;

View File

@@ -65,6 +65,8 @@ auto Database::resolve(const std::string& package, const std::string& version,
.targets = row.targets,
.source = row.source,
.pkg_config_module = row.pkg_config_module,
.brute_force_libs = row.brute_force_libs,
.brute_force_includes = row.brute_force_includes,
};
}
return std::unexpected(util::Error{

View File

@@ -25,6 +25,14 @@ struct Recipe {
// overlay rows don't need a sentinel.
std::optional<std::string> pkg_config_module;
// Set by the brute-force probe (the last-resort discover stage).
// When non-empty, codegen skips `find_package(...)` and instead
// synthesizes an INTERFACE IMPORTED target named in `targets[0]`
// (which is `<pkg>::<pkg>`) with these absolute lib paths +
// include dirs.
std::vector<std::string> brute_force_libs;
std::vector<std::string> brute_force_includes;
bool operator==(const Recipe&) const = default;
};
@@ -40,6 +48,8 @@ struct OverlayRow {
std::string source;
std::int64_t verified_at = 0;
std::optional<std::string> pkg_config_module;
std::vector<std::string> brute_force_libs;
std::vector<std::string> brute_force_includes;
};
// RAII wrapper for an open sqlite3 connection used by the overlay database.

View File

@@ -90,24 +90,40 @@ auto overlay_open(const std::filesystem::path& path)
});
}
// Schema migration: legacy overlays predate pkg_config_module.
// SQLite ADD COLUMN errors when the column already exists; treat
// "duplicate column" as success.
constexpr const char* MIGRATE_PC =
"ALTER TABLE recipes ADD COLUMN pkg_config_module TEXT";
char* mig_err = nullptr;
if (sqlite3_exec(state->handle(), MIGRATE_PC, nullptr, nullptr, &mig_err) !=
SQLITE_OK) {
if (mig_err && std::string_view{mig_err}.find("duplicate column") ==
std::string_view::npos) {
std::string msg = std::format("cannot migrate overlay schema: {}",
mig_err ? mig_err : "?");
// Schema migrations. SQLite ADD COLUMN errors when the column
// already exists; treat "duplicate column" as success.
auto add_column = [&](const char* sql) -> util::Result<void> {
char* mig_err = nullptr;
if (sqlite3_exec(state->handle(), sql, nullptr, nullptr, &mig_err) !=
SQLITE_OK) {
if (mig_err && std::string_view{mig_err}.find("duplicate column") ==
std::string_view::npos) {
std::string msg = std::format("cannot migrate overlay schema: {}",
mig_err ? mig_err : "?");
sqlite3_free(mig_err);
return std::unexpected(util::Error{
util::ErrorCode::LinkdbCorrupt, std::move(msg), "", path,
std::nullopt,
});
}
sqlite3_free(mig_err);
return std::unexpected(util::Error{
util::ErrorCode::LinkdbCorrupt, std::move(msg), "", path, std::nullopt,
});
}
sqlite3_free(mig_err);
return {};
};
if (auto r = add_column(
"ALTER TABLE recipes ADD COLUMN pkg_config_module TEXT");
!r) {
return std::unexpected(r.error());
}
if (auto r = add_column(
"ALTER TABLE recipes ADD COLUMN brute_force_libs TEXT");
!r) {
return std::unexpected(r.error());
}
if (auto r = add_column(
"ALTER TABLE recipes ADD COLUMN brute_force_includes TEXT");
!r) {
return std::unexpected(r.error());
}
return state;
@@ -119,8 +135,8 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
constexpr const char* SQL =
"INSERT OR REPLACE INTO recipes "
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
" verified_at, pkg_config_module) "
"VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?, ?)";
" verified_at, pkg_config_module, brute_force_libs, brute_force_includes) "
"VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?, ?, ?, ?)";
sqlite3* db = state.handle();
sqlite3_stmt* stmt = nullptr;
@@ -129,6 +145,8 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
}
auto targets_str = nlohmann::json(r.targets).dump();
auto libs_str = nlohmann::json(r.brute_force_libs).dump();
auto incs_str = nlohmann::json(r.brute_force_includes).dump();
auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
@@ -144,6 +162,8 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
} else {
sqlite3_bind_null(stmt, 7);
}
sqlite3_bind_text(stmt, 8, libs_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 9, incs_str.c_str(), -1, SQLITE_TRANSIENT);
auto rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
@@ -162,8 +182,8 @@ auto overlay_insert(OverlayState& state, const std::string& package,
constexpr const char* SQL =
"INSERT OR REPLACE INTO recipes "
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
" verified_at, pkg_config_module) "
"VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)";
" verified_at, pkg_config_module, brute_force_libs, brute_force_includes) "
"VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)";
sqlite3* db = state.handle();
sqlite3_stmt* stmt = nullptr;
@@ -172,6 +192,8 @@ auto overlay_insert(OverlayState& state, const std::string& package,
}
auto targets_str = nlohmann::json(r.targets).dump();
auto libs_str = nlohmann::json(r.brute_force_libs).dump();
auto incs_str = nlohmann::json(r.brute_force_includes).dump();
sqlite3_bind_text(stmt, 1, package.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, version_range.c_str(), -1, SQLITE_TRANSIENT);
@@ -185,6 +207,8 @@ auto overlay_insert(OverlayState& state, const std::string& package,
} else {
sqlite3_bind_null(stmt, 8);
}
sqlite3_bind_text(stmt, 9, libs_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 10, incs_str.c_str(), -1, SQLITE_TRANSIENT);
auto rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
@@ -276,7 +300,7 @@ auto overlay_query(OverlayState& state, const std::string& package)
-> util::Result<std::vector<OverlayRow>> {
constexpr const char* SQL =
"SELECT version_range, nixpkgs_attr, find_package, targets, source, verified_at, "
" pkg_config_module "
" pkg_config_module, brute_force_libs, brute_force_includes "
"FROM recipes WHERE package = ?";
sqlite3* db = state.handle();
@@ -310,6 +334,23 @@ auto overlay_query(OverlayState& state, const std::string& package)
if (sqlite3_column_type(stmt, 6) != SQLITE_NULL) {
row.pkg_config_module = column_text(stmt, 6);
}
auto parse_str_array = [&](int col, std::vector<std::string>& out_arr) {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL) {
return;
}
try {
auto txt = column_text(stmt, col);
if (txt.empty()) {
return;
}
out_arr =
nlohmann::json::parse(txt).get<std::vector<std::string>>();
} catch (const nlohmann::json::exception&) {
// legacy/manual rows may have stored garbage; ignore
}
};
parse_str_array(7, row.brute_force_libs);
parse_str_array(8, row.brute_force_includes);
out.push_back(std::move(row));
}
sqlite3_finalize(stmt);

View File

@@ -0,0 +1,81 @@
module cargoxx.resolver;
import std;
import cargoxx.util;
namespace cargoxx::resolver {
namespace fs = std::filesystem;
namespace {
auto is_lib_filename(const fs::path& p) -> bool {
auto name = p.filename().string();
if (!name.starts_with("lib")) {
return false;
}
auto ext = p.extension().string();
if (ext == ".a") {
return true;
}
if (ext == ".so" || ext == ".dylib") {
return true;
}
// .so.<N>, .so.<N>.<M>, ... — common shared-lib versioning. Use a
// looser check: if the name contains ".so." or ".dylib." anywhere
// after the lib prefix, accept it.
return name.find(".so.") != std::string::npos ||
name.find(".dylib.") != std::string::npos;
}
} // namespace
auto brute_scan(const fs::path& store_path, const std::string& package_name)
-> util::Result<BruteCandidate> {
if (package_name.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
"brute_scan: package name is empty",
"", std::nullopt, std::nullopt,
});
}
const auto lib_dir = store_path / "lib";
const auto include_dir = store_path / "include";
BruteCandidate out;
std::error_code ec;
if (fs::exists(lib_dir, ec) && !ec) {
for (const auto& entry : fs::directory_iterator{
lib_dir, fs::directory_options::skip_permission_denied, ec}) {
if (!entry.is_regular_file() && !entry.is_symlink()) {
continue;
}
if (!is_lib_filename(entry.path())) {
continue;
}
out.lib_files.push_back(entry.path().string());
}
std::ranges::sort(out.lib_files);
}
if (fs::exists(include_dir, ec) && !ec) {
// For include/, expose the top-level directory itself (e.g.
// `<store>/include`) — that's what `#include <pkg/foo.h>`
// expects. Adding every subdir would also work, but is noisier
// and provokes name collisions across deps.
out.include_dirs.push_back(include_dir.string());
}
if (out.lib_files.empty() && out.include_dirs.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no libs or headers under '{}'", store_path.string()),
"", store_path, std::nullopt,
});
}
return out;
}
} // namespace cargoxx::resolver

View File

@@ -89,6 +89,20 @@ auto recipe_from_findmodule(const FindModuleCandidate& fm,
};
}
auto recipe_from_brute(const BruteCandidate& b, const std::string& name,
const std::string& nixpkgs_attr, const std::string& source)
-> linkdb::Recipe {
return linkdb::Recipe{
.nixpkgs_attr = nixpkgs_attr,
// No find_package — codegen synthesizes the target directly.
.find_package = "",
.targets = {std::format("{}::{}", name, name)},
.source = source,
.brute_force_libs = b.lib_files,
.brute_force_includes = b.include_dirs,
};
}
struct Candidate {
std::string source;
linkdb::Recipe recipe;
@@ -183,6 +197,12 @@ auto discover(const std::string& name, const std::string& version_spec,
candidates.push_back(
{"pkg-config", recipe_from_pc(*pc_hit, name, "pkg-config")});
}
if (!realized_dev_path.empty()) {
if (auto b = brute_scan(fs::path{realized_dev_path}, name); b) {
candidates.push_back(
{"brute-force", recipe_from_brute(*b, name, name, "brute-force")});
}
}
if (candidates.empty()) {
return std::unexpected(error(

View File

@@ -110,6 +110,19 @@ auto pc_scan(const std::filesystem::path& store_path,
const std::string& package_name)
-> util::Result<PcCandidate>;
// Last-resort brute-force: every shared/static lib + every include
// directory under the store path is wrapped in a synthetic
// `<pkg>::<pkg>` INTERFACE IMPORTED target. Used when nothing more
// structured matched.
struct BruteCandidate {
std::vector<std::string> lib_files; // abs paths to lib*.{a,so,dylib}
std::vector<std::string> include_dirs; // abs paths under include/
};
auto brute_scan(const std::filesystem::path& store_path,
const std::string& package_name)
-> util::Result<BruteCandidate>;
// Output of a conan-center-index recipe scrape.
struct ConanRecipe {
std::string find_package; // e.g. "fmt CONFIG REQUIRED"