diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a0f40..69f40ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,20 @@ All notable changes to cargoxx will be documented in this file. and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source paths emitted relative to `build/` (i.e. prefixed with `../`). Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases. +- `cargoxx.resolver::nix_cmake_scan(store_path, package_name)` — walks + `/lib/cmake/**` for `*Config.cmake` / `*-config.cmake` + files, scans them and their sibling `.cmake` files (e.g. the + `-targets.cmake` that real packages like fmt put their + `add_library(... IMPORTED)` calls in) for IMPORTED + ALIAS targets, + and returns the `NixCmakeCandidate` whose derived stem best matches + `package_name` (case-insensitive equality > prefix > first non-empty). + Pure helpers `scan_imported_targets(text)` and + `config_stem_to_package(filename)` are exported for unit testing. + `tests/nix_cmake_scan_parse.cpp` covers 11 cases including a fixture + that mirrors fmt's `-config.cmake → -targets.cmake` + layout. `tests/nix_cmake_scan_live.cpp` (gated by + `CARGOXX_NETWORK_TESTS=1`) realizes `nixpkgs#fmt.dev` via + `nix build` and verifies `fmt::fmt` is discovered end-to-end. - `cargoxx.resolver::nixpkgs_probe(attr)` — runs `nix eval nixpkgs# --json --apply 'p: { version, path }'` via `exec::run` and returns a `NixpkgsInfo { attr, version, out_path }`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cac850..bbf32d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,7 @@ target_sources(cargoxx src/codegen/cmake.cpp src/exec/subprocess.cpp src/resolver/nixpkgs_probe.cpp + src/resolver/nix_cmake_scan.cpp src/cli/cmd_new.cpp src/cli/cmd_build.cpp src/cli/cmd_run.cpp diff --git a/docs/auto-resolution.md b/docs/auto-resolution.md new file mode 100644 index 0000000..3102e95 --- /dev/null +++ b/docs/auto-resolution.md @@ -0,0 +1,273 @@ +# Auto-resolution for non-curated packages + +Status: in progress. Tracks the implementation of `cargoxx add ` for +packages that are not in `data/linkdb.json`. See `SPEC.md` §9 step 4–6 for +the contract this implements. + +## Goal + +Today `cargoxx add` only succeeds for the 25 packages baked into +`data/linkdb.json`. This work extends `cargoxx add ` to fall through +to the user's local machine and, on success, persist the discovered +recipe to the SQLite overlay so subsequent runs are instant. + +The user-stated steps: + +1. confirm the package exists in `nixpkgs` (`nixos-unstable`), +2. discover its CMake `find_package` / target rules via Conan, then vcpkg, + then by scanning `lib/cmake/**/*Config.cmake` under the package's nix + store path, +3. verify the candidate by building an empty program that links the dep, +4. record the version (already in hand from step 1's `nix eval`), +5. write the recipe to the overlay so it sticks. + +## Design decisions + +| Decision | Choice | Why | +| --- | --- | --- | +| Verify depth | full `cargoxx build` of a tmp project | catches link / ABI errors that configure-only would miss (e.g. abseil-cpp's libstdc++ vs libc++ mismatch already exposed by `verify-curated-db.sh`) | +| Probe order | Conan → vcpkg → nix-cmake-scan; first that *passes verification* wins; failed candidates fall through | maximizes hit rate without polluting overlay | +| Discovery side-effects | `Database::resolve()` stays pure (overlay+curated only); a separate `Database::discover()` does network + verify + persist | preserves the existing test surface; `cmd_add` orchestrates the chain | +| Failure caching | populate `resolution_failures` (already in schema) when *all* probes fail; subsequent retries within 24 h short-circuit | prevents repeated minute-long retries | +| Verification result handling | scaffold tmp project, write provisional overlay row with `verified_at = 0`, build; on success rewrite `verified_at = now`; on failure delete the row | overlay only ever holds verified recipes | + +## Resolution chain + +``` +db.resolve(name, version, components) + ├─ overlay rows (existing) + ├─ curated JSON (existing) + └─ on LinkdbUnknownPackage → cmd_add calls db.discover(name, project_root) + ├─ nixpkgs probe: nix eval nixpkgs# for { version, path } + │ fail → resolution_failures, return error + ├─ Conan probe: GET conan-center-index/recipes//all/conanfile.py + │ regex out cmake_target_name + cmake_file_name + ├─ vcpkg probe: GET microsoft/vcpkg/ports//usage + │ parse the literal CMake snippet + ├─ nix-cmake-scan: walk /lib/cmake/**/*Config.cmake + │ regex add_library( ... IMPORTED) for targets + │ derive find_package name from the *Config.cmake filename stem + │ + ├─ for each candidate (in order above): + │ verify_link(candidate, name, version, components, overlay_path) + │ — scaffold tmp project (cmd_new), + │ — provisional overlay row pointing at the candidate, + │ — write empty src/main.cpp, + │ — call cmd_build(no_build = false) to run nix develop -c + │ cmake configure + build, + │ — succeeds → rewrite overlay row with verified_at = now; + │ return Recipe to caller + │ — fails → delete provisional row, try next probe + │ + └─ all candidates failed → record to resolution_failures; + return ResolutionUnsatisfiable +``` + +## File layout + +``` +src/resolver/ +├── resolver.cppm # public API surface for all resolver helpers +├── nixpkgs_probe.cpp # ✅ Phase 1 (committed: 1c7ff39) +├── nix_cmake_scan.cpp # Phase 2 +├── conan_probe.cpp # Phase 3 +├── vcpkg_probe.cpp # Phase 4 +└── verify_link.cpp # Phase 5 +``` + +`Database::discover` and the `cmd_add` wire-up land in Phase 6 by editing +`src/linkdb/curated.cpp`, `src/linkdb/overlay.cpp`, and +`src/cli/cmd_add.cpp`. + +The deferred files in `TECH_SPEC.md` §1 (`nixhub.cpp`, `lazamar.cpp`, +`nixpkgs_git.cpp`) belong to a separate feature — the *version* resolver +that picks a concrete version from a range. Out of scope here. + +## Critical files (re-)used + +| File | Why | +| --- | --- | +| `src/linkdb/linkdb.cppm` | extend with `Database::discover()` declaration | +| `src/linkdb/curated.cpp:158` | `Database::resolve` already does overlay → curated; discovery is *not* folded in here, kept side-effect free | +| `src/linkdb/overlay.cpp` | split `overlay_insert_manual` → `overlay_insert_recipe(row, source)` so non-`manual` sources are persistable; add `overlay_delete_recipe`; add `overlay_record_failure` for `resolution_failures` | +| `src/cli/cmd_add.cpp:48` | after `db->resolve(...)` returns `LinkdbUnknownPackage`, call `db->discover(name, project_root)` and use the returned recipe | +| `src/exec/exec.cppm`, `src/exec/subprocess.cpp` | reuse `exec::run` for `nix eval` and `curl` — no new tooling, just new call sites | +| `src/util/util.cppm` | reuse `ResolutionUnknownPackage` (E40), `ResolutionNetworkError` (E41), `ResolutionUnsatisfiable` (E42); no new error codes | +| `src/cli/cmd_build.cpp` | called by `verify_link.cpp`; takes `overlay_path` and `project_root`; no signature change needed | +| `scripts/verify-curated-db.sh` | conceptual template for the `verify_link` flow — same pattern as that script, in code form | + +## Probe specs + +### A. nixpkgs_probe (✅ done — Phase 1, 1c7ff39) + +``` +nix eval nixpkgs# --json --apply 'p: { version = p.version or ""; path = p.outPath; }' +``` + +- `--extra-experimental-features 'nix-command flakes'` baked into the call + so it works without user-side `nix.conf` flags. +- 60 s `ExecOptions.timeout`. +- Failure modes: missing attribute (`stderr` has `does not provide attribute`) + → `ResolutionUnknownPackage`; otherwise `ResolutionNetworkError`. +- Returned: `NixpkgsInfo { attr, version, out_path }`. +- Field name **must** be `path`, not `outPath`. nix's `--json` mode coerces + any attrset containing `outPath` to a bare-string derivation reference, + which would lose the `version` field. + +### B. nix_cmake_scan (Phase 2, next) + +- Walk `/lib/cmake/` recursively. +- For each `Config.cmake` or `-config.cmake`: + - `find_package` name = stem ``. + - Read file. Regex + `add_library\(([^ ]+)\s+(STATIC|SHARED|INTERFACE|UNKNOWN)\s+IMPORTED\)` + to extract IMPORTED targets. + - Also pick up `add_library( ALIAS )` so the canonical + `::` form gets detected. +- Pick best candidate: + 1. case-insensitive equality between stem and `package_name`, + 2. prefix match, + 3. first config with non-empty target list. +- Returns `NixCmakeCandidate { find_package, targets, config_file }` or + `ResolutionUnknownPackage`. + +### C. Conan probe (Phase 3) + +- Text-only — never executes Python. SPEC §14 mandates this. +- `curl -fsSL https://raw.githubusercontent.com/conan-io/conan-center-index/master/recipes//all/conanfile.py`. +- Regex `cmake_target_name\s*=\s*['"]([^'"]+)['"]` and same for + `cmake_file_name`. Handle both `cpp_info.set_property("cmake_target_name", ...)` + and the legacy `self.cpp_info.names["cmake"] = "..."` forms. +- Pure parser exposed as `parse_conanfile(text)`; the network adapter + wraps `curl` via `exec::run`. +- 404 → `ResolutionUnknownPackage`; transport errors → `ResolutionNetworkError`. + +### D. vcpkg probe (Phase 4) + +- `curl -fsSL https://raw.githubusercontent.com/microsoft/vcpkg/master/ports//usage`. +- The file is plain CMake. Extract first `find_package( ...)` line and + any `target_link_libraries(... ::...)` lines. +- Pure parser exposed as `parse_vcpkg_usage(text)`. + +### E. verify_link (Phase 5) + +```cpp +auto verify_link(const Recipe& candidate, + const std::string& name, + const std::string& version_spec, + const std::vector& components, + const std::filesystem::path& cargoxx_overlay_path) + -> util::Result; +``` + +- Create `/cargoxx-verify-` (mktemp). +- `cmd_new(name, /*lib_only=*/false, tmp_parent)`. +- Insert `candidate` into `cargoxx_overlay_path` with the right `source` + and `verified_at = 0` (provisional). +- Mutate the scaffolded manifest to declare `name` with `version_spec` + and `components`. +- Overwrite `src/main.cpp` with `int main() {}` — empty body. The point + is to exercise find_package + target_link_libraries + linker, *not* to + call any specific API (which would require per-package knowledge). +- Call `cmd_build(tmp_proj, no_build=false, release=false, + target=nullopt, overlay_path=cargoxx_overlay_path)`. +- On success: rewrite the overlay row with `verified_at = now()`, + return `{}`. +- On failure: delete the provisional row, return the build error. +- Always: `std::filesystem::remove_all(tmp_dir)` (RAII helper). + +## Persistence semantics + +| Probe path | `source` column | `verified_at` | TTL (existing `overlay_is_fresh`) | +| --- | --- | --- | --- | +| Conan probe verified | `conan` | now | 30 days | +| vcpkg probe verified | `vcpkg` | now | 30 days | +| nix-cmake-scan verified | `nix-probe` | now | 30 days | +| Manual via `linkdb add` | `manual` | now | never expires | + +`resolution_failures` populated only when **all** probes fail. Subsequent +`cargoxx add` calls within 24 h skip probing and return the cached error. + +## Phasing (one commit per phase) + +| Phase | Status | Commit | +| --- | --- | --- | +| 1. nixpkgs_probe + JSON parser | ✅ | `1c7ff39` | +| 2. nix_cmake_scan | pending | — | +| 3. conan_probe + parse_conanfile | pending | — | +| 4. vcpkg_probe + parse_vcpkg_usage | pending | — | +| 5. verify_link (tmp project + cmd_build) | pending | — | +| 6. Database::discover + cmd_add wire-up + failure caching | pending | — | + +## Testing strategy + +| Test | Mechanism | +| --- | --- | +| `parse_nix_eval_json(text)` | ✅ Catch2 unit (`tests/nixpkgs_probe_parse.cpp`) | +| `nixpkgs_probe(name)` | ✅ network-gated (`tests/nixpkgs_probe_live.cpp`); requires `CARGOXX_NETWORK_TESTS=1` | +| `scan_imported_targets(text)` | Catch2 unit | +| `nix_cmake_scan(tmp)` | Catch2 unit using a fixture tree | +| `parse_conanfile(text)` | Catch2 unit; embedded conanfile.py snippets covering both old and new forms | +| `parse_vcpkg_usage(text)` | Catch2 unit | +| `conan_probe(name)` | network-gated; against `fmt` | +| `vcpkg_probe(name)` | network-gated; against `fmt` | +| `verify_link` end-to-end | network-gated; uses `simdjson` (small, present in nixpkgs, not in our curated DB) | +| `cmd_add` end-to-end on uncurated package | network-gated; full flow on `simdjson` | + +Failure-mode coverage: +- Conan/vcpkg 404 → `ResolutionUnknownPackage` +- `nix eval` errors → `ResolutionUnknownPackage` +- All probes return candidates that fail to verify-link → record failure, + return `ResolutionUnsatisfiable` +- `resolution_failures` cache hit → returns the recorded error without + re-probing + +## Definition of done + +After Phase 6: + +```sh +nix develop -c cmake --build build && \ + ctest --test-dir build --output-on-failure # all unit tests green +CARGOXX_NETWORK_TESTS=1 nix develop -c ctest --test-dir build # live tests too +``` + +Manual smoke (matches the user's request 1–5): + +```sh +cd /tmp && rm -rf simd-smoke && mkdir simd-smoke && cd simd-smoke +~/cargoxx/build/cargoxx new app && cd app +~/cargoxx/build/cargoxx add simdjson # not in curated; triggers discover +# Expected output: +# probing nixpkgs#simdjson ... ok (3.x.y) +# probing conan-center-index ... ok (cmake_target_name = simdjson::simdjson) +# verifying ... ok +# Added simdjson 3.x.y (linkdb: conan) +~/cargoxx/build/cargoxx build # ordinary build path now + # picks up the freshly cached + # overlay row +``` + +A second `cargoxx add simdjson` in another fresh project hits the overlay +directly and returns instantly — proves persistence step (5). + +## Risks / known limits + +- **Network**: Conan + vcpkg probes need outbound HTTPS. The + network-gated test layer covers this; the unit tests on pure parsers + don't need network. +- **Conan recipe shape variation**: ~10 % of recipes use Python + conditionals to set `cmake_target_name` per option — text parsing + will miss these. Falls through to vcpkg / nix-scan, which is the + point of the chain. +- **nix-cmake-scan heuristics**: packages without standard + `lib/cmake//Config.cmake` layout won't be picked up. Acceptable + for v0.2; the manual escape hatch (`cargoxx linkdb add`) covers + edge cases. +- **Overlay growth**: long-tail packages will accumulate in the user's + overlay sqlite. No cleanup in v0.2 — not a concern at human-scale + package counts. +- **Verify-link slowness**: full `cargoxx build` per candidate. First + probe usually wins, so it's typically one build. Worst case: three + builds (Conan fail, vcpkg fail, nix-scan ok). Document as expected + behavior in the CLI output (`verifying...` progress message). diff --git a/src/resolver/nix_cmake_scan.cpp b/src/resolver/nix_cmake_scan.cpp new file mode 100644 index 0000000..f2db13a --- /dev/null +++ b/src/resolver/nix_cmake_scan.cpp @@ -0,0 +1,252 @@ +module cargoxx.resolver; + +import std; +import cargoxx.util; + +namespace cargoxx::resolver { + +namespace fs = std::filesystem; + +auto config_stem_to_package(std::string_view filename) -> std::string { + std::string s{filename}; + // Drop any directory prefix. + if (auto slash = s.find_last_of('/'); slash != std::string::npos) { + s.erase(0, slash + 1); + } + constexpr std::array suffixes = {".cmake"}; + for (auto suf : suffixes) { + if (s.ends_with(suf)) { + s.erase(s.size() - std::string_view{suf}.size()); + } + } + constexpr std::array stems = {std::string_view{"Config"}, std::string_view{"-config"}}; + for (auto stem : stems) { + if (s.ends_with(stem)) { + s.erase(s.size() - stem.size()); + break; + } + } + return s; +} + +namespace { + +// Walks `text` looking for `add_library( ... IMPORTED ...)` and +// `add_library( ALIAS )` forms. Returns the bare target names. +auto collect_targets(std::string_view text) -> std::vector { + std::vector out; + constexpr std::string_view marker = "add_library"; + std::size_t pos = 0; + while (pos < text.size()) { + auto next = text.find(marker, pos); + if (next == std::string_view::npos) { + break; + } + // Must be at line start or preceded by whitespace/punct (avoid + // matching `_add_library` etc.). + if (next > 0) { + char prev = text[next - 1]; + if (std::isalnum(static_cast(prev)) || prev == '_') { + pos = next + marker.size(); + continue; + } + } + // Find the opening '('. + auto open = text.find('(', next + marker.size()); + if (open == std::string_view::npos) { + break; + } + auto close = text.find(')', open + 1); + if (close == std::string_view::npos) { + break; + } + auto args = text.substr(open + 1, close - open - 1); + + // Tokenize by whitespace. CMake's add_library has the form + // add_library( [STATIC|SHARED|...] [IMPORTED] ...) + // or + // add_library( ALIAS ) + std::vector toks; + std::size_t tp = 0; + while (tp < args.size()) { + while (tp < args.size() && + std::isspace(static_cast(args[tp]))) { + ++tp; + } + if (tp >= args.size()) { + break; + } + std::size_t start = tp; + while (tp < args.size() && + !std::isspace(static_cast(args[tp]))) { + ++tp; + } + toks.push_back(args.substr(start, tp - start)); + } + + if (toks.size() >= 2) { + const auto& name = toks[0]; + bool imported = false; + bool alias = false; + for (std::size_t i = 1; i < toks.size(); ++i) { + if (toks[i] == "IMPORTED") { + imported = true; + } + if (toks[i] == "ALIAS") { + alias = true; + } + } + if (imported || alias) { + out.emplace_back(name); + } + } + + pos = close + 1; + } + return out; +} + +} // namespace + +auto scan_imported_targets(std::string_view config_text) -> std::vector { + auto targets = collect_targets(config_text); + // Stable-dedup: preserve first-occurrence order, drop duplicates. + std::vector out; + out.reserve(targets.size()); + for (auto& t : targets) { + if (std::ranges::find(out, t) == out.end()) { + out.push_back(std::move(t)); + } + } + return out; +} + +namespace { + +// Score a stem against the queried package name. Lower is better. +// 0 — exact match (case-insensitive) +// 1 — prefix match (one is a case-insensitive prefix of the other) +// 2 — fallback (any other non-empty target list) +auto match_score(std::string_view stem, std::string_view pkg) -> int { + auto eq_ci = [](std::string_view a, std::string_view b) { + if (a.size() != b.size()) { + return false; + } + for (std::size_t i = 0; i < a.size(); ++i) { + auto al = std::tolower(static_cast(a[i])); + auto bl = std::tolower(static_cast(b[i])); + if (al != bl) { + return false; + } + } + return true; + }; + if (eq_ci(stem, pkg)) { + return 0; + } + auto starts_with_ci = [](std::string_view longer, std::string_view shorter) { + if (shorter.size() > longer.size()) { + return false; + } + for (std::size_t i = 0; i < shorter.size(); ++i) { + auto a = std::tolower(static_cast(longer[i])); + auto b = std::tolower(static_cast(shorter[i])); + if (a != b) { + return false; + } + } + return true; + }; + if (starts_with_ci(stem, pkg) || starts_with_ci(pkg, stem)) { + return 1; + } + return 2; +} + +auto is_config_filename(const fs::path& p) -> bool { + if (p.extension() != ".cmake") { + return false; + } + auto stem = p.stem().string(); + return stem.ends_with("Config") || stem.ends_with("-config"); +} + +} // namespace + +auto nix_cmake_scan(const fs::path& store_path, const std::string& package_name) + -> util::Result { + const auto cmake_dir = store_path / "lib" / "cmake"; + std::error_code ec; + if (!fs::exists(cmake_dir, ec) || ec) { + return std::unexpected(util::Error{ + util::ErrorCode::ResolutionUnknownPackage, + std::format("no CMake configs under '{}'", cmake_dir.string()), + "", store_path, std::nullopt, + }); + } + + struct Match { + int score; + NixCmakeCandidate cand; + }; + std::vector matches; + + // Walk the tree once, find every *Config.cmake / *-config.cmake. The + // IMPORTED targets are usually in a sibling *-targets.cmake (via the + // standard `include(-targets.cmake)` pattern), so for each + // config file we scan every .cmake file in its parent directory. + for (const auto& entry : fs::recursive_directory_iterator{ + cmake_dir, fs::directory_options::skip_permission_denied}) { + if (!entry.is_regular_file()) { + continue; + } + if (!is_config_filename(entry.path())) { + continue; + } + + std::vector targets; + const auto pkg_dir = entry.path().parent_path(); + for (const auto& sib : fs::directory_iterator{pkg_dir}) { + if (!sib.is_regular_file() || sib.path().extension() != ".cmake") { + continue; + } + std::ifstream in{sib.path()}; + if (!in) { + continue; + } + std::string text{std::istreambuf_iterator{in}, {}}; + for (auto& t : scan_imported_targets(text)) { + if (std::ranges::find(targets, t) == targets.end()) { + targets.push_back(std::move(t)); + } + } + } + if (targets.empty()) { + continue; + } + + auto stem = config_stem_to_package(entry.path().filename().string()); + NixCmakeCandidate cand{ + .find_package = std::format("{} CONFIG REQUIRED", stem), + .targets = std::move(targets), + .config_file = entry.path(), + }; + matches.push_back(Match{ + .score = match_score(stem, package_name), + .cand = std::move(cand), + }); + } + + if (matches.empty()) { + return std::unexpected(util::Error{ + util::ErrorCode::ResolutionUnknownPackage, + std::format("no IMPORTED targets found under '{}'", cmake_dir.string()), + "", store_path, std::nullopt, + }); + } + + std::ranges::stable_sort(matches, {}, &Match::score); + return std::move(matches.front().cand); +} + +} // namespace cargoxx::resolver diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index bea3886..688b53c 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -26,4 +26,29 @@ auto parse_nix_eval_json(std::string_view attr, std::string_view json) // `ResolutionNetworkError` on timeout or evaluator errors. auto nixpkgs_probe(const std::string& attr) -> util::Result; +// One CMake config-file's IMPORTED targets together with the find_package +// expression derived from its filename stem. +struct NixCmakeCandidate { + std::string find_package; // e.g. "fmt CONFIG REQUIRED" + std::vector targets; // e.g. ["fmt::fmt"] + std::filesystem::path config_file; // the *Config.cmake we scraped +}; + +// Pure: scan a single CMake config text for `add_library(... IMPORTED)` +// target names. ALIAS targets are also collected so canonical +// `::` forms get picked up. +auto scan_imported_targets(std::string_view config_text) -> std::vector; + +// Pure: turn a CMake config filename into the find_package name. +// `fmtConfig.cmake` / `fmt-config.cmake` -> `fmt`. +auto config_stem_to_package(std::string_view filename) -> std::string; + +// Walks /lib/cmake/** for *Config.cmake / *-config.cmake files. +// Picks the candidate whose derived package name best matches `package_name` +// (exact case-insensitive equality > prefix > first non-empty target list). +// Returns ResolutionUnknownPackage when nothing usable is found. +auto nix_cmake_scan(const std::filesystem::path& store_path, + const std::string& package_name) + -> util::Result; + } // namespace cargoxx::resolver diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fd37f7f..55bf98d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -26,3 +26,5 @@ cargoxx_add_test(cmd_add) cargoxx_add_test(cmd_remove) cargoxx_add_test(nixpkgs_probe_parse) cargoxx_add_test(nixpkgs_probe_live) +cargoxx_add_test(nix_cmake_scan_parse) +cargoxx_add_test(nix_cmake_scan_live) diff --git a/tests/nix_cmake_scan_live.cpp b/tests/nix_cmake_scan_live.cpp new file mode 100644 index 0000000..9e0d8bc --- /dev/null +++ b/tests/nix_cmake_scan_live.cpp @@ -0,0 +1,57 @@ +// nix-store-gated integration test for resolver::nix_cmake_scan. +// Realizes nixpkgs#fmt.dev (cheap if already cached) and verifies that +// nix_cmake_scan picks up its real *-targets.cmake IMPORTED targets. + +#include + +import cargoxx.resolver; +import cargoxx.exec; +import cargoxx.util; +import std; + +namespace { + +auto network_tests_enabled() -> bool { + auto* env = std::getenv("CARGOXX_NETWORK_TESTS"); + return env != nullptr && std::string_view{env} == "1"; +} + +auto realize(std::string_view attr) -> std::optional { + auto r = cargoxx::exec::run( + "nix", + {"--extra-experimental-features", "nix-command flakes", + "build", std::format("nixpkgs#{}", attr), + "--no-link", "--print-out-paths"}, + cargoxx::exec::ExecOptions{ + .cwd = {}, + .env_overrides = {}, + .timeout = std::chrono::seconds{180}, + .inherit_stdio = false, + }); + if (!r || r->exit_code != 0) { + return std::nullopt; + } + auto path = r->stdout_text; + while (!path.empty() && (path.back() == '\n' || path.back() == ' ')) { + path.pop_back(); + } + return std::filesystem::path{path}; +} + +} // namespace + +TEST_CASE("nix_cmake_scan finds fmt's IMPORTED targets", + "[resolver][network]") { + if (!network_tests_enabled()) { + SKIP("CARGOXX_NETWORK_TESTS != 1"); + } + auto p = realize("fmt.dev"); + REQUIRE(p.has_value()); + + auto r = cargoxx::resolver::nix_cmake_scan(*p, "fmt"); + REQUIRE(r.has_value()); + REQUIRE(r->find_package == "fmt CONFIG REQUIRED"); + // fmt-targets.cmake declares fmt::fmt + fmt::fmt-header-only. + REQUIRE(std::ranges::find(r->targets, std::string{"fmt::fmt"}) != + r->targets.end()); +} diff --git a/tests/nix_cmake_scan_parse.cpp b/tests/nix_cmake_scan_parse.cpp new file mode 100644 index 0000000..7fddd32 --- /dev/null +++ b/tests/nix_cmake_scan_parse.cpp @@ -0,0 +1,159 @@ +#include + +import cargoxx.resolver; +import cargoxx.util; +import std; + +using cargoxx::resolver::config_stem_to_package; +using cargoxx::resolver::nix_cmake_scan; +using cargoxx::resolver::scan_imported_targets; +using cargoxx::util::ErrorCode; + +TEST_CASE("config_stem_to_package strips Config / -config / .cmake", + "[resolver][nix_cmake_scan]") { + REQUIRE(config_stem_to_package("fmtConfig.cmake") == "fmt"); + REQUIRE(config_stem_to_package("fmt-config.cmake") == "fmt"); + REQUIRE(config_stem_to_package("Catch2Config.cmake") == "Catch2"); + REQUIRE(config_stem_to_package("range-v3-config.cmake") == "range-v3"); + REQUIRE(config_stem_to_package("/abs/path/sub/spdlogConfig.cmake") == "spdlog"); +} + +TEST_CASE("scan_imported_targets picks up IMPORTED libraries", + "[resolver][nix_cmake_scan]") { + constexpr std::string_view text = R"( + # noise here + add_library(fmt::fmt SHARED IMPORTED) + set_target_properties(fmt::fmt PROPERTIES IMPORTED_LOCATION "/x") + )"; + auto out = scan_imported_targets(text); + REQUIRE(out == std::vector{"fmt::fmt"}); +} + +TEST_CASE("scan_imported_targets picks up ALIAS targets", + "[resolver][nix_cmake_scan]") { + constexpr std::string_view text = R"( + add_library(absl_strings_internal STATIC IMPORTED) + add_library(absl::strings ALIAS absl_strings_internal) + )"; + auto out = scan_imported_targets(text); + // Both the imported impl and the alias should be reported, deduped, + // in declaration order. + REQUIRE(out.size() == 2); + REQUIRE(out[0] == "absl_strings_internal"); + REQUIRE(out[1] == "absl::strings"); +} + +TEST_CASE("scan_imported_targets ignores plain (non-IMPORTED) add_library", + "[resolver][nix_cmake_scan]") { + constexpr std::string_view text = R"( + add_library(local STATIC src.cpp) + add_library(other_lib SHARED other.cpp) + )"; + auto out = scan_imported_targets(text); + REQUIRE(out.empty()); +} + +TEST_CASE("scan_imported_targets does not match _add_library or similar", + "[resolver][nix_cmake_scan]") { + constexpr std::string_view text = R"( + # function definition uses the marker as a substring + function(_add_library_helper) + endfunction() + )"; + auto out = scan_imported_targets(text); + REQUIRE(out.empty()); +} + +TEST_CASE("scan_imported_targets dedupes duplicate target names", + "[resolver][nix_cmake_scan]") { + constexpr std::string_view text = R"( + add_library(fmt::fmt SHARED IMPORTED) + # benign duplicate (e.g. config-version reuse) + add_library(fmt::fmt SHARED IMPORTED) + )"; + auto out = scan_imported_targets(text); + REQUIRE(out == std::vector{"fmt::fmt"}); +} + +namespace { + +auto fresh_store() -> std::filesystem::path { + auto d = std::filesystem::temp_directory_path() / + std::format("cargoxx-cmake-scan-{}", std::random_device{}()); + std::filesystem::create_directories(d / "lib" / "cmake"); + return d; +} + +void touch_config(const std::filesystem::path& store, std::string_view rel, + std::string_view content) { + auto p = store / "lib" / "cmake" / rel; + std::filesystem::create_directories(p.parent_path()); + std::ofstream{p} << content; +} + +} // namespace + +TEST_CASE("nix_cmake_scan finds the canonical config", "[resolver][nix_cmake_scan]") { + auto store = fresh_store(); + touch_config(store, "fmt/fmtConfig.cmake", + R"(add_library(fmt::fmt SHARED IMPORTED))"); + + auto r = nix_cmake_scan(store, "fmt"); + REQUIRE(r.has_value()); + REQUIRE(r->find_package == "fmt CONFIG REQUIRED"); + REQUIRE(r->targets == std::vector{"fmt::fmt"}); + REQUIRE(r->config_file.filename() == "fmtConfig.cmake"); +} + +TEST_CASE("nix_cmake_scan prefers exact case-insensitive match", + "[resolver][nix_cmake_scan]") { + auto store = fresh_store(); + touch_config(store, "absl/abslConfig.cmake", + R"(add_library(absl::strings SHARED IMPORTED))"); + touch_config(store, "absl-extras/abslExtrasConfig.cmake", + R"(add_library(absl::extras SHARED IMPORTED))"); + + auto r = nix_cmake_scan(store, "absl"); + REQUIRE(r.has_value()); + REQUIRE(r->find_package == "absl CONFIG REQUIRED"); +} + +TEST_CASE("nix_cmake_scan returns ResolutionUnknownPackage when nothing found", + "[resolver][nix_cmake_scan]") { + auto store = fresh_store(); + auto r = nix_cmake_scan(store, "nothing"); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage); +} + +TEST_CASE("nix_cmake_scan ignores Config files with no IMPORTED targets", + "[resolver][nix_cmake_scan]") { + auto store = fresh_store(); + touch_config(store, "junk/junkConfig.cmake", + R"(message(STATUS "no targets here"))"); + auto r = nix_cmake_scan(store, "junk"); + REQUIRE_FALSE(r.has_value()); + REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage); +} + +TEST_CASE("nix_cmake_scan scans sibling *-targets.cmake files", + "[resolver][nix_cmake_scan]") { + // Mirrors fmt's real on-disk layout: the *-config.cmake is empty of + // IMPORTED targets but include()s a sibling *-targets.cmake that + // carries them. + auto store = fresh_store(); + touch_config(store, "fmt/fmt-config.cmake", + R"(include("${CMAKE_CURRENT_LIST_DIR}/fmt-targets.cmake"))"); + touch_config(store, "fmt/fmt-targets.cmake", + R"(add_library(fmt::fmt SHARED IMPORTED) + add_library(fmt::fmt-header-only INTERFACE IMPORTED))"); + + auto r = nix_cmake_scan(store, "fmt"); + REQUIRE(r.has_value()); + REQUIRE(r->find_package == "fmt CONFIG REQUIRED"); + REQUIRE(r->targets.size() == 2); + REQUIRE(std::ranges::find(r->targets, std::string{"fmt::fmt"}) != + r->targets.end()); + REQUIRE(std::ranges::find(r->targets, std::string{"fmt::fmt-header-only"}) != + r->targets.end()); +}