Compare commits

...

25 Commits

Author SHA1 Message Date
7bb39a64c1 [M8] cargoxx-pkgs as a flake: cargoxx add → string-form dep
Wrapper: fix cache.nixos.org-1 key; drop AppImage; pin publish to mozart/cargoxx-pkgs.
2026-05-18 23:13:14 +00:00
9d33379f94 [M8] wrapper: substituters + trusted-public-keys for cargoxx-pkgs cache 2026-05-18 20:27:50 +00:00
f9932a3ad9 [M8] cargoxx-bin installPhase: copy from build/release/bin/ (Phase 1a relocation) 2026-05-18 18:54:41 +00:00
3138e78f47 [M8] cargoxx publish: open a recipe PR via tea / Gitea API 2026-05-18 18:17:50 +00:00
09f151ad82 [M8] cargoxx-git dependencies: { git = ..., rev = ... } deps 2026-05-17 18:31:13 +00:00
e6c39914b3 [M8] reusable libraries: install layout + cargoxx-path deps 2026-05-17 18:13:15 +00:00
fdf97861a4 [M7] flake: distribution bundles + non-NixOS wrapper 2026-05-17 12:29:30 +00:00
1f63984b60 [M7] buildCppPackage: hermetic single-derivation, sandbox-safe
Resolve dep store paths and synthesize vendor.toml at outer eval time.
Add tests/e2e/buildCppPackage smoke fixture with a run.sh
Update CHANGELOG.md with the M7 changes.
2026-05-16 01:13:38 +00:00
85417f317c [M7] cargoxx vendor + build --offline + path: store-path codegen 2026-05-16 00:27:45 +00:00
43a7d1f09d [M7] lockfile: pin top-level nixpkgs_rev + flake_utils_rev 2026-05-16 00:20:11 +00:00
7c10ea2382 [M7] flake: lib.buildCppPackage + packages.default 2026-05-15 23:11:06 +00:00
f62cff49c6 [M7] lockfile carries full recipe (find_package, targets, pkg_config_module, brute_force_*) 2026-05-15 23:05:40 +00:00
815e5b1be2 [M7] generated flake.nix moves to build/flake.nix 2026-05-15 22:57:05 +00:00
db1c9eb36d [M6] codegen: add -Wno-missing-field-initializers to baseline warnings 2026-05-15 16:16:03 +00:00
f90bcfbff7 [M6] README: reflect M6 state (self-hosting + 6-stage resolver chain) 2026-05-15 14:48:42 +00:00
65a749f088 [M6] tests: levenshtein + pc_scan + brute_scan + findmodule_scan + last_failure_dir + cmd_linkdb_add + codegen PkgConfig/brute-force 2026-05-15 14:41:17 +00:00
94e658fdf1 [M6] brute-force probe + INTERFACE IMPORTED target codegen 2026-05-15 14:26:06 +00:00
01b3c28d6c [M6] Conan/vcpkg fuzzy match (Levenshtein); cmake -P CMAKE_ROOT lookup 2026-05-15 14:08:49 +00:00
8bbfcf7657 [M6] resolver: CMake builtin FindModule scan probe 2026-05-15 13:56:17 +00:00
8b396bcd0f [M6] preserve every probe's scratch under last-failure/<pkg>/ 2026-05-15 13:43:16 +00:00
c46f3aa1f0 [M6] Phase 2 self-hosting: cargoxx builds itself from Cargoxx.toml 2026-05-15 13:14:28 +00:00
73aebf183e [M6] Cargoxx.toml: use nixpkgs name 'sqlite' (was 'sqlite3') 2026-05-15 13:11:36 +00:00
9b6014b82d [M6] cmd_build auto-resolves missing deps via resolver chain 2026-05-15 13:08:39 +00:00
6c4933f282 [M6] resolver: pkg-config probe + codegen for PkgConfig 2026-05-15 13:03:03 +00:00
4f9b6f1827 [M6] populate Cargoxx.toml; add 'cargoxx linkdb add' CLI; codegen fixes for self-host 2026-05-14 00:17:56 +00:00
71 changed files with 5888 additions and 354 deletions

10
.gitignore vendored
View File

@@ -1,7 +1,15 @@
# Build outputs # Build outputs
/build/ /build/*
!/build/CMakeLists.txt
/result /result
/result-* /result-*
/cargoxx.AppImage
/cargoxx-arx
# e2e fixtures regenerate these on every run.sh invocation.
tests/e2e/*/build/
tests/e2e/**/flake.lock
tests/e2e/**/Cargoxx.lock
# CMake # CMake
CMakeCache.txt CMakeCache.txt

View File

@@ -341,3 +341,225 @@ All notable changes to cargoxx will be documented in this file.
window. `tests/linkdb_overlay.cpp` covers 7 cases (insert/persist, window. `tests/linkdb_overlay.cpp` covers 7 cases (insert/persist,
override-curated, version-range gating, components rejection, override-curated, version-range gating, components rejection,
move semantics). move semantics).
- M7 generated flake.nix moves to `build/flake.nix`. The project root
belongs to the user — any hand-written `flake.nix` there is never
overwritten by cargoxx. `cargoxx build` always invokes `nix develop`
against `path:./build`.
- M7 lockfile pins top-level `nixpkgs_rev` and `flake_utils_rev`. The
generated flake's `inputs.nixpkgs.url` / `inputs.flake-utils.url` now
use the pinned revs (falling back to the branch tips during the
transitional first build before the lock is written). Per-package
schema gains the full recipe (`find_package`, `targets`,
`pkg_config_module`, `brute_force_libs`, `brute_force_includes`,
`linkdb_source`) so the lockfile is a complete dependency-pinning
artifact and `cmd_build`'s `recipe_from_lock` can short-circuit the
linkdb entirely. `tests/lockfile_round_trip.cpp` extended.
- M7 `codegen::VendorIndex` + `parse_vendor_toml` — new pure parser
(`src/codegen/vendor.cpp`) returns a struct of
`nixpkgs_store_path`, `flake_utils_store_path`, and a per-dep
`nixpkgs_attr → store_path` map. `GenerateInputs` gains an optional
`vendor` field; when set, `emit_inputs_block` emits `path:` inputs
and drops the per-dep `github:` pins.
- M7 new helpers in `cargoxx.resolver`:
`realize_path_at_rev(rev, attr)` realizes
`github:NixOS/nixpkgs/<rev>#<attr>` to a `/nix/store/...` path
(used by `cmd_vendor`); `realize_flake_source(flake_ref)` returns
the source store path via `nix flake prefetch --json` (used to pin
`nixpkgs` and `flake-utils` for offline mode).
- M7 `cargoxx vendor [--output <path>]` — new CLI verb. Reads
`Cargoxx.lock`, realizes each locked dep at its pinned
`(nixpkgs_rev, nixpkgs_attr)` into `/nix/store`, and writes
`vendor.toml` (schema = 1) recording the resolved store paths for
every dep plus the `nixpkgs` and `flake-utils` flake sources. The
output is the input to `cargoxx build --offline`.
- M7 `cargoxx build --offline [--vendor <path>]` — skips every network
probe (Conan/vcpkg fuzzy, devbox, nixpkgs_git, linkdb auto-resolve),
reads `vendor.toml` (default `./vendor.toml`), and emits
`build/flake.nix` with literal `path:/nix/store/...` inputs for
`nixpkgs`, `flake-utils`, and every dep. Offline mode also runs cmake
directly (no outer `nix develop` wrapper) since all paths are already
realized in the local store.
- M7 `cargoxx.lib.buildCppPackage` — hermetic, sandbox-safe nix builder
for downstream flakes. Mirrors `rustPlatform.buildRustPackage`'s
ergonomics: a consumer flake passes `src` and gets a derivation. Reads
`Cargoxx.lock` at outer eval time, resolves each dep's
`(nixpkgs_rev, nixpkgs_attr)` via `builtins.getFlake` into concrete
`/nix/store/...` paths, and synthesizes a `vendor.toml` via
`pkgs.writeText` — no network or nested `nix` invocations inside any
build phase. The single derivation runs `cargoxx build --release
--offline --vendor <store-path>/vendor.toml`, which emits a hermetic
`build/flake.nix` with literal `path:/nix/store/...` inputs and drives
cmake directly. Works under the host's default sandbox (sandbox=true,
non-trusted user, no `__noChroot`, no daemon escape). New e2e fixture
at `tests/e2e/buildCppPackage/` with a `run.sh` smoke test that
scaffolds the fixture in a tmp dir and runs `nix build .#default`
end-to-end. Live verified: `Hello from world!` from a binary built
entirely inside the standard nix sandbox.
- Fix: `-Wparentheses` warning in `looks_like_missing_attribute`
(`src/resolver/nixpkgs_probe.cpp:34`) — explicitly parenthesize the
`&&` clause inside `||`.
- M7 `cargoxx-bin` wraps the binary with `makeWrapper` to lock its
runtime CLI dependencies — `nix`, `cmake`, `ninja`, `curl`, `git`
— onto `PATH`. Previously cargoxx silently relied on the user's
ambient PATH, so it broke whenever invoked outside a `nix develop`
shell. The wrapped binary works under `env -i` with a minimal PATH.
- M7 the wrapper also `--set-default`s `NIX_CONFIG` to
`experimental-features = nix-command flakes` and
`build-users-group =`. Without this, the bundled `pkgs.nix`
defaults to the multi-user daemon model and fails on non-NixOS
hosts (`error: the group 'nixbld' specified in 'build-users-group'
does not exist`). `--set-default` lets a user with a properly-
configured nix daemon override by exporting `NIX_CONFIG=...`
before invoking cargoxx. Verified end-to-end in `archlinux:latest`
via `docker run`: install the `.pkg.tar.zst`, `cargoxx new demo &&
cargoxx add fmt` runs the resolver chain through verify-link
cmake/ninja and writes the dep without error.
- M7 packaging functions exposed as a stable `to*` library and as
ready-to-build flake packages, mirroring the
`github:NixOS/bundlers` naming. Available as both
`packages.<format>` (for `nix build .#<format>`) and
`lib.to<Format>` (for downstream flakes to package their own
derivations):
- `toAppImage` / `packages.appimage` — Linux AppImage (~207 MB)
- `toDockerImage` / `packages.dockerImage``docker load`-able
tar.gz (~213 MB)
- `toDEB` / `packages.deb` — Debian `.deb` (~211 MB)
- `toRPM` / `packages.rpm` — Red Hat `.rpm` (~212 MB)
- `toArchPkg` / `packages.archpkg` — Arch `.pkg.tar.zst`
(~196 MB), implemented locally because no NixOS bundler exists.
Builds a closure via `pkgs.closureInfo`, lays it under
`/nix/store`, drops a `/usr/bin/<mainProgram>` symlink, writes
`.PKGINFO` from `drv.pname`/`drv.version`/`drv.meta`, and packs
with `bsdtar --zstd`. Generic — works for any derivation with
a `bin/<mainProgram>` and the usual `pname`/`version` attrs.
Added `inputs.bundlers.url = "github:NixOS/bundlers"` (with
`inputs.nixpkgs.follows = "nixpkgs"`) to keep the closure aligned
with the project's pinned nixpkgs.
- M8 multiple binary build artifacts (Cargo `src/bin/` parity).
Codegen now routes every executable (`add_executable`) to
`${CMAKE_BINARY_DIR}/bin` via `set_target_properties(... RUNTIME_OUTPUT_DIRECTORY ...)`.
`buildCppPackage` and `cargoxx run --bin <name>` follow suit, and
layout discovery picks up Cargo's `src/bin/<sub>/main.cpp` form
(subdir name becomes the binary name). End-to-end fixture
`tests/e2e/buildCppPackage/src/bin/extra.cpp` proves a second
binary lands in `$out/bin/` alongside the primary.
- M8 reusable library install layout. `cargoxx`-built libraries now
ship a CMake-idiomatic `$out` tree: `lib/lib<name>.a`,
`lib/cmake/<name>/{<name>Config.cmake,<name>ConfigVersion.cmake,
<name>Targets.cmake}`, `lib/pkgconfig/<name>.pc`, and modules
under `include/<name>/` (via `install(TARGETS ... FILE_SET CXX_MODULES ...)`).
Codegen emits the install rules + a `Config.cmake.in` template
(inline `file(WRITE …)`) consumed by
`configure_package_config_file` and `write_basic_package_version_file`,
plus a basic `.pc.in` template. The exported library target
carries `target_compile_features(... PUBLIC cxx_std_<NN>)` so
consumers `find_package`-ing it get the right standard for
module BMI regeneration.
`buildCppPackage.installPhase` switched from `cp -a build/release/bin/.`
to `cmake --install build/release --prefix $out` — bins, libs,
headers, config, and pc files all land via one invocation.
`project(... VERSION <pkg.version> ...)` is now part of the
generated header so `<name>ConfigVersion.cmake` reflects the
manifest's version.
- M8 cargoxx-path dependencies (Cargo's `{ path = "..." }`). The
manifest gains a discriminated dep table form:
```toml
[dependencies]
greeter = { path = "./greeter" }
```
`manifest::Dependency` carries `DepSource source` +
`optional<string> path`. The parser branches on `path`; unknown
dep-table keys still rejected for tables that have neither
`version` nor `path`. Round-tripped by the writer.
Lockfile schema adds per-package `source_kind` + `source_path`
so the consumer's `Cargoxx.lock` records "this dep is built from
a cargoxx source tree at `./greeter`" (not nixpkgs).
`cmd_build::resolve_path_dep` reads `<path>/Cargoxx.toml` to
verify the dep's name matches, then synthesizes a Recipe
(`find_package = "<name> CONFIG REQUIRED"`,
`targets = ["<name>::<name>"]`, `source = "cargoxx-path"`).
Codegen needs no special case — the synthesized recipe flows
through the existing `find_package` emission path.
`buildCppPackage`'s recursion: `evalDep` branches on
`source_kind == "cargoxx-path"` and recurses with
`src = src + "/" + source_path`. The resulting derivation joins
`buildInputs`; CMake's `CMAKE_PREFIX_PATH` picks it up so the
consumer's `find_package(<dep> CONFIG REQUIRED)` resolves
against the producer's installed Config.cmake. Path deps must be
subdirectories of the consumer's source tree (no sibling form in
v1 — sibling deps will land with git/registry sources in 1c/1d).
New e2e fixture `tests/e2e/pathDep/`: a `consumer` project with
`[dependencies] greeter = { path = "./greeter" }`. `run.sh`
generates locks in both, then `nix build .#default` produces a
binary that prints "Hello from greeter, world!".
- M8 design doc at `docs/library-reuse-and-publish.md` covers the
full two-part roadmap: library reuse (path → git → registry deps)
and Gitea-hosted public registry with `cargoxx publish` + bot
validation + binary-cache substitution. Phase 1a (install rules)
and Phase 1b (path deps) shipped in this commit; phases 1c, 1d,
and 2 remain to be built.
- M8 cargoxx-git dependencies (`{ git = "<url>", rev = "<40-char>" }`).
The manifest accepts a third dep-table form:
```toml
[dependencies]
mylib = { git = "https://gitea/me/mylib", rev = "abc…" }
```
Branch/tag refs are not yet supported — the rev must be a literal
40-char commit. `manifest::Dependency` carries `git_url` + `git_rev`;
parser branches on `git`, rejects git deps that omit `rev`.
Lockfile schema adds `source_git_url` + `source_git_commit` +
`source_git_sha256` (SRI form, e.g. `sha256-<base64>`). The sha256
pins the fetched source as a fixed-output derivation: `pkgs.fetchgit`
in the consumer's `buildCppPackage` substitutes from cache when the
same `(url, rev, sha256)` triple appears elsewhere.
`cmd_build::resolve_git_dep` reuses the lockfile's prior sha256 on
re-builds; on a fresh dep it calls
`resolver::prefetch_flake_source(git+<url>?rev=<rev>)` which now
returns `{store_path, hash}` (extended via a new
`PrefetchedSource` struct, with `realize_flake_source` kept as a
thin compat wrapper). Verifies the dep's name by reading the
fetched tree's `Cargoxx.toml`. In `--offline` mode, a git dep
without a cached hash errors with a clear "run online first"
message.
`flake.nix`'s `evalDep` branches on `source_kind == "cargoxx-git"`
and feeds `pkgs.fetchgit { url, rev, hash }` into a recursive
`buildCppPackage` call. The fetched source is content-addressed,
so the entire `(fetch → install)` closure is cacheable end-to-end.
Codegen unchanged — the synthesized recipe (find_package +
`<name>::<name>` target) is identical to the path-dep case, just
with a different `linkdb_source` discriminator.
- M8 `cargoxx publish` (Phase 2c). Publishes the project's HEAD as a
new version recipe in a Gitea-hosted cargoxx registry. Steps:
1. Validate `Cargoxx.toml` has `name`, `version`, `license`.
2. Verify the working tree is clean and `Cargoxx.lock` exists.
3. Reject path-dep deps (registry can't reference local trees).
4. Read `git remote get-url origin` + `git rev-parse HEAD`,
normalize SSH URLs to https.
5. Compute the source SRI hash via `nix flake prefetch
git+<url>?rev=<commit> --json`.
6. Build the recipe TOML (mirrors `Cargoxx.toml`'s deps + `[meta]`).
7. POST `/repos/<registry>/contents` with `{branch: "master",
new_branch: "publish/<name>-<version>", files: [...]}` — a
single atomic commit creating both `recipes/<name>/versions/
<v>.toml` and (for new packages) `recipes/<name>/maintainers.txt`
pre-populated with the publisher's Gitea username (`tea api
/user`).
8. POST `/repos/<registry>/pulls` to open the PR.
`--dry-run` prints the recipe TOML and exits without any network
ops. `--registry <owner>/<repo>` overrides the default
(`$CARGOXX_REGISTRY`, falling back to `mozart/cargoxx-pkgs`).
Authentication comes from `tea login` — no separate token plumbing.
Manifest schema gains three optional `[package]` fields used in the
recipe's `[meta]` block: `description`, `repository`, `homepage`.
The cargoxx wrapper (`flake.nix`'s `cargoxxRuntimePath`) now bundles
`pkgs.tea` so the binary can shell out to it on non-Nix hosts.
Verified end-to-end against
`https://git.amadey.xyz/mozart/cargoxx-pkgs`: from a
`mozart/greeter` checkout, `cargoxx publish` opens PR #3 with
branch `publish/greeter-0.1.0` containing a fresh
`recipes/greeter/versions/0.1.0.toml` and a new
`recipes/greeter/maintainers.txt`.

View File

@@ -1,94 +0,0 @@
cmake_minimum_required(VERSION 3.30)
# Opt into experimental C++ modules dyndep + `import std;` support.
# Required until CMake declares these stable; see CMake docs for the current UUID.
set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
set(CMAKE_CXX_MODULE_STD ON)
project(cargoxx LANGUAGES CXX VERSION 0.1.0)
# Phase 0: hand-written CMakeLists.txt. Replaced by generated build/CMakeLists.txt
# at milestone M3 once cargoxx can build itself. See TECH_SPEC.md §15.
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS ON)
set(CMAKE_CXX_SCAN_FOR_MODULES ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE)
endif()
# TECH_SPEC.md §17: -Wall -Wextra -Wpedantic -Wconversion, -Werror in CI.
add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)
option(CARGOXX_WERROR "Treat warnings as errors" OFF)
if(CARGOXX_WERROR)
add_compile_options(-Werror)
endif()
find_package(SQLite3 REQUIRED)
find_package(reproc REQUIRED)
# ----- cargoxx library: module units + implementation units -----
add_library(cargoxx STATIC)
target_include_directories(cargoxx SYSTEM PRIVATE third_party)
target_sources(cargoxx
PRIVATE
src/util/error.cpp
src/util/semver.cpp
src/manifest/parser.cpp
src/manifest/writer.cpp
src/layout/layout.cpp
src/lockfile/lockfile.cpp
src/linkdb/database.cpp
src/linkdb/overlay.cpp
src/codegen/flake.cpp
src/codegen/cmake.cpp
src/exec/subprocess.cpp
src/resolver/nixpkgs_probe.cpp
src/resolver/nix_cmake_scan.cpp
src/resolver/conan_probe.cpp
src/resolver/vcpkg_probe.cpp
src/resolver/verify_link.cpp
src/resolver/discover.cpp
src/resolver/search_devbox.cpp
src/resolver/nixpkgs_git.cpp
src/resolver/version_resolve.cpp
src/cli/cmd_new.cpp
src/cli/cmd_build.cpp
src/cli/cmd_run.cpp
src/cli/cmd_test.cpp
src/cli/cmd_clean.cpp
src/cli/cmd_add.cpp
src/cli/cmd_remove.cpp
src/cli/run.cpp
PUBLIC
FILE_SET CXX_MODULES FILES
src/lib.cppm
src/util/util.cppm
src/exec/exec.cppm
src/manifest/manifest.cppm
src/lockfile/lockfile.cppm
src/layout/layout.cppm
src/linkdb/linkdb.cppm
src/resolver/resolver.cppm
src/codegen/codegen.cppm
src/cli/cli.cppm
)
target_link_libraries(cargoxx PRIVATE SQLite::SQLite3 reproc)
# ----- cargoxx binary -----
add_executable(cargoxx_bin src/main.cpp)
set_target_properties(cargoxx_bin PROPERTIES OUTPUT_NAME cargoxx)
target_link_libraries(cargoxx_bin PRIVATE cargoxx)
# ----- tests -----
option(CARGOXX_BUILD_TESTS "Build cargoxx tests" ON)
if(CARGOXX_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()

32
Cargoxx.lock Normal file
View File

@@ -0,0 +1,32 @@
flake_utils_rev = '11707dc2f618dd54ca8739b309ec4fc024de578b'
nixpkgs_rev = 'da5ad661ba4e5ef59ba743f0d112cbc30e474f32'
version = 1
[[package]]
dependencies = [ 'reproc *', 'sqlite *', 'catch2_3 *' ]
name = 'cargoxx'
version = '0.1.0'
[[package]]
find_package = 'reproc CONFIG REQUIRED'
linkdb_source = 'nix-probe'
name = 'reproc'
nixpkgs_attr = 'reproc'
targets = [ 'reproc' ]
version = '*'
[[package]]
find_package = 'SQLite3 REQUIRED'
linkdb_source = 'cmake-findmodule'
name = 'sqlite'
nixpkgs_attr = 'sqlite'
targets = [ 'SQLite::SQLite3' ]
version = '*'
[[package]]
find_package = 'Catch2 CONFIG REQUIRED'
linkdb_source = 'nix-probe'
name = 'catch2_3'
nixpkgs_attr = 'catch2_3'
targets = [ 'Catch2::Catch2', 'Catch2::Catch2WithMain' ]
version = '*'

View File

@@ -5,11 +5,12 @@ edition = "cpp23"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
sqlite3 = "*" sqlite = "*"
reproc = "*" reproc = "*"
[dev-dependencies] [dev-dependencies]
catch2 = "*" catch2_3 = "*"
[build] [build]
warnings_as_errors = false warnings_as_errors = false
include_dirs = ["third_party"]

101
README.md
View File

@@ -1,23 +1,104 @@
# cargoxx # cargoxx
A Cargo-style frontend for modern C++ that uses Nix as the source of truth for A Cargo-style frontend for modern C++ that uses Nix as the dependency
dependencies and generates CMake for the build. Users author projects with C++23 source of truth and generates CMake for the build. Users author projects
modules and never touch CMake or `flake.nix` by hand. with C++23 modules and never touch CMake or `flake.nix` by hand.
> Status: pre-v0.1, milestone M0 (repo skeleton). Not yet usable. > Status: M6. Self-hosting (cargoxx builds itself from `Cargoxx.toml`).
> Full auto-resolver chain (Conan / vcpkg / nix-config / CMake FindModule /
> pkg-config / brute-force fallback). Pre-v0.1; CLI is stable but the
> on-disk linkdb schema is not.
See [`SPEC.md`](./SPEC.md) for the user-facing contract and See [`SPEC.md`](./SPEC.md) for the user-facing contract and
[`TECH_SPEC.md`](./TECH_SPEC.md) for the implementation plan. [`TECH_SPEC.md`](./TECH_SPEC.md) for the implementation.
## What it does
```sh
cargoxx new myapp # scaffold Cargoxx.toml + src/main.cpp
cd myapp
cargoxx add fmt # resolve fmt → write recipe to ~/.cache/cargoxx/linkdb.sqlite,
# pin nixpkgs rev into Cargoxx.lock
cargoxx build # generate build/CMakeLists.txt + flake.nix,
# then `nix develop -c cmake --build`
cargoxx run # build + exec
cargoxx test # build + ctest
```
The user never touches `build/CMakeLists.txt` or `flake.nix`. Both are
regenerated from `Cargoxx.toml` on every build.
## How dependency resolution works
`cargoxx add <pkg>` (and `cargoxx build` on a missing dep) walks a probe
chain. The first probe whose verify-link build succeeds wins; its recipe
is confirmed in the SQLite overlay.
| # | Probe | Source |
|---|---|---|
| 1 | conan-center-index | exact + Levenshtein fuzzy ≤ ⌈len/4⌉ |
| 2 | microsoft/vcpkg ports | exact + Levenshtein fuzzy |
| 3 | nix-probe | `<X>Config.cmake` under `pkgs.<pkg>.dev` |
| 4 | cmake-findmodule | CMake's bundled `Find*.cmake` |
| 5 | pkg-config | `<X>.pc` under `pkgs.<pkg>.dev/lib/pkgconfig` |
| 6 | brute-force | every `lib/lib*.{a,so,dylib}` + `include/` |
Package names match nixpkgs/devbox. Conan/vcpkg fuzzy match is internal
only — the user-typed name is preserved end-to-end in the manifest,
lockfile, and linkdb key.
### Debugging a failed `cargoxx add`
Every probe's verify-link scratch project is preserved under
`~/.cache/cargoxx/last-failure/<pkg>/<NN>-<probe>/`. Re-run cmake inside
any subdir to reproduce, or just inspect the generated `flake.nix` and
`build/CMakeLists.txt`.
```
~/.cache/cargoxx/last-failure/sqlite/
01-vcpkg/ # vcpkg fuzzy candidate, failed
02-cmake-findmodule/ # FindSQLite3 candidate, won
```
## Building from source ## Building from source
cargoxx builds inside a Nix-managed dev shell that pins the toolchain cargoxx is self-hosted, so the bootstrap path requires an existing
(Clang with C++23 modules + `import std;`, CMake ≥ 3.30, Ninja). cargoxx binary (release artifact or one built from a prior commit) plus
a Nix toolchain.
```sh ```sh
nix develop nix develop
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug cargoxx build # regenerate build/CMakeLists.txt + root flake.nix
cmake --build build cmake -S build -B build/debug -G Ninja
cmake --build build/debug
``` ```
The resulting binary is at `build/cargoxx`. The dev shell ships `pkgs.gcc15Stdenv` (g++ 15, libstdc++, with
`import std;` via `bits/std.cc`). cmake/ninja are also injected.
On a clean clone with an empty `~/.cache/cargoxx/linkdb.sqlite`,
`cargoxx build` auto-resolves all three of cargoxx's own deps
(`sqlite`, `reproc`, `catch2_3`) on first invocation.
## Manifest
```toml
[package]
name = "myapp"
version = "0.1.0"
edition = "cpp23"
[dependencies]
fmt = "10.2"
sqlite = "*"
[dev-dependencies]
catch2_3 = "*"
[build]
warnings_as_errors = true
include_dirs = ["third_party"]
sanitizers = ["address"]
```
See [`SPEC.md`](./SPEC.md) §4 for the full schema.

View File

@@ -619,18 +619,17 @@ User-facing errors are formatted via `util::format(Error)` and printed to stderr
## 15. Bootstrap and self-hosting ## 15. Bootstrap and self-hosting
Three phases. **Phase 0 (historical) — hand-written `CMakeLists.txt` and `flake.nix` at the repo root.**
**Phase 0 — hand-written CMake (commits before milestone M3).** **Phase 2 (current, since M6) — fully self-hosted.**
`CMakeLists.txt` and `flake.nix` at the repo root are written by humans. `cargoxx` builds `cargoxx`. `Cargoxx.toml` describes cargoxx's own deps with nixpkgs names: `sqlite`, `reproc`, `catch2_3`. `cargoxx build` runs the auto-resolver chain (nixpkgs probe → realize → nix_cmake_scan → pc_scan), confirms each recipe via verify_link, and generates `build/CMakeLists.txt` and the root `flake.nix`. Both files are committed (tracked) so the build is reproducible without first building cargoxx, and `[build].include_dirs = ["third_party"]` keeps the vendored headers on the include path.
**Phase 1 — generated CMake, hand-written flake.** Bootstrap path:
At milestone M3 (codegen complete), commit a populated `Cargoxx.toml`. Generate `build/CMakeLists.txt` from it. Delete the root `CMakeLists.txt`. The root `flake.nix` stays hand-written because cargoxx doesn't know about its own host-language deps yet. ```
pre-built cargoxx → cargoxx build → next cargoxx
```
**Phase 2 — fully self-hosted.** A clean clone with an empty `~/.cache/cargoxx/linkdb.sqlite` auto-resolves all three deps on first `cargoxx build` (sqlite goes through pkg-config because nixpkgs ships no SQLite3Config.cmake; reproc/catch2_3 go through nix_cmake_scan). For continuity, a known-good cargoxx binary is shipped as a release artifact; from-scratch bootstrap is not in v0.1 scope.
At milestone M5, vendor all third-party headers into `third_party/` and have cargoxx generate the flake too. The bootstrap path becomes: pre-built cargoxx binary → run `cargoxx build` → produce next cargoxx.
For continuity, ship a known-good cargoxx binary as a release artifact. Anyone bootstrapping from source clones the repo, downloads the latest release binary, and runs `./bootstrap-cargoxx build`. If we ever want to bootstrap from absolute zero, `scripts/bootstrap-build.sh` does it with a hand-written CMake invocation.
--- ---

474
build/CMakeLists.txt Normal file
View File

@@ -0,0 +1,474 @@
cmake_minimum_required(VERSION 3.30)
set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
set(CMAKE_CXX_MODULE_STD ON)
project(cargoxx VERSION 0.1.0 LANGUAGES CXX)
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
# Generated by cargoxx — do not edit.
# Source of truth: ../Cargoxx.toml
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS ON)
set(CMAKE_CXX_SCAN_FOR_MODULES ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
add_compile_options(-Wall -Wextra -Wpedantic -Wconversion -Wno-missing-field-initializers)
# ----- dependencies -----
find_package(reproc CONFIG REQUIRED)
find_package(SQLite3 REQUIRED)
find_package(Catch2 CONFIG REQUIRED)
# ----- library target -----
add_library(cargoxx STATIC)
target_sources(cargoxx
PUBLIC
FILE_SET CXX_MODULES BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/.. FILES
../src/cli/cli.cppm
../src/codegen/codegen.cppm
../src/exec/exec.cppm
../src/layout/layout.cppm
../src/lib.cppm
../src/linkdb/linkdb.cppm
../src/lockfile/lockfile.cppm
../src/manifest/manifest.cppm
../src/resolver/resolver.cppm
../src/util/util.cppm
PRIVATE
../src/cli/cmd_add.cpp
../src/cli/cmd_build.cpp
../src/cli/cmd_clean.cpp
../src/cli/cmd_linkdb_add.cpp
../src/cli/cmd_new.cpp
../src/cli/cmd_publish.cpp
../src/cli/cmd_remove.cpp
../src/cli/cmd_run.cpp
../src/cli/cmd_test.cpp
../src/cli/cmd_vendor.cpp
../src/cli/run.cpp
../src/codegen/cmake.cpp
../src/codegen/flake.cpp
../src/codegen/vendor.cpp
../src/exec/subprocess.cpp
../src/layout/layout.cpp
../src/linkdb/database.cpp
../src/linkdb/overlay.cpp
../src/lockfile/lockfile.cpp
../src/manifest/parser.cpp
../src/manifest/writer.cpp
../src/resolver/brute_scan.cpp
../src/resolver/cargoxx_pkgs_probe.cpp
../src/resolver/conan_probe.cpp
../src/resolver/discover.cpp
../src/resolver/findmodule_scan.cpp
../src/resolver/fuzzy_listing.cpp
../src/resolver/nix_cmake_scan.cpp
../src/resolver/nixpkgs_git.cpp
../src/resolver/nixpkgs_probe.cpp
../src/resolver/pc_scan.cpp
../src/resolver/search_devbox.cpp
../src/resolver/vcpkg_probe.cpp
../src/resolver/verify_link.cpp
../src/resolver/version_resolve.cpp
../src/util/error.cpp
../src/util/levenshtein.cpp
../src/util/semver.cpp
)
target_compile_features(cargoxx PUBLIC cxx_std_23)
target_include_directories(cargoxx SYSTEM PRIVATE ../third_party)
target_link_libraries(cargoxx PUBLIC
reproc
SQLite::SQLite3
)
# ----- install + package-config + pkg-config -----
install(TARGETS cargoxx
EXPORT cargoxxTargets
FILE_SET CXX_MODULES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/cargoxx
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(EXPORT cargoxxTargets
FILE cargoxxTargets.cmake
NAMESPACE cargoxx::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx)
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake.in [[
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
include("${CMAKE_CURRENT_LIST_DIR}/cargoxxTargets.cmake")
check_required_components(cargoxx)
]])
configure_package_config_file(
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx)
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx)
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc.in [[
prefix=@CMAKE_INSTALL_PREFIX@
exec_prefix=${prefix}
libdir=${prefix}/${CMAKE_INSTALL_LIBDIR}
includedir=${prefix}/${CMAKE_INSTALL_INCLUDEDIR}
Name: @PROJECT_NAME@
Version: @PROJECT_VERSION@
Description: @PROJECT_NAME@
Cflags: -I${includedir}
Libs: -L${libdir} -l@PROJECT_NAME@
]])
configure_file(${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc.in
${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc @ONLY)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc
DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
# ----- binary target -----
add_executable(cargoxx_bin ../src/main.cpp)
set_target_properties(cargoxx_bin PROPERTIES
OUTPUT_NAME cargoxx
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
target_link_libraries(cargoxx_bin PRIVATE
cargoxx
reproc
SQLite::SQLite3
)
install(TARGETS cargoxx_bin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
# ----- tests -----
enable_testing()
include(Catch)
add_executable(test_brute_scan_parse ../tests/brute_scan_parse.cpp)
target_link_libraries(test_brute_scan_parse PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_brute_scan_parse)
add_executable(test_cmd_add ../tests/cmd_add.cpp)
target_link_libraries(test_cmd_add PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_cmd_add)
add_executable(test_cmd_build ../tests/cmd_build.cpp)
target_link_libraries(test_cmd_build PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_cmd_build)
add_executable(test_cmd_clean ../tests/cmd_clean.cpp)
target_link_libraries(test_cmd_clean PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_cmd_clean)
add_executable(test_cmd_linkdb_add ../tests/cmd_linkdb_add.cpp)
target_link_libraries(test_cmd_linkdb_add PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_cmd_linkdb_add)
add_executable(test_cmd_new ../tests/cmd_new.cpp)
target_link_libraries(test_cmd_new PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_cmd_new)
add_executable(test_cmd_publish_validation ../tests/cmd_publish_validation.cpp)
target_link_libraries(test_cmd_publish_validation PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_cmd_publish_validation)
add_executable(test_cmd_remove ../tests/cmd_remove.cpp)
target_link_libraries(test_cmd_remove PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_cmd_remove)
add_executable(test_cmd_run ../tests/cmd_run.cpp)
target_link_libraries(test_cmd_run PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_cmd_run)
add_executable(test_codegen_cmake ../tests/codegen_cmake.cpp)
target_link_libraries(test_codegen_cmake PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_codegen_cmake)
add_executable(test_codegen_flake ../tests/codegen_flake.cpp)
target_link_libraries(test_codegen_flake PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_codegen_flake)
add_executable(test_conan_probe_live ../tests/conan_probe_live.cpp)
target_link_libraries(test_conan_probe_live PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_conan_probe_live)
add_executable(test_conan_probe_parse ../tests/conan_probe_parse.cpp)
target_link_libraries(test_conan_probe_parse PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_conan_probe_parse)
add_executable(test_devbox_resolve_live ../tests/devbox_resolve_live.cpp)
target_link_libraries(test_devbox_resolve_live PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_devbox_resolve_live)
add_executable(test_devbox_resolve_parse ../tests/devbox_resolve_parse.cpp)
target_link_libraries(test_devbox_resolve_parse PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_devbox_resolve_parse)
add_executable(test_exec_run ../tests/exec_run.cpp)
target_link_libraries(test_exec_run PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_exec_run)
add_executable(test_findmodule_scan_live ../tests/findmodule_scan_live.cpp)
target_link_libraries(test_findmodule_scan_live PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_findmodule_scan_live)
add_executable(test_last_failure_dir ../tests/last_failure_dir.cpp)
target_link_libraries(test_last_failure_dir PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_last_failure_dir)
add_executable(test_layout_discovery ../tests/layout_discovery.cpp)
target_link_libraries(test_layout_discovery PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_layout_discovery)
add_executable(test_levenshtein ../tests/levenshtein.cpp)
target_link_libraries(test_levenshtein PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_levenshtein)
add_executable(test_linkdb_lookup ../tests/linkdb_lookup.cpp)
target_link_libraries(test_linkdb_lookup PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_linkdb_lookup)
add_executable(test_linkdb_overlay ../tests/linkdb_overlay.cpp)
target_link_libraries(test_linkdb_overlay PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_linkdb_overlay)
add_executable(test_lockfile_round_trip ../tests/lockfile_round_trip.cpp)
target_link_libraries(test_lockfile_round_trip PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_lockfile_round_trip)
add_executable(test_manifest_parse ../tests/manifest_parse.cpp)
target_link_libraries(test_manifest_parse PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_manifest_parse)
add_executable(test_manifest_write ../tests/manifest_write.cpp)
target_link_libraries(test_manifest_write PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_manifest_write)
add_executable(test_nix_cmake_scan_live ../tests/nix_cmake_scan_live.cpp)
target_link_libraries(test_nix_cmake_scan_live PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_nix_cmake_scan_live)
add_executable(test_nix_cmake_scan_parse ../tests/nix_cmake_scan_parse.cpp)
target_link_libraries(test_nix_cmake_scan_parse PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_nix_cmake_scan_parse)
add_executable(test_nixpkgs_git_resolve ../tests/nixpkgs_git_resolve.cpp)
target_link_libraries(test_nixpkgs_git_resolve PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_nixpkgs_git_resolve)
add_executable(test_nixpkgs_probe_live ../tests/nixpkgs_probe_live.cpp)
target_link_libraries(test_nixpkgs_probe_live PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_nixpkgs_probe_live)
add_executable(test_nixpkgs_probe_parse ../tests/nixpkgs_probe_parse.cpp)
target_link_libraries(test_nixpkgs_probe_parse PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_nixpkgs_probe_parse)
add_executable(test_pc_scan_parse ../tests/pc_scan_parse.cpp)
target_link_libraries(test_pc_scan_parse PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_pc_scan_parse)
add_executable(test_semver_satisfies ../tests/semver_satisfies.cpp)
target_link_libraries(test_semver_satisfies PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_semver_satisfies)
add_executable(test_util_error ../tests/util_error.cpp)
target_link_libraries(test_util_error PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_util_error)
add_executable(test_vcpkg_probe_live ../tests/vcpkg_probe_live.cpp)
target_link_libraries(test_vcpkg_probe_live PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_vcpkg_probe_live)
add_executable(test_vcpkg_probe_parse ../tests/vcpkg_probe_parse.cpp)
target_link_libraries(test_vcpkg_probe_parse PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_vcpkg_probe_parse)
add_executable(test_verify_link_unit ../tests/verify_link_unit.cpp)
target_link_libraries(test_verify_link_unit PRIVATE
cargoxx
reproc
SQLite::SQLite3
Catch2::Catch2
Catch2::Catch2WithMain
)
catch_discover_tests(test_verify_link_unit)

View File

@@ -0,0 +1,448 @@
# Library reuse + public registry
Design plan for two interlocking features:
1. **Library reuse** — cargoxx-built libraries are consumable by other
cargoxx projects, by plain CMake projects (`find_package`), and by
plain clang invocations (`pkg-config`).
2. **Public registry + publish** — a Gitea-hosted package registry
(`cargoxx-pkgs`), a `cargoxx publish` CLI, and self-hosted CI that
validates PRs, builds, pushes derivations to a binary cache, and
auto-merges.
Phase 1 covers library reuse end-to-end with local path and git deps.
Phase 2 layers the registry on top, with cache-backed substitution so
consumers don't recompile.
---
## Part 1 — reusable libraries
### 1.1 Producer install layout
```
$out/
├── bin/ # binaries (existing path)
├── lib/
│ ├── libmylib.a # archive of impl bits
│ ├── pkgconfig/mylib.pc # pkg-config descriptor
│ └── cmake/mylib/
│ ├── mylibConfig.cmake # entry point for find_package
│ ├── mylibConfigVersion.cmake # semver gate
│ └── mylibTargets.cmake # IMPORTED tgt + CXX_MODULES FILE_SET
└── include/mylib/ # public headers + source-form modules
```
Covers three consumption paths:
- **cargoxx → cargoxx**: consumer's CMakeLists emits
`find_package(mylib CONFIG REQUIRED)` + link against `mylib::mylib`.
`$out` is on `CMAKE_PREFIX_PATH` via `buildInputs`.
- **plain CMake**: same `find_package` call, manual `HINTS` if not on
the default prefix path.
- **plain clang + pkg-config**: works for non-module libraries
(header-only or `.a` with extern API). Module libraries require a
CMake-aware consumer — industry-wide pkg-config has no story for
module BMIs.
### 1.2 Codegen changes (`src/codegen/cmake.cpp`)
When `layout.library` is set, append:
```cmake
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
install(TARGETS mylib
EXPORT mylibTargets
FILE_SET CXX_MODULES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/mylib
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(EXPORT mylibTargets
FILE mylibTargets.cmake
NAMESPACE mylib::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib)
# Config.cmake.in written inline so we don't need an off-tree template file.
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake.in [[
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
@FIND_DEPENDENCY_BLOCK@
include("${CMAKE_CURRENT_LIST_DIR}/mylibTargets.cmake")
check_required_components(mylib)
]])
configure_package_config_file(
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib)
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib)
# pkg-config descriptor — also inline.
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/mylib.pc.in [[
prefix=@CMAKE_INSTALL_PREFIX@
exec_prefix=${prefix}
libdir=${prefix}/lib
includedir=${prefix}/include
Name: @PROJECT_NAME@
Version: @PROJECT_VERSION@
Description: @PROJECT_DESCRIPTION@
Cflags: -I${includedir}
Libs: -L${libdir} -lmylib
]])
configure_file(${CMAKE_CURRENT_BINARY_DIR}/mylib.pc.in
${CMAKE_CURRENT_BINARY_DIR}/mylib.pc @ONLY)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/mylib.pc
DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
```
`@FIND_DEPENDENCY_BLOCK@` is computed by codegen — one `find_dependency(X)`
line per cargoxx-source dep, so the transitive graph reconstitutes when
a consumer does `find_package(mylib …)`.
Binaries also get an install rule so `cmake --install` handles
everything in one shot:
```cmake
install(TARGETS mylib_bin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(TARGETS tool RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
```
### 1.3 `buildCppPackage.installPhase` switches to `cmake --install`
```nix
installPhase = ''
cmake --install build/release --prefix $out
'';
```
Replaces the `cp -a build/release/bin/. $out/bin/` hack — now bins,
library archive, headers, cmake config, pc file all land in the right
place from one install command.
### 1.4 Manifest schema — discriminated dep source
Backward-compat (string form unchanged):
```toml
[dependencies]
sqlite = "*" # nixpkgs / linkdb (today)
fmt = { version = ">=10" } # nixpkgs explicit (today)
mylib = { path = "../mylib" } # cargoxx local path ← NEW
remote = { git = "https://gitea/me/remote", rev = "abc123" } # cargoxx git ← NEW
shared = { version = "1.0", registry = "cargoxx" } # cargoxx registry ← NEW
```
`manifest::Dependency` grows discriminated source fields. Parser rejects
unknown keys in dep tables (today silently ignored).
### 1.5 Lockfile schema additions
For cargoxx-source deps the lockfile carries source provenance and the
recipe shape (`find_package`, `targets`) — codegen consumes the latter
unchanged, so cargoxx deps look like any other linkdb recipe to the
emitted CMakeLists.
Path deps: record relative path.
Git deps: record `(url, commit, sha256)`.
Registry deps: record only `(registry_rev, registry_attr)`; the source
URL + sha256 live in the registry repo, not the consumer's lockfile.
### 1.6 `buildCppPackage` recursion
```nix
buildCargoxxDep = p:
if p.source_kind == "path" then
buildCppPackage { src = src + ("/" + p.path); }
else if p.source_kind == "git" then
let depSrc = pkgs.fetchgit {
inherit (p) url;
rev = p.commit;
hash = p.sha256;
};
in buildCppPackage { src = depSrc; }
else if p.source_kind == "registry" then
# Pull the prebuilt $out from the registry flake — no source fetch,
# no recompile. The registry's `packages.<system>.<attr>` derivation
# is content-addressed and substituted from the binary cache.
let registry = builtins.getFlake
"git+https://gitea/.../cargoxx-pkgs?rev=${lock.registry_rev}";
in registry.packages.${system}.${p.registry_attr}
else throw "unknown source_kind: ${p.source_kind}";
cargoxxDepDrvs = map buildCargoxxDep
(builtins.filter (p: p ? source_kind) lock.package);
```
`cargoxxDepDrvs` join `buildInputs`. nixpkgs's `cmakeConfigurePhase`
automatically prepends every `buildInputs` `$out` to `CMAKE_PREFIX_PATH`,
so consumer `find_package(<dep> CONFIG REQUIRED)` resolves without
extra plumbing.
### 1.7 Resolver flow for cargoxx-source deps
```
manifest dep → cmd_build::resolve_dep()
├─ source kind == Nixpkgs/Auto: existing resolver chain
├─ source kind == CargoxxPath:
│ read ${path}/Cargoxx.toml, take [package].name/version,
│ synthesize Recipe { find_package = "<name> CONFIG REQUIRED",
│ targets = ["<name>::<name>"] }
├─ source kind == CargoxxGit:
│ fetch the repo at rev (resolver cache),
│ same as CargoxxPath against the checkout
└─ source kind == CargoxxRegistry:
fetch the registry's recipe TOML, take name + version,
synthesize the same Recipe shape.
```
The recipe shape — `find_package(<name> CONFIG REQUIRED)` plus
`<name>::<name>` target — matches what the producer's installed
`<name>Config.cmake` defines. Codegen needs no cargoxx-dep special case.
### 1.8 Phasing
| Phase | Deliverable |
|---|---|
| 1a | install rules + Config.cmake + .pc; `cmake --install` in buildCppPackage |
| 1b | `{ path = "..." }` deps end-to-end (manifest parse + lock + recursive buildCppPackage); e2e fixture |
| 1c | `{ git = "...", rev = "..." }` deps via `pkgs.fetchgit` |
| 1d | `{ version = "1.0", registry = "cargoxx" }` deps (gated on Phase 2 being live) |
---
## Part 2 — Gitea-hosted registry
### 2.1 `cargoxx-pkgs` repo layout
```
cargoxx-pkgs/
├── flake.nix
├── recipes/
│ └── <name>/
│ ├── maintainers.txt # gitea usernames, one per line
│ ├── meta.toml # description, homepage, license, repo
│ └── versions/
│ ├── 1.0.0.toml
│ └── 1.0.1.toml
└── .gitea/workflows/
├── validate-pr.yml
└── auto-merge.yml
```
### 2.2 Recipe `versions/<v>.toml`
```toml
schema = 1
name = "mylib"
version = "1.0.0"
[source]
type = "git"
url = "https://gitea/me/mylib"
commit = "abc123…" # 40-char
sha256 = "sha256-…" # FOD pin
[dependencies]
fmt = "10.2"
otherlib = { version = "0.3", registry = "cargoxx" }
[lock]
nixpkgs_rev = "…"
flake_utils_rev = "…"
cargoxx_rev = "…"
[meta]
description = "…"
homepage = "https://…"
license = "MIT"
```
### 2.3 `cargoxx-pkgs` flake (the substitution surface)
```nix
{
description = "cargoxx package registry";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/<rev>";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.cargoxx.url = "git+https://gitea/.../cargoxx?rev=<rev>";
outputs = { self, nixpkgs, flake-utils, cargoxx }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
mkPackage = recipeFile:
let r = builtins.fromTOML (builtins.readFile recipeFile);
in cargoxx.lib.${system}.buildCppPackage {
src = pkgs.fetchgit {
inherit (r.source) url;
rev = r.source.commit;
hash = r.source.sha256;
};
};
# Enumerate recipes/<name>/versions/<v>.toml, build packagesAttrs.
packagesAttrs = ;
in {
packages = packagesAttrs;
});
}
```
CI evaluates this flake, builds the new attr, pushes `$out` to the
binary cache. Consumers later get a cache hit for the SAME
derivation hash via `builtins.getFlake` at the same `registry_rev`.
### 2.4 Publishing flow (`cargoxx publish`)
```
cargoxx publish [--dry-run]
1. Validate Cargoxx.toml has name, version, description, license, repository.
2. Validate working tree clean; current commit on a remote branch.
3. Validate Cargoxx.lock pins every cargoxx-source dep to a registry version
(path = "..." deps disallowed in publish).
4. Compute recipe TOML:
[source].url = git remote URL
[source].commit = HEAD commit
[source].sha256 = sha256 of `git archive HEAD`
[dependencies] = mirror Cargoxx.toml
[lock] = mirror Cargoxx.lock pins
5. Clone cargoxx-pkgs (cached under ~/.cache/cargoxx/registry-clone).
6. Create branch publish/<name>-<version>; new package → also create
recipes/<name>/{maintainers.txt,meta.toml}.
7. Write recipes/<name>/versions/<version>.toml; commit.
8. Push branch; `tea pr create` (tea login is already set up).
```
### 2.5 Validation bot (`.gitea/workflows/validate-pr.yml`)
Runs on every PR via self-hosted runners:
1. **Schema check**: PR touches only `recipes/<name>/**`; recipe TOML
validates.
2. **Source fixity**: re-fetch `[source].url` at `[source].commit`,
recompute sha256, compare.
3. **Build**: `nix build .#packages.x86_64-linux.<name>`.
4. **Cache push**:
```
nix copy --to "file:///srv/cargoxx-cache?secret-key=$KEY" \
.#packages.x86_64-linux.<name>
```
`/srv/cargoxx-cache` served by nginx as the binary cache.
5. **Dependency closure**: every `[dependencies]` entry exists at the
same PR HEAD.
6. **Maintainer match**:
- New package: PR author becomes maintainer (write
`maintainers.txt`).
- Existing package: PR author appears in `maintainers.txt`. Else
label `needs-human-review`.
7. On all-green + maintainer-match: label `auto-merge`.
`auto-merge.yml` triggers on the `auto-merge` label and merges via
the Gitea API.
Maintainer transfer is a separate PR editing `maintainers.txt`. Same
validation: PR author must already be on the list.
### 2.6 Resolving registry deps (`cargoxx add foo`)
```
cargoxx add foo[@<version>]
1. Fetch https://gitea/.../cargoxx-pkgs/raw/branch/master/index.json
(the index, emitted by CI, maps name → list of versions).
2. Pick the highest matching version (or @<version>).
3. Fetch recipes/foo/versions/<v>.toml.
4. Write Cargoxx.toml: foo = { version = "<v>", registry = "cargoxx" }.
5. Write Cargoxx.lock: source_kind = "registry",
registry_attr = "foo_<safe_ver>".
6. Also persist lock.registry_rev = <current master commit>.
```
The bundled cargoxx wrapper has `git`, `curl` on PATH, so this works
on non-Nix hosts too.
### 2.7 Binary cache config
Once the cache URL + signing key are decided, the cargoxx wrapper's
`NIX_CONFIG` (in `flake.nix`'s `cargoxxNixConfig`) gains:
```
substituters = https://cache.cargoxx.<gitea-domain>/... https://cache.nixos.org
trusted-public-keys = cache.cargoxx.<gitea-domain>:<base64>= cache.nixos.org:6NCH…=
```
Set via `--set-default`, so a user with a different setup can
override by exporting `NIX_CONFIG`.
### 2.8 Hosting
| Concern | Choice |
|---|---|
| Repo host | Self-hosted Gitea (your instance). |
| CI | Gitea Actions on self-hosted runners (your existing pool). |
| Binary cache | Static-HTTPS to start (`nix copy --to file:///srv/cargoxx-cache`); migrate to `attic` (https://github.com/zhaofengli/attic) if scale demands. |
| Publishing auth | `tea login` (already configured). |
| Architectures | `x86_64-linux` only for v1. aarch64/darwin are follow-ups. |
### 2.9 Phasing
| Phase | Deliverable |
|---|---|
| 2a | `cargoxx-pkgs` repo skeleton + `flake.nix` (zero recipes). |
| 2b | `.gitea/workflows/validate-pr.yml`; manual recipe PRs. |
| 2c | `cargoxx publish` CLI; `auto-merge.yml`. |
| 2d | `cargoxx add` ↔ registry index; CI cache push; wrapper substituters config. |
---
## Module BMI strategy
Following the libstdc++/libc++ model in nixpkgs: ship **source `.cppm`**
via `install(TARGETS … FILE_SET CXX_MODULES …)`, ship the **compiled
archive** (`libfoo.a`) of impl bits. Consumers regenerate the BMI in
their own build (cmake's `FILE_SET CXX_MODULES` handles it when
`find_package(foo)` is called). The compiled archive is what's
cached — the BMI is cheap to regenerate against an already-cached
archive.
cargoxx already enables `CMAKE_CXX_MODULE_STD ON`, which CMake uses to
rebuild the `std` BMI in each consumer project. The same machinery
covers cargoxx libraries.
---
## Why `buildCppPackage` doesn't fetch source for registry deps
The registry flake (`cargoxx-pkgs`) owns the
`source-fetch → cargoxx-build → cmake-install` pipeline. Its
`packages.<system>.<name>` is an input-addressed derivation: same
sources + same toolchain pins → same hash → cache hit.
Consumers reference that derivation via `builtins.getFlake` at the
locked `registry_rev`. Nix's binary cache substitutes the `$out`
directly — no source clone, no compile. Source fetch + build live
inside the registry's eval, run once per recipe at CI time. This is
the same model as nixpkgs/`cache.nixos.org`: you don't fetch fmt's
source to use it from nixpkgs.
---
## Open follow-ups
1. Gitea domain + cache URL (for wrapper config in Phase 2d).
2. Signing key generation
(`nix-store --generate-binary-cache-key cache.cargoxx.<domain>
<secret> <public>`); private into runner secrets, public into
wrapper config.
3. Multi-arch (`aarch64-linux`, `*-darwin`) — Phase 3+.
4. Conan-style wrap recipes for non-cargoxx libraries — design now,
defer implementation.

204
flake.lock generated
View File

@@ -1,5 +1,44 @@
{ {
"nodes": { "nodes": {
"bundlers": {
"inputs": {
"nix-appimage": "nix-appimage",
"nix-bundle": "nix-bundle",
"nix-utils": "nix-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1777842037,
"narHash": "sha256-E6kwkFsKnU5k/QAX1aNOPfh69G6Im8/EwdRcZR4J0QE=",
"owner": "NixOS",
"repo": "bundlers",
"rev": "7bb70086c2dad3eecae4805f4d758c80e3cba960",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "bundlers",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -18,7 +57,119 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils_2": {
"locked": {
"lastModified": 1623875721,
"narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f7e004a55b120c02ecb6219596820fcd32ca8772",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_3": {
"inputs": {
"systems": "systems_3"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix-appimage": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": [
"bundlers",
"nixpkgs"
]
},
"locked": {
"lastModified": 1757920913,
"narHash": "sha256-jd0QwCVz4O1sHHkeaZILD/7D6oyalceEJ4EFnWCgm0k=",
"owner": "ralismark",
"repo": "nix-appimage",
"rev": "7946addbc0d97e358a6d7aefe5e82310f0fe6b18",
"type": "github"
},
"original": {
"owner": "ralismark",
"repo": "nix-appimage",
"type": "github"
}
},
"nix-bundle": {
"inputs": {
"nixpkgs": [
"bundlers",
"nixpkgs"
],
"utils": "utils"
},
"locked": {
"lastModified": 1756736056,
"narHash": "sha256-8YFhvulVX3iS4TYnKisA9zSImJeFN21G75HOUUFjzuE=",
"owner": "nix-community",
"repo": "nix-bundle",
"rev": "eff01593f62794d458ec714090091419194ab64d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-bundle",
"type": "github"
}
},
"nix-utils": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1744222205,
"narHash": "sha256-di1eNHQdpvvyXv6i7Z+S79KF7cQyhTs7AdFHp7q1e3Q=",
"owner": "juliosueiras-nix",
"repo": "nix-utils",
"rev": "53282197ad090c8cf47c96e99bf6c6c3b2cdc7c0",
"type": "github"
},
"original": {
"owner": "juliosueiras-nix",
"repo": "nix-utils",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": {
"lastModified": 1629252929,
"narHash": "sha256-Aj20gmGBs8TG7pyaQqgbsqAQ6cB+TVuL18Pk3DPBxcQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3788c68def67ca7949e0864c27638d484389363d",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1777954456, "lastModified": 1777954456,
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
@@ -36,8 +187,9 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "bundlers": "bundlers",
"nixpkgs": "nixpkgs" "flake-utils": "flake-utils_3",
"nixpkgs": "nixpkgs_2"
} }
}, },
"systems": { "systems": {
@@ -54,6 +206,54 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

204
flake.nix
View File

@@ -1,30 +1,208 @@
{ {
description = "cargoxx Cargo-style frontend for modern C++"; description = "cargoxx";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
bundlers.url = "github:NixOS/bundlers";
bundlers.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs, flake-utils, bundlers }:
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
# exec::run shells out to these at runtime; wrap the binary so
# they're reachable even outside a nix develop shell.
cargoxxRuntimePath = pkgs.lib.makeBinPath [
pkgs.nix
pkgs.cmake
pkgs.ninja
pkgs.curl
pkgs.git
pkgs.tea # used by `cargoxx publish` for Gitea API + auth
];
# Defaults applied to the bundled `nix` so it works on hosts
# that don't already have nix set up (Arch/Debian/Fedora users
# who install our .pkg.tar.zst / .deb / .rpm). Multi-user mode
# would expect a `nixbld` group and a running daemon.
# `substituters` includes the cargoxx-pkgs binary cache so
# `cargoxx add <pkg>` substitutes prebuilt $out instead of
# rebuilding each registry package locally.
cargoxxNixConfig = ''
experimental-features = nix-command flakes
build-users-group =
substituters = https://cache.cargoxx.amadey.xyz https://cache.nixos.org
trusted-public-keys = cache.cargoxx.amadey.xyz:HQNcKDh9lufWm0M32a06AEiLf1Hr0WoRY3Bp5NnWZxs= cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
'';
cargoxx-bin = pkgs.gcc15Stdenv.mkDerivation {
pname = "cargoxx";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [ pkgs.cmake pkgs.ninja pkgs.makeWrapper ];
buildInputs = [ pkgs.sqlite pkgs.reproc pkgs.catch2_3 ];
configurePhase = ''
cmake -S build -B build/release -G Ninja \
-DCMAKE_BUILD_TYPE=Release
'';
buildPhase = ''
cmake --build build/release
'';
installPhase = ''
mkdir -p $out/bin
cp build/release/bin/cargoxx $out/bin/
wrapProgram $out/bin/cargoxx \
--prefix PATH : ${cargoxxRuntimePath} \
--set-default NIX_CONFIG ${pkgs.lib.escapeShellArg cargoxxNixConfig}
'';
hardeningDisable = [ "all" ];
};
buildCppPackage = { src, name ? null, ... }@args:
let
lock = builtins.fromTOML (builtins.readFile (src + "/Cargoxx.lock"));
isDep = p: p ? linkdb_source;
isRoot = p: !(isDep p);
isCargoxxSource = p: (p.source_kind or "") != "";
root = builtins.head (builtins.filter isRoot lock.package);
depPkgs = builtins.filter isDep lock.package;
pname = if name != null then name else root.name;
pkgsAt = rev:
(builtins.getFlake "github:NixOS/nixpkgs/${rev}")
.legacyPackages.${system};
# cargoxx-source deps recurse into buildCppPackage; the result
# joins buildInputs so the consumer's find_package(<dep> CONFIG
# REQUIRED) resolves via CMAKE_PREFIX_PATH.
evalDep = p:
if (p.source_kind or "") == "cargoxx-path" then
buildCppPackage { src = src + ("/" + p.source_path); }
else if (p.source_kind or "") == "cargoxx-git" then
let depSrc = pkgs.fetchgit {
url = p.source_git_url;
rev = p.source_git_commit;
hash = p.source_git_sha256;
};
in buildCppPackage { src = depSrc; }
else
let rev = if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "")
then p.nixpkgs_rev
else lock.nixpkgs_rev;
in (pkgsAt rev).${p.nixpkgs_attr};
depInputs = map evalDep depPkgs;
usesPkgConfig = builtins.any
(p: (p.linkdb_source or "") == "pkg-config") depPkgs;
nixpkgsSource = (builtins.getFlake
"github:NixOS/nixpkgs/${lock.nixpkgs_rev}").outPath;
flakeUtilsSource = (builtins.getFlake
"github:numtide/flake-utils/${lock.flake_utils_rev}").outPath;
mkDepTomlEntry = p:
let
derivation = evalDep p;
# For cargoxx-source deps we don't have a nixpkgs rev/attr — the
# vendor.toml entry just needs a name + store_path so cargoxx's
# offline pathway can find the dep's installed prefix.
isCargoxx = (p.source_kind or "") != "";
attr = if isCargoxx then "" else p.nixpkgs_attr;
rev = if isCargoxx then ""
else if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "")
then p.nixpkgs_rev else lock.nixpkgs_rev;
in ''
[[dep]]
name = "${p.name}"
nixpkgs_attr = "${attr}"
nixpkgs_rev = "${rev}"
store_path = "${derivation}"
'';
vendorToml = pkgs.writeText "vendor.toml" (''
schema = 1
[nixpkgs]
rev = "${lock.nixpkgs_rev}"
store_path = "${nixpkgsSource}"
[flake_utils]
rev = "${lock.flake_utils_rev}"
store_path = "${flakeUtilsSource}"
'' + builtins.concatStringsSep "\n" (map mkDepTomlEntry depPkgs));
in pkgs.gcc15Stdenv.mkDerivation {
inherit pname src;
version = root.version;
nativeBuildInputs =
[ cargoxx-bin pkgs.cmake pkgs.ninja ]
++ pkgs.lib.optional usesPkgConfig pkgs.pkg-config;
buildInputs = depInputs;
dontConfigure = true;
buildPhase = ''
export HOME=$(mktemp -d)
cargoxx build --release --offline --vendor ${vendorToml}
'';
installPhase = ''
cmake --install build/release --prefix $out
'';
hardeningDisable = [ "all" ];
};
bundlers-sys = bundlers.bundlers.${system};
# Arch's pacman expects a tar.zst archive containing a
# `.PKGINFO` metadata file plus the file tree rooted at /.
# We ship the entire closure under /nix/store; /usr/bin/cargoxx
# is a symlink to the in-store wrapper.
toArchPkg = drv:
let
pname = drv.pname or drv.name;
version = drv.version or "0";
mainProgram = drv.meta.mainProgram or pname;
in pkgs.runCommand "${pname}-${version}-1-x86_64.pkg.tar.zst" {
nativeBuildInputs = [ pkgs.libarchive pkgs.coreutils ];
closureInfo = pkgs.closureInfo { rootPaths = [ drv ]; };
} ''
stage=$(mktemp -d)
mkdir -p $stage/nix/store $stage/usr/bin
for p in $(cat $closureInfo/store-paths); do
cp -a "$p" $stage/nix/store/
done
ln -s ${drv}/bin/${mainProgram} $stage/usr/bin/${mainProgram}
installed_size=$(du -sk $stage | cut -f1)
cat > $stage/.PKGINFO <<EOF
pkgname = ${pname}
pkgver = ${version}-1
pkgdesc = ${drv.meta.description or pname}
builddate = 0
packager = nix-build
size = $installed_size
arch = x86_64
EOF
( cd $stage && bsdtar --zstd -cf $out .PKGINFO nix usr )
'';
in { in {
packages.default = cargoxx-bin;
packages.dockerImage = bundlers-sys.toDockerImage cargoxx-bin;
packages.deb = bundlers-sys.toDEB cargoxx-bin;
packages.rpm = bundlers-sys.toRPM cargoxx-bin;
packages.archpkg = toArchPkg cargoxx-bin;
# Reusable packaging functions, all of the shape `drv -> drv`.
# Mirror the `to*` naming used by github:NixOS/bundlers.
lib.toDockerImage = bundlers-sys.toDockerImage;
lib.toDEB = bundlers-sys.toDEB;
lib.toRPM = bundlers-sys.toRPM;
lib.toArchPkg = toArchPkg;
lib.buildCppPackage = buildCppPackage;
devShells.default = pkgs.gcc15Stdenv.mkDerivation { devShells.default = pkgs.gcc15Stdenv.mkDerivation {
name = "cargoxx-dev"; name = "cargoxx-dev";
version = "0.1.0"; version = "0.1.0";
nativeBuildInputs = [ nativeBuildInputs = [ pkgs.ninja pkgs.cmake ];
pkgs.cmake buildInputs = [ pkgs.reproc pkgs.sqlite pkgs.catch2_3 ];
pkgs.ninja
pkgs.git
pkgs.pkg-config
];
buildInputs = [
pkgs.sqlite
pkgs.reproc
pkgs.catch2_3
];
hardeningDisable = [ "all" ]; hardeningDisable = [ "all" ];
}; };
}); });

View File

@@ -20,7 +20,24 @@ auto cmd_new(const std::string& name, bool lib_only,
// `overlay_path` lets tests redirect the linkdb overlay away from ~/.cache. // `overlay_path` lets tests redirect the linkdb overlay away from ~/.cache.
auto cmd_build(const std::filesystem::path& project_root, bool no_build, bool release, auto cmd_build(const std::filesystem::path& project_root, bool no_build, bool release,
std::optional<std::string> target = std::nullopt, std::optional<std::string> target = std::nullopt,
std::optional<std::filesystem::path> overlay_path = std::nullopt) std::optional<std::filesystem::path> overlay_path = std::nullopt,
bool offline = false,
std::optional<std::filesystem::path> vendor = std::nullopt)
-> util::Result<void>;
auto cmd_vendor(const std::filesystem::path& project_root,
const std::filesystem::path& output)
-> util::Result<void>;
// Publish the project's current HEAD as a new version recipe in the
// cargoxx-pkgs repo (mozart/cargoxx-pkgs). Validates manifest + lockfile,
// computes the source sha256 via `nix flake prefetch`, writes
// `recipes/<name>/versions/<version>.toml` (and `maintainers.txt` for
// new packages) into a `publish/<name>-<version>` branch via the
// Gitea contents API, opens a PR via `tea api`. With `dry_run=true`,
// prints the recipe TOML and skips all network operations.
// Authentication comes from `tea login`.
auto cmd_publish(const std::filesystem::path& project_root, bool dry_run)
-> util::Result<void>; -> util::Result<void>;
// Builds the project, picks a binary target, and execs it with `args`. // Builds the project, picks a binary target, and execs it with `args`.
@@ -53,6 +70,17 @@ auto cmd_add(const std::filesystem::path& project_root, const std::string& name,
auto cmd_remove(const std::filesystem::path& project_root, const std::string& name) auto cmd_remove(const std::filesystem::path& project_root, const std::string& name)
-> util::Result<void>; -> util::Result<void>;
// Inserts a manual recipe into the SQLite linkdb overlay. Equivalent to
// the auto-discover path's confirmed row, but user-provided. Use it
// when nixpkgs ships a CMake FindModule (no <X>Config.cmake) or when the
// scanner can't pick the right target automatically.
auto cmd_linkdb_add(const std::string& package, const std::string& version_range,
const std::string& find_package_text,
std::vector<std::string> targets,
const std::string& nixpkgs_attr,
std::optional<std::filesystem::path> overlay_path = std::nullopt)
-> util::Result<void>;
auto run(int argc, char** argv) -> int; auto run(int argc, char** argv) -> int;
} // namespace cargoxx::cli } // namespace cargoxx::cli

View File

@@ -77,9 +77,12 @@ auto record_lockfile_rev(const fs::path& project_root, const std::string& name,
return lockfile::write(lock, lock_path); return lockfile::write(lock, lock_path);
} }
// Drives the resolver chain (Conan → vcpkg → nix-cmake-scan), running a // Drives the resolver chain (Conan → vcpkg → nix-cmake-scan → pc-scan),
// real `cmd_build` against each candidate via verify_link. On success the // running a real `cmd_build` against each candidate via verify_link.
// overlay carries a confirmed row for the package. // On success the overlay carries a confirmed row for the package.
// Every probe's scratch project is preserved under
// `<XDG>/cargoxx/last-failure/<name>/<NN>-<probe>/` for inspection;
// the dir is wiped clean at the start of each call.
auto run_auto_resolution(const std::string& name, const std::string& version, auto run_auto_resolution(const std::string& name, const std::string& version,
const std::vector<std::string>& components, const std::vector<std::string>& components,
const fs::path& overlay_path) -> util::Result<void> { const fs::path& overlay_path) -> util::Result<void> {
@@ -87,17 +90,18 @@ auto run_auto_resolution(const std::string& name, const std::string& version,
return cmd_build(root, /*no_build=*/false, /*release=*/false, return cmd_build(root, /*no_build=*/false, /*release=*/false,
/*target=*/std::nullopt, overlay_path); /*target=*/std::nullopt, overlay_path);
}; };
const auto scratch_root = const auto scratch_root = resolver::last_failure_dir(name);
std::filesystem::temp_directory_path() / std::error_code ec;
std::format("cargoxx-discover-{}", std::random_device{}()); std::filesystem::remove_all(scratch_root, ec);
std::filesystem::create_directories(scratch_root, ec);
auto disc = resolver::discover(name, version, components, overlay_path, auto disc = resolver::discover(name, version, components, overlay_path,
scratch_root, build_fn); scratch_root, build_fn);
std::error_code ec;
std::filesystem::remove_all(scratch_root, ec);
if (!disc) { if (!disc) {
std::cerr << std::format(
"note: every probe attempt's scratch project is preserved at\n"
" {}/ — re-run cmake inside any subdir to reproduce.\n",
scratch_root.string());
return std::unexpected(disc.error()); return std::unexpected(disc.error());
} }
return {}; return {};
@@ -137,6 +141,81 @@ auto cmd_add(const fs::path& project_root, const std::string& name,
} }
} }
// Probe cargoxx-pkgs (the project's own recipe flake) before falling
// through to the nixpkgs/conan/vcpkg chain. If the name has a recipe
// there, the dep is identical in shape to a nixpkgs-resolved one —
// string-form spec in Cargoxx.toml, lockfile entry pinning the attr
// + cargoxx-pkgs repo rev, generated build/flake.nix gets
// `inputs.cargoxx-pkgs` and `cargoxx-pkgs.packages.${system}.<attr>`
// as a buildInput. Disabled in tests via CARGOXX_NO_AUTORESOLVE.
{
auto* env = std::getenv("CARGOXX_NO_AUTORESOLVE");
const bool autoresolve_disabled = env != nullptr && *env != 0;
if (!autoresolve_disabled) {
auto hit = resolver::try_cargoxx_pkgs(name, effective_version);
if (hit) {
// cargoxx-pkgs's flake exposes per-version attrs as
// `<n>_<safe>` (e.g. greeter_0_1_1) plus a bare `<n>`
// pointing at the latest. Pin the concrete attr so the
// lock is hermetic.
std::string safe;
safe.reserve(hit->version.size());
for (char c : hit->version) {
safe.push_back((c == '.' || c == '-' || c == '+') ? '_' : c);
}
auto attr = std::format("{}_{}", name, safe);
m->dependencies.push_back(manifest::Dependency{
.name = name,
.version_spec = hit->version,
.components = std::move(components),
});
if (auto r = manifest::write(*m, manifest_path); !r) {
return std::unexpected(r.error());
}
const auto lock_path = project_root / "Cargoxx.lock";
lockfile::Lockfile lock;
if (std::error_code ec; std::filesystem::exists(lock_path, ec)) {
if (auto parsed = lockfile::parse(lock_path); parsed) {
lock = std::move(*parsed);
}
}
if (lock.version == 0) {
lock.version = 1;
}
lockfile::LockfilePackage entry{
.name = name,
.version = hit->version,
.cargoxx_pkgs_attr = attr,
.cargoxx_pkgs_rev = hit->repo_rev,
.linkdb_source = "cargoxx-pkgs",
.find_package = std::format("{} CONFIG REQUIRED", name),
.targets = {std::format("{}::{}", name, name)},
};
bool replaced = false;
for (auto& p : lock.packages) {
if (p.name == name && p.version == hit->version) {
p = entry;
replaced = true;
break;
}
}
if (!replaced) {
lock.packages.push_back(std::move(entry));
}
if (auto r = lockfile::write(lock, lock_path); !r) {
return std::unexpected(r.error());
}
return {};
}
if (hit.error().code != util::ErrorCode::ResolutionUnknownPackage) {
return std::unexpected(hit.error());
}
// Fall through to the linkdb chain.
}
}
const auto effective_overlay = overlay_path.value_or(linkdb::default_overlay_path()); const auto effective_overlay = overlay_path.value_or(linkdb::default_overlay_path());
// Drop any auto-discovered overlay rows for this package before // Drop any auto-discovered overlay rows for this package before

View File

@@ -8,6 +8,7 @@ import cargoxx.linkdb;
import cargoxx.lockfile; import cargoxx.lockfile;
import cargoxx.codegen; import cargoxx.codegen;
import cargoxx.exec; import cargoxx.exec;
import cargoxx.resolver;
namespace cargoxx::cli { namespace cargoxx::cli {
@@ -34,6 +35,34 @@ auto write_text(const fs::path& path, std::string_view content) -> util::Result<
return {}; return {};
} }
auto query_flake_rev(std::string_view flake_ref) -> std::optional<std::string> {
auto r = exec::run("nix",
{"--extra-experimental-features",
"nix-command flakes", "flake", "metadata", "--json",
std::string{flake_ref}},
exec::ExecOptions{
.cwd = fs::current_path(),
.env_overrides = {},
.timeout = std::chrono::seconds{30},
.inherit_stdio = false,
});
if (!r || r->exit_code != 0) {
return std::nullopt;
}
std::string_view body = r->stdout_text;
constexpr std::string_view key = "\"rev\":\"";
auto pos = body.find(key);
if (pos == std::string_view::npos) {
return std::nullopt;
}
pos += key.size();
auto end = body.find('"', pos);
if (end == std::string_view::npos) {
return std::nullopt;
}
return std::string{body.substr(pos, end - pos)};
}
// Builds the lockfile from the manifest + resolved recipes, **preserving** // Builds the lockfile from the manifest + resolved recipes, **preserving**
// `nixpkgs_rev` for any (name, version) entry that already exists in // `nixpkgs_rev` for any (name, version) entry that already exists in
// `prior` with a matching key. This is what makes `cargoxx build` // `prior` with a matching key. This is what makes `cargoxx build`
@@ -45,7 +74,9 @@ auto write_text(const fs::path& path, std::string_view content) -> util::Result<
auto merge_lockfile(const manifest::Manifest& m, auto merge_lockfile(const manifest::Manifest& m,
const std::vector<linkdb::Recipe>& recipes, const std::vector<linkdb::Recipe>& recipes,
const std::vector<linkdb::Recipe>& dev_recipes, const std::vector<linkdb::Recipe>& dev_recipes,
const lockfile::Lockfile& prior) -> lockfile::Lockfile { const lockfile::Lockfile& prior,
const std::map<std::string, std::string>& git_sha256s)
-> lockfile::Lockfile {
auto find_prior = [&](const std::string& name, const std::string& version) auto find_prior = [&](const std::string& name, const std::string& version)
-> std::optional<lockfile::LockfilePackage> { -> std::optional<lockfile::LockfilePackage> {
for (const auto& p : prior.packages) { for (const auto& p : prior.packages) {
@@ -58,6 +89,13 @@ auto merge_lockfile(const manifest::Manifest& m,
lockfile::Lockfile lock; lockfile::Lockfile lock;
lock.version = 1; lock.version = 1;
lock.nixpkgs_rev_pin = prior.nixpkgs_rev_pin.has_value()
? prior.nixpkgs_rev_pin
: query_flake_rev("github:NixOS/nixpkgs/nixos-unstable");
lock.flake_utils_rev_pin =
prior.flake_utils_rev_pin.has_value()
? prior.flake_utils_rev_pin
: query_flake_rev("github:numtide/flake-utils");
lockfile::LockfilePackage root{ lockfile::LockfilePackage root{
.name = m.package.name, .name = m.package.name,
@@ -78,19 +116,61 @@ auto merge_lockfile(const manifest::Manifest& m,
auto emit_dep = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) { auto emit_dep = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) {
std::optional<std::string> rev; std::optional<std::string> rev;
std::string attr = rec.nixpkgs_attr; std::string attr = rec.nixpkgs_attr;
std::optional<std::string> cxx_pkgs_attr;
std::optional<std::string> cxx_pkgs_rev;
if (auto p = find_prior(dep.name, dep.version_spec); p) { if (auto p = find_prior(dep.name, dep.version_spec); p) {
rev = p->nixpkgs_rev; rev = p->nixpkgs_rev;
if (p->nixpkgs_attr && !p->nixpkgs_attr->empty()) { if (p->nixpkgs_attr && !p->nixpkgs_attr->empty()) {
attr = *p->nixpkgs_attr; attr = *p->nixpkgs_attr;
} }
cxx_pkgs_attr = p->cargoxx_pkgs_attr;
cxx_pkgs_rev = p->cargoxx_pkgs_rev;
}
// For cargoxx-pkgs-resolved deps, drop the nixpkgs fields — the
// recipe synthesized at `cargoxx add` time left them empty and
// the codegen layer prefers cargoxx_pkgs_attr anyway.
std::optional<std::string> nix_attr_opt;
if (cxx_pkgs_attr && !cxx_pkgs_attr->empty()) {
attr.clear();
rev.reset();
} else if (!attr.empty()) {
nix_attr_opt = attr;
}
std::optional<std::string> source_kind;
std::optional<std::string> source_path;
std::optional<std::string> source_git_url;
std::optional<std::string> source_git_commit;
std::optional<std::string> source_git_sha256;
if (dep.source == manifest::DepSource::CargoxxPath) {
source_kind = "cargoxx-path";
source_path = dep.path;
} else if (dep.source == manifest::DepSource::CargoxxGit) {
source_kind = "cargoxx-git";
source_git_url = dep.git_url;
source_git_commit = dep.git_rev;
if (auto it = git_sha256s.find(dep.name); it != git_sha256s.end()) {
source_git_sha256 = it->second;
}
} }
lock.packages.push_back(lockfile::LockfilePackage{ lock.packages.push_back(lockfile::LockfilePackage{
.name = dep.name, .name = dep.name,
.version = dep.version_spec, .version = dep.version_spec,
.dependencies = {}, .dependencies = {},
.nixpkgs_attr = std::move(attr), .nixpkgs_attr = std::move(nix_attr_opt),
.nixpkgs_rev = std::move(rev), .nixpkgs_rev = std::move(rev),
.cargoxx_pkgs_attr = std::move(cxx_pkgs_attr),
.cargoxx_pkgs_rev = std::move(cxx_pkgs_rev),
.linkdb_source = rec.source, .linkdb_source = rec.source,
.find_package = rec.find_package,
.targets = rec.targets,
.pkg_config_module = rec.pkg_config_module,
.brute_force_libs = rec.brute_force_libs,
.brute_force_includes = rec.brute_force_includes,
.source_kind = std::move(source_kind),
.source_path = std::move(source_path),
.source_git_url = std::move(source_git_url),
.source_git_commit = std::move(source_git_commit),
.source_git_sha256 = std::move(source_git_sha256),
}); });
}; };
for (std::size_t i = 0; i < m.dependencies.size(); ++i) { for (std::size_t i = 0; i < m.dependencies.size(); ++i) {
@@ -108,7 +188,9 @@ namespace {
auto run_nix_cmake(const fs::path& project_root, const std::vector<std::string>& cmake_args, auto run_nix_cmake(const fs::path& project_root, const std::vector<std::string>& cmake_args,
std::string_view phase) -> util::Result<void> { std::string_view phase) -> util::Result<void> {
std::vector<std::string> args{"develop", "--command", "cmake"}; std::vector<std::string> args{"--extra-experimental-features",
"nix-command flakes", "develop",
"path:./build", "--command", "cmake"};
args.insert(args.end(), cmake_args.begin(), cmake_args.end()); args.insert(args.end(), cmake_args.begin(), cmake_args.end());
auto r = exec::run("nix", args, exec::ExecOptions{ auto r = exec::run("nix", args, exec::ExecOptions{
@@ -134,7 +216,8 @@ auto run_nix_cmake(const fs::path& project_root, const std::vector<std::string>&
auto cmd_build(const fs::path& project_root, bool no_build, bool release, auto cmd_build(const fs::path& project_root, bool no_build, bool release,
std::optional<std::string> target, std::optional<std::string> target,
std::optional<fs::path> overlay_path) -> util::Result<void> { std::optional<fs::path> overlay_path, bool offline,
std::optional<fs::path> vendor) -> util::Result<void> {
auto manifest_path = project_root / "Cargoxx.toml"; auto manifest_path = project_root / "Cargoxx.toml";
auto m = manifest::parse(manifest_path); auto m = manifest::parse(manifest_path);
if (!m) { if (!m) {
@@ -146,17 +229,194 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
return std::unexpected(layout_result.error()); return std::unexpected(layout_result.error());
} }
auto db = linkdb::Database::open(std::move(overlay_path)); const auto effective_overlay = overlay_path.value_or(linkdb::default_overlay_path());
auto db = linkdb::Database::open(effective_overlay);
if (!db) { if (!db) {
return std::unexpected(db.error()); return std::unexpected(db.error());
} }
auto auto_resolve = [&](const std::string& name, const std::string& version,
const std::vector<std::string>& components)
-> util::Result<void> {
auto build_fn = [&](const fs::path& root) {
return cmd_build(root, /*no_build=*/false, /*release=*/false,
/*target=*/std::nullopt, effective_overlay);
};
const auto scratch_root = resolver::last_failure_dir(name);
std::error_code ec;
std::filesystem::remove_all(scratch_root, ec);
std::filesystem::create_directories(scratch_root, ec);
auto disc = resolver::discover(name, version, components,
effective_overlay, scratch_root, build_fn);
if (!disc) {
std::cerr << std::format(
"note: every probe attempt's scratch project is preserved at\n"
" {}/ — re-run cmake inside any subdir to reproduce.\n",
scratch_root.string());
return std::unexpected(disc.error());
}
return {};
};
lockfile::Lockfile prior;
if (std::error_code ec; std::filesystem::exists(project_root / "Cargoxx.lock", ec)) {
if (auto r = lockfile::parse(project_root / "Cargoxx.lock"); r) {
prior = std::move(*r);
}
}
auto recipe_from_lock = [&](const std::string& name, const std::string& version)
-> std::optional<linkdb::Recipe> {
for (const auto& p : prior.packages) {
if (p.name != name || p.version != version) {
continue;
}
if (!p.find_package || p.targets.empty()) {
if (p.brute_force_libs.empty() && p.brute_force_includes.empty()) {
return std::nullopt;
}
}
return linkdb::Recipe{
.nixpkgs_attr = p.nixpkgs_attr.value_or(""),
.find_package = p.find_package.value_or(""),
.targets = p.targets,
.source = p.linkdb_source.value_or(""),
.pkg_config_module = p.pkg_config_module,
.brute_force_libs = p.brute_force_libs,
.brute_force_includes = p.brute_force_includes,
};
}
return std::nullopt;
};
// For { path = "../foo" } deps: skip the linkdb chain entirely. Read
// the dep's Cargoxx.toml to confirm the name matches, then synthesize
// a Recipe that maps to the producer's installed Config.cmake.
auto resolve_path_dep = [&](const manifest::Dependency& dep)
-> util::Result<linkdb::Recipe> {
const auto dep_root = (project_root / *dep.path).lexically_normal();
auto dep_manifest = manifest::parse(dep_root / "Cargoxx.toml");
if (!dep_manifest) {
return std::unexpected(dep_manifest.error());
}
if (dep_manifest->package.name != dep.name) {
return std::unexpected(util::Error{
util::ErrorCode::ManifestInvalidField,
std::format("path dep '{}' points to a project named '{}'",
dep.name, dep_manifest->package.name),
std::format("rename the dep, or fix [package].name in {}",
(dep_root / "Cargoxx.toml").string()),
dep_root / "Cargoxx.toml", std::nullopt,
});
}
return linkdb::Recipe{
.nixpkgs_attr = "",
.find_package = std::format("{} CONFIG REQUIRED", dep.name),
.targets = {std::format("{}::{}", dep.name, dep.name)},
.source = "cargoxx-path",
.pkg_config_module = std::nullopt,
.brute_force_libs = {},
.brute_force_includes = {},
};
};
// Side channel: (dep name) → SRI hash captured during git resolution,
// consumed by merge_lockfile to persist source_git_sha256.
std::map<std::string, std::string> git_sha256s;
// For { git = "...", rev = "..." } deps: if the prior lockfile already
// records an SRI hash for this (url, commit), reuse it. Otherwise run
// `nix flake prefetch` to fetch + hash the source tree (FOD-compatible),
// then verify the dep's Cargoxx.toml name matches.
auto resolve_git_dep = [&](const manifest::Dependency& dep)
-> util::Result<linkdb::Recipe> {
std::optional<std::string> cached_sha;
for (const auto& p : prior.packages) {
if (p.name == dep.name && p.source_kind == "cargoxx-git" &&
p.source_git_url == dep.git_url &&
p.source_git_commit == dep.git_rev && p.source_git_sha256) {
cached_sha = p.source_git_sha256;
break;
}
}
if (!cached_sha) {
if (offline) {
return std::unexpected(util::Error{
util::ErrorCode::BuildCmakeFailed,
std::format("git dep '{}' has no cached hash and --offline "
"forbids network fetch", dep.name),
"run `cargoxx build` once online to populate Cargoxx.lock",
std::nullopt, std::nullopt,
});
}
auto flake_ref = std::format("git+{}?rev={}", *dep.git_url, *dep.git_rev);
auto prefetched = resolver::prefetch_flake_source(flake_ref);
if (!prefetched) {
return std::unexpected(prefetched.error());
}
// Verify name matches by reading the fetched tree's Cargoxx.toml.
auto dep_manifest = manifest::parse(
std::filesystem::path{prefetched->store_path} / "Cargoxx.toml");
if (!dep_manifest) {
return std::unexpected(dep_manifest.error());
}
if (dep_manifest->package.name != dep.name) {
return std::unexpected(util::Error{
util::ErrorCode::ManifestInvalidField,
std::format("git dep '{}' points to a project named '{}'",
dep.name, dep_manifest->package.name),
"rename the dep or use a repo whose [package].name matches",
std::nullopt, std::nullopt,
});
}
cached_sha = prefetched->hash;
}
git_sha256s[dep.name] = *cached_sha;
return linkdb::Recipe{
.nixpkgs_attr = "",
.find_package = std::format("{} CONFIG REQUIRED", dep.name),
.targets = {std::format("{}::{}", dep.name, dep.name)},
.source = "cargoxx-git",
.pkg_config_module = std::nullopt,
.brute_force_libs = {},
.brute_force_includes = {},
};
};
auto resolve_list = [&](const std::vector<manifest::Dependency>& deps) auto resolve_list = [&](const std::vector<manifest::Dependency>& deps)
-> util::Result<std::vector<linkdb::Recipe>> { -> util::Result<std::vector<linkdb::Recipe>> {
std::vector<linkdb::Recipe> out; std::vector<linkdb::Recipe> out;
out.reserve(deps.size()); out.reserve(deps.size());
for (const auto& dep : deps) { for (const auto& dep : deps) {
if (dep.source == manifest::DepSource::CargoxxPath) {
auto r = resolve_path_dep(dep);
if (!r) {
return std::unexpected(r.error());
}
out.push_back(std::move(*r));
continue;
}
if (dep.source == manifest::DepSource::CargoxxGit) {
auto r = resolve_git_dep(dep);
if (!r) {
return std::unexpected(r.error());
}
out.push_back(std::move(*r));
continue;
}
if (auto cached = recipe_from_lock(dep.name, dep.version_spec); cached) {
out.push_back(std::move(*cached));
continue;
}
auto r = db->resolve(dep.name, dep.version_spec, dep.components); auto r = db->resolve(dep.name, dep.version_spec, dep.components);
if (!r && r.error().code == util::ErrorCode::LinkdbUnknownPackage) {
if (auto resolved = auto_resolve(dep.name, dep.version_spec,
dep.components);
!resolved) {
return std::unexpected(resolved.error());
}
r = db->resolve(dep.name, dep.version_spec, dep.components);
}
if (!r) { if (!r) {
return std::unexpected(r.error()); return std::unexpected(r.error());
} }
@@ -172,14 +432,25 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
if (!dev_recipes) { if (!dev_recipes) {
return std::unexpected(dev_recipes.error()); return std::unexpected(dev_recipes.error());
} }
auto lock = merge_lockfile(*m, *recipes, *dev_recipes, prior, git_sha256s);
lockfile::Lockfile prior; std::optional<codegen::VendorIndex> vendor_index;
if (std::error_code ec; std::filesystem::exists(project_root / "Cargoxx.lock", ec)) { if (offline) {
if (auto r = lockfile::parse(project_root / "Cargoxx.lock"); r) { auto vendor_path = vendor.value_or(project_root / "vendor.toml");
prior = std::move(*r); if (std::error_code v_ec; !fs::exists(vendor_path, v_ec)) {
return std::unexpected(io_error(
std::format("--offline requires vendor.toml; expected at '{}'",
vendor_path.string()),
vendor_path));
} }
std::ifstream in_file{vendor_path};
std::string body{std::istreambuf_iterator<char>(in_file), {}};
auto parsed = codegen::parse_vendor_toml(body);
if (!parsed) {
return std::unexpected(parsed.error());
}
vendor_index = std::move(*parsed);
} }
auto lock = merge_lockfile(*m, *recipes, *dev_recipes, prior);
codegen::GenerateInputs in{ codegen::GenerateInputs in{
.manifest = *m, .manifest = *m,
@@ -188,6 +459,7 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
.recipes = *recipes, .recipes = *recipes,
.dev_recipes = *dev_recipes, .dev_recipes = *dev_recipes,
.project_root = project_root, .project_root = project_root,
.vendor = vendor_index,
}; };
auto flake_text = codegen::flake_nix(in); auto flake_text = codegen::flake_nix(in);
auto cmake_text = codegen::cmake_lists(in); auto cmake_text = codegen::cmake_lists(in);
@@ -200,7 +472,7 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
project_root / "build")); project_root / "build"));
} }
if (auto r = write_text(project_root / "flake.nix", flake_text); !r) { if (auto r = write_text(project_root / "build" / "flake.nix", flake_text); !r) {
return std::unexpected(r.error()); return std::unexpected(r.error());
} }
if (auto r = write_text(project_root / "build" / "CMakeLists.txt", cmake_text); !r) { if (auto r = write_text(project_root / "build" / "CMakeLists.txt", cmake_text); !r) {
@@ -224,16 +496,41 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
"-G", "Ninja", "-G", "Ninja",
std::format("-DCMAKE_BUILD_TYPE={}", profile_cap), std::format("-DCMAKE_BUILD_TYPE={}", profile_cap),
}; };
if (auto r = run_nix_cmake(project_root, configure_args, "configure"); !r) {
return std::unexpected(r.error());
}
std::vector<std::string> build_args{"--build", build_dir}; std::vector<std::string> build_args{"--build", build_dir};
if (target) { if (target) {
build_args.push_back("--target"); build_args.push_back("--target");
build_args.push_back(*target); build_args.push_back(*target);
} }
if (auto r = run_nix_cmake(project_root, build_args, "build"); !r) {
auto run_cmake = [&](const std::vector<std::string>& args,
std::string_view phase) -> util::Result<void> {
if (offline) {
auto r = exec::run("cmake", args,
exec::ExecOptions{
.cwd = project_root,
.env_overrides = {},
.timeout = std::nullopt,
.inherit_stdio = true,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code != 0) {
return std::unexpected(util::Error{
util::ErrorCode::BuildCmakeFailed,
std::format("cmake {} failed (exit {})", phase, r->exit_code),
"", std::nullopt, std::nullopt,
});
}
return {};
}
return run_nix_cmake(project_root, args, phase);
};
if (auto r = run_cmake(configure_args, "configure"); !r) {
return std::unexpected(r.error());
}
if (auto r = run_cmake(build_args, "build"); !r) {
return std::unexpected(r.error()); return std::unexpected(r.error());
} }

View File

@@ -0,0 +1,31 @@
module cargoxx.cli;
import std;
import cargoxx.linkdb;
import cargoxx.util;
namespace cargoxx::cli {
auto cmd_linkdb_add(const std::string& package, const std::string& version_range,
const std::string& find_package_text,
std::vector<std::string> targets,
const std::string& nixpkgs_attr,
std::optional<std::filesystem::path> overlay_path)
-> util::Result<void> {
auto db = linkdb::Database::open(std::move(overlay_path));
if (!db) {
return std::unexpected(db.error());
}
if (auto r = db->evict_auto_recipes(package); !r) {
return std::unexpected(r.error());
}
linkdb::Recipe r{
.nixpkgs_attr = nixpkgs_attr,
.find_package = find_package_text,
.targets = std::move(targets),
.source = "manual",
};
return db->add_manual(package, version_range, r);
}
} // namespace cargoxx::cli

459
src/cli/cmd_publish.cpp Normal file
View File

@@ -0,0 +1,459 @@
module;
#include <json.hpp>
module cargoxx.cli;
import std;
import cargoxx.util;
import cargoxx.manifest;
import cargoxx.lockfile;
import cargoxx.exec;
namespace cargoxx::cli {
namespace fs = std::filesystem;
namespace {
// The one and only publish destination.
constexpr std::string_view CARGOXX_PKGS_REPO = "mozart/cargoxx-pkgs";
auto err(util::ErrorCode code, std::string msg, std::string hint = "")
-> util::Error {
return util::Error{code, std::move(msg), std::move(hint), std::nullopt,
std::nullopt};
}
auto trim(std::string s) -> std::string {
auto end = s.find_last_not_of(" \t\r\n");
if (end != std::string::npos) {
s.erase(end + 1);
}
auto start = s.find_first_not_of(" \t\r\n");
if (start == std::string::npos) {
return {};
}
return s.substr(start);
}
// Run a process, return trimmed stdout on success.
auto capture(const std::string& prog, std::vector<std::string> args,
const fs::path& cwd) -> util::Result<std::string> {
auto r = exec::run(prog, args,
exec::ExecOptions{
.cwd = cwd,
.env_overrides = {},
.timeout = std::chrono::seconds{60},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code != 0) {
return std::unexpected(err(
util::ErrorCode::ExecCommandFailed,
std::format("{} failed (exit {}): {}", prog, r->exit_code,
r->stderr_text)));
}
return trim(r->stdout_text);
}
// Normalize a git remote URL to an https form Nix can fetch.
// `git@host:owner/repo.git` → `https://host/owner/repo`
// `https://host/owner/repo.git` → `https://host/owner/repo`
// `ssh://git@host:port/owner/repo.git` → `https://host/owner/repo`
auto normalize_remote(std::string url) -> std::string {
if (url.starts_with("git@")) {
// git@host:owner/repo[.git]
auto colon = url.find(':');
if (colon != std::string::npos) {
auto host = url.substr(4, colon - 4);
auto path = url.substr(colon + 1);
url = std::format("https://{}/{}", host, path);
}
} else if (url.starts_with("ssh://git@")) {
url.replace(0, std::string_view{"ssh://git@"}.size(), "https://");
// Strip :PORT from ssh form.
auto slash = url.find('/', 8);
auto colon = url.find(':', 8);
if (colon != std::string::npos && colon < slash) {
url.erase(colon, slash - colon);
}
}
if (url.ends_with(".git")) {
url.erase(url.size() - 4);
}
return url;
}
// Encode a string as base64 (RFC 4648, no line wrap). Inline impl avoids
// dragging in another library — Gitea's contents-API requires base64
// bodies for file content.
auto b64encode(std::string_view bytes) -> std::string {
static constexpr std::string_view alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve((bytes.size() + 2) / 3 * 4);
std::size_t i = 0;
while (i + 3 <= bytes.size()) {
auto a = static_cast<unsigned char>(bytes[i]);
auto b = static_cast<unsigned char>(bytes[i + 1]);
auto c = static_cast<unsigned char>(bytes[i + 2]);
out += alphabet[(a >> 2) & 0x3f];
out += alphabet[((a << 4) | (b >> 4)) & 0x3f];
out += alphabet[((b << 2) | (c >> 6)) & 0x3f];
out += alphabet[c & 0x3f];
i += 3;
}
if (i < bytes.size()) {
auto a = static_cast<unsigned char>(bytes[i]);
out += alphabet[(a >> 2) & 0x3f];
if (i + 1 == bytes.size()) {
out += alphabet[(a << 4) & 0x3f];
out += "==";
} else {
auto b = static_cast<unsigned char>(bytes[i + 1]);
out += alphabet[((a << 4) | (b >> 4)) & 0x3f];
out += alphabet[(b << 2) & 0x3f];
out += '=';
}
}
return out;
}
auto escape_toml(std::string_view s) -> std::string {
std::string out;
out.reserve(s.size() + 2);
out += '"';
for (char c : s) {
if (c == '\\' || c == '"') {
out += '\\';
}
out += c;
}
out += '"';
return out;
}
auto build_recipe(const manifest::Manifest& m, const lockfile::Lockfile& lock,
std::string_view source_url, std::string_view source_commit,
std::string_view source_sha256) -> std::string {
std::string out;
out += "schema = 1\n";
out += std::format("name = {}\n", escape_toml(m.package.name));
out += std::format("version = {}\n\n", escape_toml(m.package.version));
out += "[source]\n";
out += "type = \"git\"\n";
out += std::format("url = {}\n", escape_toml(source_url));
out += std::format("commit = {}\n", escape_toml(source_commit));
out += std::format("sha256 = {}\n\n", escape_toml(source_sha256));
if (!m.dependencies.empty()) {
out += "[dependencies]\n";
for (const auto& d : m.dependencies) {
if (d.source == manifest::DepSource::CargoxxPath) {
// Path deps can't be published — caller already rejected.
continue;
}
out += std::format("{} = {}\n", d.name, escape_toml(d.version_spec));
}
out += "\n";
}
out += "[lock]\n";
if (lock.nixpkgs_rev_pin) {
out += std::format("nixpkgs_rev = {}\n", escape_toml(*lock.nixpkgs_rev_pin));
}
if (lock.flake_utils_rev_pin) {
out += std::format("flake_utils_rev = {}\n",
escape_toml(*lock.flake_utils_rev_pin));
}
out += "\n";
out += "[meta]\n";
if (m.package.description) {
out += std::format("description = {}\n", escape_toml(*m.package.description));
} else {
out += std::format("description = {}\n", escape_toml(m.package.name));
}
if (m.package.homepage) {
out += std::format("homepage = {}\n", escape_toml(*m.package.homepage));
}
if (m.package.license) {
out += std::format("license = {}\n", escape_toml(*m.package.license));
}
return out;
}
auto extract_json_string(std::string_view body, std::string_view key)
-> std::optional<std::string> {
auto needle = std::format("\"{}\":\"", key);
auto pos = body.find(needle);
if (pos == std::string_view::npos) {
return std::nullopt;
}
pos += needle.size();
auto end = body.find('"', pos);
if (end == std::string_view::npos) {
return std::nullopt;
}
return std::string{body.substr(pos, end - pos)};
}
auto tea_whoami() -> util::Result<std::string> {
auto r = capture("tea", {"api", "/user"}, fs::current_path());
if (!r) {
return std::unexpected(r.error());
}
auto login = extract_json_string(*r, "login");
if (!login) {
return std::unexpected(err(util::ErrorCode::Internal,
"tea api /user returned no 'login' field"));
}
return *login;
}
auto path_exists_remote(const std::string& registry, const std::string& path)
-> bool {
auto r = exec::run("tea",
{"api", std::format("/repos/{}/contents/{}", registry, path)},
exec::ExecOptions{
.cwd = fs::current_path(),
.env_overrides = {},
.timeout = std::chrono::seconds{30},
.inherit_stdio = false,
});
if (!r) {
return false;
}
return r->exit_code == 0 && r->stdout_text.find("\"name\":") != std::string::npos;
}
} // namespace
auto cmd_publish(const fs::path& project_root, bool dry_run)
-> util::Result<void> {
// 1. Read manifest + lockfile
auto m = manifest::parse(project_root / "Cargoxx.toml");
if (!m) {
return std::unexpected(m.error());
}
if (m->package.name.empty() || m->package.version.empty()) {
return std::unexpected(err(util::ErrorCode::ManifestInvalidField,
"publish requires [package].name and .version"));
}
if (!m->package.license) {
return std::unexpected(err(util::ErrorCode::ManifestInvalidField,
"publish requires [package].license"));
}
auto lock_path = project_root / "Cargoxx.lock";
if (std::error_code ec; !fs::exists(lock_path, ec)) {
return std::unexpected(err(util::ErrorCode::ManifestNotFound,
"Cargoxx.lock is missing — run `cargoxx build` first"));
}
auto lock = lockfile::parse(lock_path);
if (!lock) {
return std::unexpected(lock.error());
}
// 2. Path deps are disallowed when publishing (they're local-only).
for (const auto& d : m->dependencies) {
if (d.source == manifest::DepSource::CargoxxPath) {
return std::unexpected(err(
util::ErrorCode::ManifestInvalidField,
std::format("path dep '{}' cannot be published — convert to "
"{{ git = ..., rev = ... }} or release the dep "
"separately first", d.name)));
}
}
// 3. Git context.
auto remote_url = capture("git", {"remote", "get-url", "origin"}, project_root);
if (!remote_url) {
return std::unexpected(err(util::ErrorCode::ManifestInvalidField,
"no `origin` git remote",
"git remote add origin <url>"));
}
auto source_url = normalize_remote(*remote_url);
auto head = capture("git", {"rev-parse", "HEAD"}, project_root);
if (!head) {
return std::unexpected(head.error());
}
// Working tree must be clean — otherwise the published source won't
// match what's actually in the repo at HEAD.
auto status = capture("git", {"status", "--porcelain"}, project_root);
if (!status) {
return std::unexpected(status.error());
}
if (!status->empty()) {
return std::unexpected(err(util::ErrorCode::ManifestInvalidField,
"git working tree is not clean",
"commit or stash changes before publishing"));
}
// 4. Compute source sha256 via nix flake prefetch — the published
// recipe pins this; the registry CI re-verifies it.
auto flake_ref = std::format("git+{}?rev={}", source_url, *head);
auto prefetch = capture("nix",
{"--extra-experimental-features",
"nix-command flakes", "flake", "prefetch",
flake_ref, "--json"},
project_root);
if (!prefetch) {
return std::unexpected(err(util::ErrorCode::ResolutionNetworkError,
std::format("nix flake prefetch failed for {}",
flake_ref)));
}
auto source_sha256 = extract_json_string(*prefetch, "hash");
if (!source_sha256) {
return std::unexpected(err(util::ErrorCode::ResolutionNetworkError,
"nix flake prefetch returned no hash"));
}
// 5. Build the recipe TOML.
auto recipe = build_recipe(*m, *lock, source_url, *head, *source_sha256);
if (dry_run) {
std::cout << recipe;
return {};
}
// 6. Auth.
const std::string registry{CARGOXX_PKGS_REPO};
auto publisher = tea_whoami();
if (!publisher) {
return std::unexpected(publisher.error());
}
// 7. Compose the API payload — one atomic commit creating the
// version recipe, plus maintainers.txt for first-time packages.
auto branch = std::format("publish/{}-{}", m->package.name, m->package.version);
auto version_path = std::format("recipes/{}/versions/{}.toml",
m->package.name, m->package.version);
auto maintainers_path = std::format("recipes/{}/maintainers.txt", m->package.name);
bool is_new_package = !path_exists_remote(registry, maintainers_path);
nlohmann::json files = nlohmann::json::array();
files.push_back({
{"operation", "create"},
{"path", version_path},
{"content", b64encode(recipe)},
});
if (is_new_package) {
std::string maintainers_body = *publisher + "\n";
files.push_back({
{"operation", "create"},
{"path", maintainers_path},
{"content", b64encode(maintainers_body)},
});
}
nlohmann::json body = {
{"branch", "master"},
{"new_branch", branch},
{"message", std::format("publish: {} {}", m->package.name, m->package.version)},
{"files", files},
};
// tea api with `-d @<file>` reads the body from a file — write it
// to a temp path. exec::run doesn't pipe stdin, so a tempfile is
// the simplest portable plumbing.
auto contents_tmp = fs::temp_directory_path() /
std::format("cargoxx-publish-{}.json",
std::random_device{}());
std::ofstream{contents_tmp} << body.dump();
auto contents_r = exec::run(
"tea",
{"api", "-X", "POST", "-d", std::format("@{}", contents_tmp.string()),
std::format("/repos/{}/contents", registry)},
exec::ExecOptions{
.cwd = project_root,
.env_overrides = {},
.timeout = std::chrono::seconds{120},
.inherit_stdio = false,
});
std::error_code rm_ec;
fs::remove(contents_tmp, rm_ec);
if (!contents_r) {
return std::unexpected(contents_r.error());
}
if (contents_r->exit_code != 0) {
return std::unexpected(err(
util::ErrorCode::ExecCommandFailed,
std::format("tea api contents failed (exit {}): {}",
contents_r->exit_code, contents_r->stderr_text)));
}
// Gitea returns API errors with HTTP 4xx; tea writes the error JSON
// to stdout with exit code 0. The `"errors"` array key only appears
// in error responses, never in success ones — use that as signal.
if (contents_r->stdout_text.find("\"errors\"") != std::string::npos) {
return std::unexpected(err(
util::ErrorCode::ExecCommandFailed,
std::format("Gitea contents API rejected the request: {}",
contents_r->stdout_text)));
}
// 8. Open the PR.
nlohmann::json pr_body = {
{"title", std::format("publish: {} {}", m->package.name, m->package.version)},
{"body",
std::format("Automated publish from `cargoxx publish` by @{}.\n\n"
"- source: `{}@{}`\n- sha256: `{}`\n",
*publisher, source_url, *head, *source_sha256)},
{"head", branch},
{"base", "master"},
};
auto pr_tmp = fs::temp_directory_path() /
std::format("cargoxx-pr-{}.json", std::random_device{}());
std::ofstream{pr_tmp} << pr_body.dump();
auto pr_r = exec::run(
"tea",
{"api", "-X", "POST", "-d", std::format("@{}", pr_tmp.string()),
std::format("/repos/{}/pulls", registry)},
exec::ExecOptions{
.cwd = project_root,
.env_overrides = {},
.timeout = std::chrono::seconds{60},
.inherit_stdio = false,
});
std::error_code ec;
fs::remove(pr_tmp, ec);
if (!pr_r) {
return std::unexpected(pr_r.error());
}
if (pr_r->exit_code != 0) {
return std::unexpected(err(
util::ErrorCode::ExecCommandFailed,
std::format("tea api /pulls failed (exit {}): {}",
pr_r->exit_code, pr_r->stderr_text)));
}
if (pr_r->stdout_text.find("\"errors\"") != std::string::npos) {
return std::unexpected(err(
util::ErrorCode::ExecCommandFailed,
std::format("Gitea /pulls API rejected the request: {}",
pr_r->stdout_text)));
}
// Parse the response properly — substring extraction is fooled by
// the nested `html_url` inside the `user`/`head`/`base` sub-objects.
std::optional<std::string> pr_url;
try {
auto j = nlohmann::json::parse(pr_r->stdout_text);
if (j.contains("html_url") && j["html_url"].is_string()) {
pr_url = j["html_url"].get<std::string>();
}
} catch (const nlohmann::json::exception&) {
// Leave pr_url empty; fall through to the placeholder message.
}
std::cout << std::format(" Opened {}\n",
pr_url.value_or("(PR URL not parsed)"));
return {};
}
} // namespace cargoxx::cli

View File

@@ -81,7 +81,7 @@ auto cmd_run(const fs::path& project_root, bool release,
} }
const std::string profile = release ? "release" : "debug"; const std::string profile = release ? "release" : "debug";
auto bin_path = project_root / "build" / profile / selected->name; auto bin_path = project_root / "build" / profile / "bin" / selected->name;
auto r = exec::run(bin_path.string(), args, auto r = exec::run(bin_path.string(), args,
exec::ExecOptions{ exec::ExecOptions{

View File

@@ -19,8 +19,9 @@ auto cmd_test(const fs::path& project_root, bool release,
const auto build_dir = std::format("build/{}", profile); const auto build_dir = std::format("build/{}", profile);
auto r = exec::run("nix", auto r = exec::run("nix",
{"develop", "--command", "ctest", "--test-dir", build_dir, {"--extra-experimental-features", "nix-command flakes",
"--output-on-failure"}, "develop", "path:./build", "--command", "ctest",
"--test-dir", build_dir, "--output-on-failure"},
exec::ExecOptions{ exec::ExecOptions{
.cwd = project_root, .cwd = project_root,
.env_overrides = {}, .env_overrides = {},

128
src/cli/cmd_vendor.cpp Normal file
View File

@@ -0,0 +1,128 @@
module cargoxx.cli;
import std;
import cargoxx.lockfile;
import cargoxx.resolver;
import cargoxx.util;
namespace cargoxx::cli {
namespace fs = std::filesystem;
namespace {
auto error(util::ErrorCode code, std::string msg, fs::path path) -> util::Error {
return util::Error{code, std::move(msg), "", std::move(path), std::nullopt};
}
auto escape_toml(std::string_view s) -> std::string {
std::string out;
out.reserve(s.size() + 2);
out += '"';
for (char c : s) {
if (c == '"' || c == '\\') {
out += '\\';
}
out += c;
}
out += '"';
return out;
}
} // namespace
auto cmd_vendor(const fs::path& project_root, const fs::path& output)
-> util::Result<void> {
auto lock_path = project_root / "Cargoxx.lock";
auto lock = lockfile::parse(lock_path);
if (!lock) {
return std::unexpected(lock.error());
}
if (!lock->nixpkgs_rev_pin || lock->nixpkgs_rev_pin->empty()) {
return std::unexpected(error(
util::ErrorCode::ManifestInvalidField,
"Cargoxx.lock has no top-level nixpkgs_rev — run cargoxx build "
"online first to pin it",
lock_path));
}
if (!lock->flake_utils_rev_pin || lock->flake_utils_rev_pin->empty()) {
return std::unexpected(error(
util::ErrorCode::ManifestInvalidField,
"Cargoxx.lock has no top-level flake_utils_rev",
lock_path));
}
auto nixpkgs_src = resolver::realize_flake_source(
std::format("github:NixOS/nixpkgs/{}", *lock->nixpkgs_rev_pin));
if (!nixpkgs_src) {
return std::unexpected(nixpkgs_src.error());
}
auto flake_utils_src = resolver::realize_flake_source(
std::format("github:numtide/flake-utils/{}", *lock->flake_utils_rev_pin));
if (!flake_utils_src) {
return std::unexpected(flake_utils_src.error());
}
std::string body;
body += "schema = 1\n\n";
body += "[nixpkgs]\n";
body += std::format("rev = {}\n", escape_toml(*lock->nixpkgs_rev_pin));
body += std::format("store_path = {}\n\n", escape_toml(*nixpkgs_src));
body += "[flake_utils]\n";
body += std::format("rev = {}\n", escape_toml(*lock->flake_utils_rev_pin));
body += std::format("store_path = {}\n", escape_toml(*flake_utils_src));
for (const auto& p : lock->packages) {
if (!p.linkdb_source) {
continue;
}
if (!p.nixpkgs_attr) {
return std::unexpected(error(
util::ErrorCode::ManifestInvalidField,
std::format("lockfile dep '{}' has no nixpkgs_attr", p.name),
lock_path));
}
auto rev = p.nixpkgs_rev.has_value() && !p.nixpkgs_rev->empty()
? *p.nixpkgs_rev
: *lock->nixpkgs_rev_pin;
auto store = resolver::realize_path_at_rev(rev, *p.nixpkgs_attr);
if (!store) {
return std::unexpected(store.error());
}
std::optional<std::string> dev_path;
if (auto d = resolver::realize_path_at_rev(
rev, std::format("{}.dev", *p.nixpkgs_attr));
d) {
dev_path = *d;
}
body += "\n[[dep]]\n";
body += std::format("name = {}\n", escape_toml(p.name));
body += std::format("nixpkgs_attr = {}\n", escape_toml(*p.nixpkgs_attr));
body += std::format("nixpkgs_rev = {}\n", escape_toml(rev));
body += std::format("store_path = {}\n", escape_toml(*store));
if (dev_path) {
body += std::format("dev_path = {}\n", escape_toml(*dev_path));
}
}
std::error_code ec;
fs::create_directories(output.parent_path(), ec);
std::ofstream out{output};
if (!out) {
return std::unexpected(error(
util::ErrorCode::Internal,
std::format("cannot open vendor file for writing: {}",
output.string()),
output));
}
out << body;
if (!out) {
return std::unexpected(error(
util::ErrorCode::Internal,
std::format("write failed: {}", output.string()), output));
}
return {};
}
} // namespace cargoxx::cli

View File

@@ -23,13 +23,32 @@ auto run(int argc, char** argv) -> int {
"build", "Generate flake.nix and build/CMakeLists.txt; build with nix+cmake"); "build", "Generate flake.nix and build/CMakeLists.txt; build with nix+cmake");
bool build_no_build = false; bool build_no_build = false;
bool build_release = false; bool build_release = false;
bool build_offline = false;
std::string build_target; std::string build_target;
std::string build_vendor;
build_cmd->add_flag("--no-build", build_no_build, build_cmd->add_flag("--no-build", build_no_build,
"Generate files only; do not invoke nix/cmake"); "Generate files only; do not invoke nix/cmake");
build_cmd->add_flag("--release", build_release, "Build the release profile"); build_cmd->add_flag("--release", build_release, "Build the release profile");
build_cmd->add_flag("--offline", build_offline,
"Skip network probes and nix develop wrappers. "
"Reads vendor.toml for store-path inputs.");
build_cmd->add_option("--vendor", build_vendor,
"Path to vendor.toml (used with --offline; default ./vendor.toml)");
build_cmd->add_option("--target", build_target, build_cmd->add_option("--target", build_target,
"Build a specific target (passed to cmake --build)"); "Build a specific target (passed to cmake --build)");
auto* vendor_cmd = app.add_subcommand(
"vendor", "Resolve every locked dependency into /nix/store and write vendor.toml");
std::string vendor_output;
vendor_cmd->add_option("--output", vendor_output,
"Path to write vendor.toml (default ./vendor.toml)");
auto* publish_cmd = app.add_subcommand(
"publish", "Publish the current HEAD as a new recipe in cargoxx-pkgs");
bool publish_dry_run = false;
publish_cmd->add_flag("--dry-run", publish_dry_run,
"Print the recipe TOML; skip all network ops");
auto* run_cmd = app.add_subcommand("run", "Build and run a binary target"); auto* run_cmd = app.add_subcommand("run", "Build and run a binary target");
bool run_release = false; bool run_release = false;
std::string run_bin; std::string run_bin;
@@ -58,6 +77,28 @@ auto run(int argc, char** argv) -> int {
std::string remove_name; std::string remove_name;
remove_cmd->add_option("name", remove_name, "Package name to remove")->required(); remove_cmd->add_option("name", remove_name, "Package name to remove")->required();
auto* linkdb_cmd =
app.add_subcommand("linkdb", "Manage the link database");
linkdb_cmd->require_subcommand(1);
auto* linkdb_add_cmd =
linkdb_cmd->add_subcommand("add", "Insert a manual recipe");
std::string ldb_package;
std::string ldb_version = "*";
std::string ldb_find_package;
std::string ldb_targets;
std::string ldb_nixpkgs_attr;
linkdb_add_cmd->add_option("package", ldb_package, "Package name")->required();
linkdb_add_cmd->add_option("--version", ldb_version, "Version range (default: *)");
linkdb_add_cmd->add_option("--find-package", ldb_find_package,
"Body of the find_package(...) call (e.g. \"fmt CONFIG REQUIRED\")")
->required();
linkdb_add_cmd->add_option("--targets", ldb_targets,
"Comma-separated CMake targets (e.g. fmt::fmt)")
->required();
linkdb_add_cmd->add_option("--nixpkgs-attr", ldb_nixpkgs_attr,
"nixpkgs attribute name (e.g. fmt_10)")
->required();
try { try {
app.parse(argc, argv); app.parse(argc, argv);
} catch (const CLI::ParseError& e) { } catch (const CLI::ParseError& e) {
@@ -87,19 +128,45 @@ auto run(int argc, char** argv) -> int {
if (!build_target.empty()) { if (!build_target.empty()) {
target = build_target; target = build_target;
} }
auto r = cmd_build(cwd, build_no_build, build_release, target); std::optional<std::filesystem::path> vendor_path;
if (!build_vendor.empty()) {
vendor_path = build_vendor;
}
auto r = cmd_build(cwd, build_no_build, build_release, target,
std::nullopt, build_offline, vendor_path);
if (!r) { if (!r) {
std::cerr << util::format(r.error()); std::cerr << util::format(r.error());
return 1; return 1;
} }
if (build_no_build) { if (build_no_build) {
std::cout << " Generated flake.nix, build/CMakeLists.txt, Cargoxx.lock\n"; std::cout << " Generated build/flake.nix, build/CMakeLists.txt, Cargoxx.lock\n";
} else { } else {
std::cout << " Built\n"; std::cout << " Built\n";
} }
return 0; return 0;
} }
if (*vendor_cmd) {
auto out = vendor_output.empty() ? cwd / "vendor.toml"
: std::filesystem::path{vendor_output};
auto r = cmd_vendor(cwd, out);
if (!r) {
std::cerr << util::format(r.error());
return 1;
}
std::cout << std::format(" Wrote {}\n", out.string());
return 0;
}
if (*publish_cmd) {
auto r = cmd_publish(cwd, publish_dry_run);
if (!r) {
std::cerr << util::format(r.error());
return 1;
}
return 0;
}
if (*run_cmd) { if (*run_cmd) {
std::optional<std::string> bin; std::optional<std::string> bin;
if (!run_bin.empty()) { if (!run_bin.empty()) {
@@ -175,6 +242,31 @@ auto run(int argc, char** argv) -> int {
return 0; return 0;
} }
if (*linkdb_add_cmd) {
std::vector<std::string> targets;
std::size_t pos = 0;
while (pos <= ldb_targets.size()) {
auto comma = ldb_targets.find(',', pos);
auto piece = ldb_targets.substr(
pos, comma == std::string::npos ? ldb_targets.size() - pos : comma - pos);
if (!piece.empty()) {
targets.push_back(std::move(piece));
}
if (comma == std::string::npos) {
break;
}
pos = comma + 1;
}
auto r = cmd_linkdb_add(ldb_package, ldb_version, ldb_find_package,
std::move(targets), ldb_nixpkgs_attr);
if (!r) {
std::cerr << util::format(r.error());
return 1;
}
std::cout << std::format(" Added manual recipe for {}\n", ldb_package);
return 0;
}
return 0; return 0;
} }

View File

@@ -64,7 +64,10 @@ auto emit_header(const manifest::Manifest& m) -> std::string {
"set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD \"d0edc3af-4c50-42ea-a356-e2862fe7a444\")\n" "set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD \"d0edc3af-4c50-42ea-a356-e2862fe7a444\")\n"
"set(CMAKE_CXX_MODULE_STD ON)\n" "set(CMAKE_CXX_MODULE_STD ON)\n"
"\n" "\n"
"project({} LANGUAGES CXX)\n" "project({} VERSION {} LANGUAGES CXX)\n"
"\n"
"include(GNUInstallDirs)\n"
"include(CMakePackageConfigHelpers)\n"
"\n" "\n"
"# Generated by cargoxx — do not edit.\n" "# Generated by cargoxx — do not edit.\n"
"# Source of truth: ../Cargoxx.toml\n" "# Source of truth: ../Cargoxx.toml\n"
@@ -75,8 +78,9 @@ auto emit_header(const manifest::Manifest& m) -> std::string {
"set(CMAKE_CXX_SCAN_FOR_MODULES ON)\n" "set(CMAKE_CXX_SCAN_FOR_MODULES ON)\n"
"set(CMAKE_EXPORT_COMPILE_COMMANDS ON)\n" "set(CMAKE_EXPORT_COMPILE_COMMANDS ON)\n"
"\n" "\n"
"add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)\n", "add_compile_options(-Wall -Wextra -Wpedantic -Wconversion "
m.package.name, edition_to_int(m.package.edition)); "-Wno-missing-field-initializers)\n",
m.package.name, m.package.version, edition_to_int(m.package.edition));
} }
auto emit_find_packages(const std::vector<linkdb::Recipe>& recipes, auto emit_find_packages(const std::vector<linkdb::Recipe>& recipes,
@@ -86,11 +90,60 @@ auto emit_find_packages(const std::vector<linkdb::Recipe>& recipes,
return {}; return {};
} }
std::string out = "\n# ----- dependencies -----\n"; std::string out = "\n# ----- dependencies -----\n";
for (const auto& r : recipes) {
bool pkgconfig_emitted = false;
auto emit_one = [&](const linkdb::Recipe& r) {
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;
}
std::string upper;
upper.reserve(r.pkg_config_module->size());
for (char c : *r.pkg_config_module) {
upper += std::isalnum(static_cast<unsigned char>(c))
? static_cast<char>(std::toupper(
static_cast<unsigned char>(c)))
: '_';
}
out += std::format("pkg_check_modules({} REQUIRED IMPORTED_TARGET {})\n",
upper, *r.pkg_config_module);
} else {
out += std::format("find_package({})\n", r.find_package); out += std::format("find_package({})\n", r.find_package);
} }
};
for (const auto& r : recipes) {
emit_one(r);
}
for (const auto& r : dev_recipes) { for (const auto& r : dev_recipes) {
out += std::format("find_package({})\n", r.find_package); emit_one(r);
} }
return out; return out;
} }
@@ -103,15 +156,71 @@ auto any_recipe_is_catch2(const std::vector<linkdb::Recipe>& dev_recipes) -> boo
return std::ranges::any_of(dev_recipes, recipe_is_catch2); return std::ranges::any_of(dev_recipes, recipe_is_catch2);
} }
auto emit_library_install_rules(const std::string& package_name) -> std::string {
// Installs the static archive + module FILE_SET, exports targets,
// generates Config.cmake + Version.cmake via configure_package_config_file,
// and writes a basic pkg-config descriptor. Inline file(WRITE …) keeps the
// .in templates self-contained in the generated CMakeLists.txt — no
// out-of-tree files to manage.
return std::format(
"\n# ----- install + package-config + pkg-config -----\n"
"install(TARGETS {0}\n"
" EXPORT {0}Targets\n"
" FILE_SET CXX_MODULES DESTINATION ${{CMAKE_INSTALL_INCLUDEDIR}}/{0}\n"
" ARCHIVE DESTINATION ${{CMAKE_INSTALL_LIBDIR}})\n"
"install(EXPORT {0}Targets\n"
" FILE {0}Targets.cmake\n"
" NAMESPACE {0}::\n"
" DESTINATION ${{CMAKE_INSTALL_LIBDIR}}/cmake/{0})\n"
"\n"
"file(WRITE ${{CMAKE_CURRENT_BINARY_DIR}}/{0}Config.cmake.in [[\n"
"@PACKAGE_INIT@\n"
"include(CMakeFindDependencyMacro)\n"
"include(\"${{CMAKE_CURRENT_LIST_DIR}}/{0}Targets.cmake\")\n"
"check_required_components({0})\n"
"]])\n"
"configure_package_config_file(\n"
" ${{CMAKE_CURRENT_BINARY_DIR}}/{0}Config.cmake.in\n"
" ${{CMAKE_CURRENT_BINARY_DIR}}/{0}Config.cmake\n"
" INSTALL_DESTINATION ${{CMAKE_INSTALL_LIBDIR}}/cmake/{0})\n"
"write_basic_package_version_file(\n"
" ${{CMAKE_CURRENT_BINARY_DIR}}/{0}ConfigVersion.cmake\n"
" VERSION ${{PROJECT_VERSION}}\n"
" COMPATIBILITY SameMajorVersion)\n"
"install(FILES\n"
" ${{CMAKE_CURRENT_BINARY_DIR}}/{0}Config.cmake\n"
" ${{CMAKE_CURRENT_BINARY_DIR}}/{0}ConfigVersion.cmake\n"
" DESTINATION ${{CMAKE_INSTALL_LIBDIR}}/cmake/{0})\n"
"\n"
"file(WRITE ${{CMAKE_CURRENT_BINARY_DIR}}/{0}.pc.in [[\n"
"prefix=@CMAKE_INSTALL_PREFIX@\n"
"exec_prefix=${{prefix}}\n"
"libdir=${{prefix}}/${{CMAKE_INSTALL_LIBDIR}}\n"
"includedir=${{prefix}}/${{CMAKE_INSTALL_INCLUDEDIR}}\n"
"\n"
"Name: @PROJECT_NAME@\n"
"Version: @PROJECT_VERSION@\n"
"Description: @PROJECT_NAME@\n"
"Cflags: -I${{includedir}}\n"
"Libs: -L${{libdir}} -l@PROJECT_NAME@\n"
"]])\n"
"configure_file(${{CMAKE_CURRENT_BINARY_DIR}}/{0}.pc.in\n"
" ${{CMAKE_CURRENT_BINARY_DIR}}/{0}.pc @ONLY)\n"
"install(FILES ${{CMAKE_CURRENT_BINARY_DIR}}/{0}.pc\n"
" DESTINATION ${{CMAKE_INSTALL_LIBDIR}}/pkgconfig)\n",
package_name);
}
auto emit_library(const layout::Target& lib, const std::string& package_name, auto emit_library(const layout::Target& lib, const std::string& package_name,
const std::vector<linkdb::Recipe>& recipes, const std::vector<linkdb::Recipe>& recipes,
const std::vector<std::string>& include_dirs, const std::vector<std::string>& include_dirs,
manifest::Edition edition,
const fs::path& project_root) -> std::string { const fs::path& project_root) -> std::string {
std::string out = "\n# ----- library target -----\n"; std::string out = "\n# ----- library target -----\n";
out += std::format("add_library({} STATIC)\n", package_name); out += std::format("add_library({} STATIC)\n", package_name);
out += std::format("target_sources({}\n", package_name); out += std::format("target_sources({}\n", package_name);
out += " PUBLIC\n"; out += " PUBLIC\n";
out += " FILE_SET CXX_MODULES FILES\n"; out += " FILE_SET CXX_MODULES BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/.. FILES\n";
for (const auto& m : lib.module_units) { for (const auto& m : lib.module_units) {
out += std::format(" {}\n", rel_to_build(m, project_root)); out += std::format(" {}\n", rel_to_build(m, project_root));
} }
@@ -122,6 +231,11 @@ auto emit_library(const layout::Target& lib, const std::string& package_name,
} }
} }
out += ")\n"; out += ")\n";
// PUBLIC cxx_std_NN propagates the standard requirement onto the
// exported IMPORTED target, so consumers `find_package`-ing this
// library get the right standard for module BMI regeneration.
out += std::format("target_compile_features({} PUBLIC cxx_std_{})\n",
package_name, edition_to_int(edition));
if (!include_dirs.empty()) { if (!include_dirs.empty()) {
out += std::format("target_include_directories({} SYSTEM PRIVATE", package_name); out += std::format("target_include_directories({} SYSTEM PRIVATE", package_name);
for (const auto& d : include_dirs) { for (const auto& d : include_dirs) {
@@ -131,6 +245,8 @@ auto emit_library(const layout::Target& lib, const std::string& package_name,
} }
out += link_block(package_name, "PUBLIC", false, package_name, out += link_block(package_name, "PUBLIC", false, package_name,
collect_dep_targets(recipes)); collect_dep_targets(recipes));
out += emit_library_install_rules(package_name);
return out; return out;
} }
@@ -140,10 +256,14 @@ auto emit_primary_binary(const layout::Target& bin, const std::string& package_n
std::string out = "\n# ----- binary target -----\n"; std::string out = "\n# ----- binary target -----\n";
out += std::format("add_executable({}_bin {})\n", package_name, out += std::format("add_executable({}_bin {})\n", package_name,
rel_to_build(bin.entry, project_root)); rel_to_build(bin.entry, project_root));
out += std::format("set_target_properties({}_bin PROPERTIES OUTPUT_NAME {})\n", out += std::format("set_target_properties({}_bin PROPERTIES\n"
" OUTPUT_NAME {}\n"
" RUNTIME_OUTPUT_DIRECTORY \"${{CMAKE_BINARY_DIR}}/bin\")\n",
package_name, package_name); package_name, package_name);
out += link_block(std::format("{}_bin", package_name), "PRIVATE", has_lib, package_name, out += link_block(std::format("{}_bin", package_name), "PRIVATE", has_lib, package_name,
collect_dep_targets(recipes)); collect_dep_targets(recipes));
out += std::format("install(TARGETS {}_bin RUNTIME DESTINATION ${{CMAKE_INSTALL_BINDIR}})\n",
package_name);
return out; return out;
} }
@@ -153,8 +273,13 @@ auto emit_extra_binary(const layout::Target& bin, const std::string& package_nam
std::string out = std::format("\n# ----- binary target: {} -----\n", bin.name); std::string out = std::format("\n# ----- binary target: {} -----\n", bin.name);
out += std::format("add_executable({} {})\n", bin.name, out += std::format("add_executable({} {})\n", bin.name,
rel_to_build(bin.entry, project_root)); rel_to_build(bin.entry, project_root));
out += std::format("set_target_properties({} PROPERTIES\n"
" RUNTIME_OUTPUT_DIRECTORY \"${{CMAKE_BINARY_DIR}}/bin\")\n",
bin.name);
out += link_block(bin.name, "PRIVATE", has_lib, package_name, out += link_block(bin.name, "PRIVATE", has_lib, package_name,
collect_dep_targets(recipes)); collect_dep_targets(recipes));
out += std::format("install(TARGETS {} RUNTIME DESTINATION ${{CMAKE_INSTALL_BINDIR}})\n",
bin.name);
return out; return out;
} }
@@ -285,7 +410,8 @@ auto cmake_lists(const GenerateInputs& in) -> std::string {
if (in.layout.library) { if (in.layout.library) {
out += emit_library(*in.layout.library, pkg_name, in.recipes, out += emit_library(*in.layout.library, pkg_name, in.recipes,
in.manifest.build.include_dirs, in.project_root); in.manifest.build.include_dirs,
in.manifest.package.edition, in.project_root);
} }
const auto* primary = find_primary_bin(in.layout); const auto* primary = find_primary_bin(in.layout);

View File

@@ -9,6 +9,16 @@ import cargoxx.lockfile;
export namespace cargoxx::codegen { export namespace cargoxx::codegen {
// When set, codegen emits the generated flake's nixpkgs / flake-utils
// inputs as `path:/nix/store/...` references (already-realized source
// paths) instead of `github:NixOS/nixpkgs/<rev>` URLs. This makes the
// inner build hermetic — no network and no nix daemon access required.
struct VendorIndex {
std::string nixpkgs_store_path;
std::string flake_utils_store_path;
std::unordered_map<std::string, std::string> dep_store_paths; // by attr
};
// All inputs the generators need. Held by const reference; the caller owns // All inputs the generators need. Held by const reference; the caller owns
// the underlying objects. Not copyable. // the underlying objects. Not copyable.
struct GenerateInputs { struct GenerateInputs {
@@ -18,9 +28,14 @@ struct GenerateInputs {
std::vector<linkdb::Recipe> recipes; // one per manifest dep, same order std::vector<linkdb::Recipe> recipes; // one per manifest dep, same order
std::vector<linkdb::Recipe> dev_recipes; // one per dev_dependency, same order std::vector<linkdb::Recipe> dev_recipes; // one per dev_dependency, same order
std::filesystem::path project_root; std::filesystem::path project_root;
std::optional<VendorIndex> vendor;
}; };
auto flake_nix(const GenerateInputs& in) -> std::string; auto flake_nix(const GenerateInputs& in) -> std::string;
auto cmake_lists(const GenerateInputs& in) -> std::string; auto cmake_lists(const GenerateInputs& in) -> std::string;
// Pure: parses a vendor.toml (see cmd_vendor) into a VendorIndex.
auto parse_vendor_toml(std::string_view body)
-> util::Result<VendorIndex>;
} // namespace cargoxx::codegen } // namespace cargoxx::codegen

View File

@@ -9,6 +9,9 @@ namespace cargoxx::codegen {
namespace { namespace {
constexpr std::string_view CARGOXX_PKGS_URL =
"git+https://git.amadey.xyz/mozart/cargoxx-pkgs";
// One pinned dep gets its own nixpkgs flake input. Unpinned deps stay // One pinned dep gets its own nixpkgs flake input. Unpinned deps stay
// on the shared `nixpkgs` input (which always tracks nixos-unstable). // on the shared `nixpkgs` input (which always tracks nixos-unstable).
struct DepBinding { struct DepBinding {
@@ -20,6 +23,15 @@ struct DepBinding {
std::optional<std::string> rev; // pinned commit (null → unpinned) std::optional<std::string> rev; // pinned commit (null → unpinned)
}; };
// Parallel to DepBinding for cargoxx-pkgs-resolved deps. All deps from
// cargoxx-pkgs share a single `cargoxx-pkgs` flake input pinned at
// `rev` — if a project ever needs multiple revs simultaneously, this
// can grow per-rev inputs the way DepBinding does for nixpkgs.
struct CargoxxPkgsBinding {
std::string attr; // e.g. "greeter_0_1_1"
std::string rev; // cargoxx-pkgs repo HEAD at lock time
};
// Replaces every char outside [a-zA-Z0-9_] with '_'. The result is safe // Replaces every char outside [a-zA-Z0-9_] with '_'. The result is safe
// to use as a Nix identifier (let bindings, lambda destructure params) // to use as a Nix identifier (let bindings, lambda destructure params)
// and as an attribute name (inputs.<attr>) — Nix permits underscores in // and as an attribute name (inputs.<attr>) — Nix permits underscores in
@@ -42,42 +54,73 @@ auto sanitize_input_attr(std::string_view name, std::string_view version)
return std::format("nixpkgs_{}_{}", sanitize(name), sanitize(version)); return std::format("nixpkgs_{}_{}", sanitize(name), sanitize(version));
} }
struct LockfileRef { struct LockfileEntry {
std::optional<std::string> rev; std::optional<std::string> nixpkgs_attr;
std::optional<std::string> attr; std::optional<std::string> nixpkgs_rev;
std::optional<std::string> cargoxx_pkgs_attr;
std::optional<std::string> cargoxx_pkgs_rev;
}; };
auto find_lockfile_ref(const lockfile::Lockfile& lock, const std::string& name, auto find_lockfile_entry(const lockfile::Lockfile& lock, const std::string& name,
const std::string& version) -> LockfileRef { const std::string& version) -> LockfileEntry {
for (const auto& p : lock.packages) { for (const auto& p : lock.packages) {
if (p.name == name && p.version == version) { if (p.name == name && p.version == version) {
return LockfileRef{.rev = p.nixpkgs_rev, .attr = p.nixpkgs_attr}; return LockfileEntry{
.nixpkgs_attr = p.nixpkgs_attr,
.nixpkgs_rev = p.nixpkgs_rev,
.cargoxx_pkgs_attr = p.cargoxx_pkgs_attr,
.cargoxx_pkgs_rev = p.cargoxx_pkgs_rev,
};
} }
} }
return LockfileRef{}; return {};
} }
auto build_bindings(const GenerateInputs& in) -> std::vector<DepBinding> { struct Bindings {
std::vector<DepBinding> out; std::vector<DepBinding> nixpkgs;
out.reserve(in.manifest.dependencies.size()); std::vector<CargoxxPkgsBinding> cargoxx_pkgs;
for (std::size_t i = 0; i < in.manifest.dependencies.size(); ++i) { };
const auto& dep = in.manifest.dependencies[i];
const auto& rec = in.recipes[i]; auto build_bindings(const GenerateInputs& in) -> Bindings {
auto ref = find_lockfile_ref(in.lock, dep.name, dep.version_spec); Bindings out;
// For pinned deps the lockfile's nixpkgs_attr is authoritative auto push = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) {
// (it came from devbox's attr_paths for this specific rev). The // cargoxx-source deps (path/git) don't live in nixpkgs — they're
// curated recipe's attr only applies to nixos-unstable, so it's // produced by a recursive buildCppPackage invocation when the
// wrong when the dep pulls from a different rev. // consumer is built via `nix build`. Emitting them here would
std::string attr = (ref.attr && !ref.attr->empty()) ? *ref.attr // generate `pkgs.` (empty attr) in the devshell flake. Skip them;
// the cargoxx-build-direct path will pick them up via a separate
// pre-build resolution step in a follow-up.
if (dep.source == manifest::DepSource::CargoxxPath
|| dep.source == manifest::DepSource::CargoxxGit) {
return;
}
auto entry = find_lockfile_entry(in.lock, dep.name, dep.version_spec);
// cargoxx-pkgs resolution wins if the lockfile records it.
if (entry.cargoxx_pkgs_attr && !entry.cargoxx_pkgs_attr->empty()
&& entry.cargoxx_pkgs_rev && !entry.cargoxx_pkgs_rev->empty()) {
out.cargoxx_pkgs.push_back(CargoxxPkgsBinding{
.attr = *entry.cargoxx_pkgs_attr,
.rev = *entry.cargoxx_pkgs_rev,
});
return;
}
std::string attr =
(entry.nixpkgs_attr && !entry.nixpkgs_attr->empty())
? *entry.nixpkgs_attr
: rec.nixpkgs_attr; : rec.nixpkgs_attr;
DepBinding b{ out.nixpkgs.push_back(DepBinding{
.name = dep.name, .name = dep.name,
.version = dep.version_spec, .version = dep.version_spec,
.nixpkgs_attr = std::move(attr), .nixpkgs_attr = std::move(attr),
.sanitized = sanitize_input_attr(dep.name, dep.version_spec), .sanitized = sanitize_input_attr(dep.name, dep.version_spec),
.rev = std::move(ref.rev), .rev = entry.nixpkgs_rev,
});
}; };
out.push_back(std::move(b)); for (std::size_t i = 0; i < in.manifest.dependencies.size(); ++i) {
push(in.manifest.dependencies[i], in.recipes[i]);
}
for (std::size_t i = 0; i < in.manifest.dev_dependencies.size(); ++i) {
push(in.manifest.dev_dependencies[i], in.dev_recipes[i]);
} }
return out; return out;
} }
@@ -100,30 +143,70 @@ auto pinned_inputs_dedup(const std::vector<DepBinding>& bindings)
return out; return out;
} }
auto emit_inputs_block(const std::vector<const DepBinding*>& pinned) // All cargoxx-pkgs deps share a single `cargoxx-pkgs` flake input.
// Picks the first rev seen; if revs diverge across deps the project
// is mid-migration and the user should re-run `cargoxx add` for each
// stale dep to align them.
auto cargoxx_pkgs_rev(const std::vector<CargoxxPkgsBinding>& bs)
-> std::optional<std::string> {
if (bs.empty()) {
return std::nullopt;
}
return bs.front().rev;
}
auto emit_inputs_block(const std::vector<const DepBinding*>& pinned,
const std::optional<std::string>& cargoxx_pkgs_rev,
const lockfile::Lockfile& lock,
const std::optional<VendorIndex>& vendor)
-> std::string { -> std::string {
// Always emit the shared toolchain `nixpkgs` and `flake-utils` auto nixpkgs_url = [&]() -> std::string {
// inputs. Per-pinned-dep inputs land between them so the output if (vendor && !vendor->nixpkgs_store_path.empty()) {
// diff stays stable across reruns. return std::format("path:{}", vendor->nixpkgs_store_path);
}
if (lock.nixpkgs_rev_pin && !lock.nixpkgs_rev_pin->empty()) {
return std::format("github:NixOS/nixpkgs/{}", *lock.nixpkgs_rev_pin);
}
return "github:NixOS/nixpkgs/nixos-unstable";
}();
auto flake_utils_url = [&]() -> std::string {
if (vendor && !vendor->flake_utils_store_path.empty()) {
return std::format("path:{}", vendor->flake_utils_store_path);
}
if (lock.flake_utils_rev_pin && !lock.flake_utils_rev_pin->empty()) {
return std::format("github:numtide/flake-utils/{}",
*lock.flake_utils_rev_pin);
}
return "github:numtide/flake-utils";
}();
std::string out = std::string out =
" inputs = {\n" " inputs = {\n"
" nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n"; + std::format(" nixpkgs.url = \"{}\";\n", nixpkgs_url);
if (!vendor) {
for (const auto* b : pinned) { for (const auto* b : pinned) {
out += std::format(" {}.url = \"github:NixOS/nixpkgs/{}\";\n", out += std::format(" {}.url = \"github:NixOS/nixpkgs/{}\";\n",
b->sanitized, *b->rev); b->sanitized, *b->rev);
} }
out += " flake-utils.url = \"github:numtide/flake-utils\";\n" }
" };\n"; if (cargoxx_pkgs_rev) {
out += std::format(" cargoxx-pkgs.url = \"{}?rev={}\";\n",
CARGOXX_PKGS_URL, *cargoxx_pkgs_rev);
}
out += std::format(" flake-utils.url = \"{}\";\n", flake_utils_url);
out += " };\n";
return out; return out;
} }
auto emit_outputs_params(const std::vector<const DepBinding*>& pinned) auto emit_outputs_params(const std::vector<const DepBinding*>& pinned,
-> std::string { bool any_cargoxx_pkgs) -> std::string {
std::string out = "{ self, nixpkgs"; std::string out = "{ self, nixpkgs";
for (const auto* b : pinned) { for (const auto* b : pinned) {
out += ", "; out += ", ";
out += b->sanitized; out += b->sanitized;
} }
if (any_cargoxx_pkgs) {
out += ", cargoxx-pkgs";
}
out += ", flake-utils }"; out += ", flake-utils }";
return out; return out;
} }
@@ -144,15 +227,23 @@ auto base_expr(const DepBinding& b) -> std::string {
: std::format("pkgs.{}", b.nixpkgs_attr); : std::format("pkgs.{}", b.nixpkgs_attr);
} }
auto emit_build_inputs(const std::vector<DepBinding>& bindings) -> std::string { auto emit_build_inputs(const std::vector<DepBinding>& nixpkgs_bs,
const std::vector<CargoxxPkgsBinding>& cxx_bs)
-> std::string {
std::set<std::string> seen; std::set<std::string> seen;
std::string out; std::string out;
for (const auto& b : bindings) { for (const auto& b : nixpkgs_bs) {
auto expr = base_expr(b); auto expr = base_expr(b);
if (seen.insert(expr).second) { if (seen.insert(expr).second) {
out += std::format(" {}\n", expr); out += std::format(" {}\n", expr);
} }
} }
for (const auto& b : cxx_bs) {
auto expr = std::format("cargoxx-pkgs.packages.${{system}}.{}", b.attr);
if (seen.insert(expr).second) {
out += std::format(" {}\n", expr);
}
}
return out; return out;
} }
@@ -160,17 +251,30 @@ auto emit_build_inputs(const std::vector<DepBinding>& bindings) -> std::string {
auto flake_nix(const GenerateInputs& in) -> std::string { auto flake_nix(const GenerateInputs& in) -> std::string {
auto bindings = build_bindings(in); auto bindings = build_bindings(in);
auto pinned = pinned_inputs_dedup(bindings); auto pinned = pinned_inputs_dedup(bindings.nixpkgs);
auto cxx_rev = cargoxx_pkgs_rev(bindings.cargoxx_pkgs);
std::string out; std::string out;
out += "{\n"; out += "{\n";
out += std::format(" description = \"{}\";\n\n", in.manifest.package.name); out += std::format(" description = \"{}\";\n\n", in.manifest.package.name);
out += emit_inputs_block(pinned); out += emit_inputs_block(pinned, cxx_rev, in.lock, in.vendor);
const bool any_pkg_config =
std::ranges::any_of(in.recipes,
[](const linkdb::Recipe& r) {
return r.pkg_config_module &&
!r.pkg_config_module->empty();
}) ||
std::ranges::any_of(in.dev_recipes,
[](const linkdb::Recipe& r) {
return r.pkg_config_module &&
!r.pkg_config_module->empty();
});
out += "\n"; out += "\n";
out += " outputs = "; out += " outputs = ";
out += emit_outputs_params(pinned); out += emit_outputs_params(pinned, cxx_rev.has_value());
out += ":\n" out += ":\n"
" flake-utils.lib.eachDefaultSystem (system:\n" " flake-utils.lib.eachDefaultSystem (system:\n"
" let\n" " let\n"
@@ -182,10 +286,13 @@ auto flake_nix(const GenerateInputs& in) -> std::string {
" version = \"1.0\";\n" " version = \"1.0\";\n"
" nativeBuildInputs = [\n" " nativeBuildInputs = [\n"
" pkgs.ninja\n" " pkgs.ninja\n"
" pkgs.cmake\n" " pkgs.cmake\n";
" ];\n" if (any_pkg_config) {
out += " pkgs.pkg-config\n";
}
out += " ];\n"
" buildInputs = [\n"; " buildInputs = [\n";
out += emit_build_inputs(bindings); out += emit_build_inputs(bindings.nixpkgs, bindings.cargoxx_pkgs);
out += " ];\n" out += " ];\n"
" hardeningDisable = [\n" " hardeningDisable = [\n"
" \"all\"\n" " \"all\"\n"

69
src/codegen/vendor.cpp Normal file
View File

@@ -0,0 +1,69 @@
module;
#include <toml.hpp>
module cargoxx.codegen;
import std;
import cargoxx.util;
namespace cargoxx::codegen {
auto parse_vendor_toml(std::string_view body) -> util::Result<VendorIndex> {
toml::table root;
try {
root = toml::parse(body);
} catch (const toml::parse_error& e) {
return std::unexpected(util::Error{
util::ErrorCode::ManifestParseError,
std::format("vendor.toml is not valid TOML: {}", e.description()),
"", std::nullopt, std::nullopt,
});
}
auto missing = [](std::string msg) {
return util::Error{
util::ErrorCode::ManifestInvalidField, std::move(msg), "",
std::nullopt, std::nullopt,
};
};
VendorIndex out;
if (const auto* tbl = root["nixpkgs"].as_table()) {
if (auto v = (*tbl)["store_path"].value<std::string>()) {
out.nixpkgs_store_path = *v;
} else {
return std::unexpected(missing("vendor.toml: [nixpkgs].store_path is required"));
}
} else {
return std::unexpected(missing("vendor.toml: [nixpkgs] table is required"));
}
if (const auto* tbl = root["flake_utils"].as_table()) {
if (auto v = (*tbl)["store_path"].value<std::string>()) {
out.flake_utils_store_path = *v;
} else {
return std::unexpected(missing("vendor.toml: [flake_utils].store_path is required"));
}
} else {
return std::unexpected(missing("vendor.toml: [flake_utils] table is required"));
}
if (const auto* arr = root["dep"].as_array()) {
for (const auto& el : *arr) {
const auto* tbl = el.as_table();
if (!tbl) {
return std::unexpected(missing("vendor.toml: [[dep]] entries must be tables"));
}
auto attr = (*tbl)["nixpkgs_attr"].value<std::string>();
auto path = (*tbl)["store_path"].value<std::string>();
if (!attr || !path) {
return std::unexpected(missing(
"vendor.toml: each [[dep]] needs nixpkgs_attr and store_path"));
}
out.dep_store_paths.emplace(*attr, *path);
}
}
return out;
}
} // namespace cargoxx::codegen

View File

@@ -104,6 +104,31 @@ auto discover(const fs::path& project_root, const std::string& package_name)
.module_units = {}, .module_units = {},
}); });
} }
// Cargo's src/bin/<name>/main.cpp form: a subdirectory of src/bin/
// whose entry point is its own main.cpp. v1 ships the entry only —
// sibling .cpp files in the subdir are not collected.
if (fs::exists(bin_dir, ec) && !ec) {
std::vector<fs::path> subdir_mains;
for (const auto& entry : fs::directory_iterator{bin_dir}) {
if (!entry.is_directory()) {
continue;
}
auto candidate = entry.path() / "main.cpp";
if (fs::exists(candidate, ec) && !ec) {
subdir_mains.push_back(candidate);
}
}
std::ranges::sort(subdir_mains);
for (const auto& m : subdir_mains) {
out.binaries.push_back(Target{
.kind = TargetKind::Binary,
.name = m.parent_path().filename().string(),
.entry = m,
.additional_sources = {},
.module_units = {},
});
}
}
std::ranges::sort(out.binaries, by_name); std::ranges::sort(out.binaries, by_name);
for (const auto& f : top_level_cpp(tests_dir)) { for (const auto& f : top_level_cpp(tests_dir)) {

View File

@@ -64,6 +64,9 @@ auto Database::resolve(const std::string& package, const std::string& version,
.find_package = row.find_package, .find_package = row.find_package,
.targets = row.targets, .targets = row.targets,
.source = row.source, .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{ return std::unexpected(util::Error{

View File

@@ -15,6 +15,24 @@ struct Recipe {
std::vector<std::string> targets; // post-substitution std::vector<std::string> targets; // post-substitution
std::string source; // 'curated' | 'manual' | etc. std::string source; // 'curated' | 'manual' | etc.
// When set, the dep is consumed via PkgConfig instead of a normal
// find_package. Codegen emits
// find_package(PkgConfig REQUIRED)
// pkg_check_modules(<UPPER> REQUIRED IMPORTED_TARGET <pkg_config_module>)
// and the recipe's `targets` list holds the synthesized
// PkgConfig::<UPPER> entries. `find_package` for these recipes is
// the literal string "PkgConfig REQUIRED" — kept consistent so
// 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; bool operator==(const Recipe&) const = default;
}; };
@@ -29,6 +47,9 @@ struct OverlayRow {
std::vector<std::string> targets; std::vector<std::string> targets;
std::string source; std::string source;
std::int64_t verified_at = 0; 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. // RAII wrapper for an open sqlite3 connection used by the overlay database.

View File

@@ -90,6 +90,42 @@ auto overlay_open(const std::filesystem::path& path)
}); });
} }
// 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 {};
};
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; return state;
} }
@@ -99,8 +135,8 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
constexpr const char* SQL = constexpr const char* SQL =
"INSERT OR REPLACE INTO recipes " "INSERT OR REPLACE INTO recipes "
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, " "(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
" verified_at) " " verified_at, pkg_config_module, brute_force_libs, brute_force_includes) "
"VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?)"; "VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?, ?, ?, ?)";
sqlite3* db = state.handle(); sqlite3* db = state.handle();
sqlite3_stmt* stmt = nullptr; sqlite3_stmt* stmt = nullptr;
@@ -109,6 +145,8 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
} }
auto targets_str = nlohmann::json(r.targets).dump(); 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>( auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()) std::chrono::system_clock::now().time_since_epoch())
.count(); .count();
@@ -119,6 +157,13 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
sqlite3_bind_text(stmt, 4, r.find_package.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 4, r.find_package.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 6, now); sqlite3_bind_int64(stmt, 6, now);
if (r.pkg_config_module) {
sqlite3_bind_text(stmt, 7, r.pkg_config_module->c_str(), -1, SQLITE_TRANSIENT);
} 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); auto rc = sqlite3_step(stmt);
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
@@ -137,8 +182,8 @@ auto overlay_insert(OverlayState& state, const std::string& package,
constexpr const char* SQL = constexpr const char* SQL =
"INSERT OR REPLACE INTO recipes " "INSERT OR REPLACE INTO recipes "
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, " "(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
" verified_at) " " verified_at, pkg_config_module, brute_force_libs, brute_force_includes) "
"VALUES (?, ?, ?, ?, ?, NULL, ?, ?)"; "VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)";
sqlite3* db = state.handle(); sqlite3* db = state.handle();
sqlite3_stmt* stmt = nullptr; sqlite3_stmt* stmt = nullptr;
@@ -147,6 +192,8 @@ auto overlay_insert(OverlayState& state, const std::string& package,
} }
auto targets_str = nlohmann::json(r.targets).dump(); 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, 1, package.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, version_range.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, version_range.c_str(), -1, SQLITE_TRANSIENT);
@@ -155,6 +202,13 @@ auto overlay_insert(OverlayState& state, const std::string& package,
sqlite3_bind_text(stmt, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 6, source.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 6, source.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 7, verified_at); sqlite3_bind_int64(stmt, 7, verified_at);
if (r.pkg_config_module) {
sqlite3_bind_text(stmt, 8, r.pkg_config_module->c_str(), -1, SQLITE_TRANSIENT);
} 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); auto rc = sqlite3_step(stmt);
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
@@ -245,7 +299,8 @@ auto overlay_evict_auto(OverlayState& state, const std::string& package)
auto overlay_query(OverlayState& state, const std::string& package) auto overlay_query(OverlayState& state, const std::string& package)
-> util::Result<std::vector<OverlayRow>> { -> util::Result<std::vector<OverlayRow>> {
constexpr const char* SQL = constexpr const char* SQL =
"SELECT version_range, nixpkgs_attr, find_package, targets, source, verified_at " "SELECT version_range, nixpkgs_attr, find_package, targets, source, verified_at, "
" pkg_config_module, brute_force_libs, brute_force_includes "
"FROM recipes WHERE package = ?"; "FROM recipes WHERE package = ?";
sqlite3* db = state.handle(); sqlite3* db = state.handle();
@@ -276,6 +331,26 @@ auto overlay_query(OverlayState& state, const std::string& package)
} }
row.source = column_text(stmt, 4); row.source = column_text(stmt, 4);
row.verified_at = sqlite3_column_int64(stmt, 5); row.verified_at = sqlite3_column_int64(stmt, 5);
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)); out.push_back(std::move(row));
} }
sqlite3_finalize(stmt); sqlite3_finalize(stmt);

View File

@@ -76,9 +76,57 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path)
if (auto v = tbl["nixpkgs_rev"].value<std::string>()) { if (auto v = tbl["nixpkgs_rev"].value<std::string>()) {
pkg.nixpkgs_rev = *v; pkg.nixpkgs_rev = *v;
} }
if (auto v = tbl["cargoxx_pkgs_attr"].value<std::string>()) {
pkg.cargoxx_pkgs_attr = *v;
}
if (auto v = tbl["cargoxx_pkgs_rev"].value<std::string>()) {
pkg.cargoxx_pkgs_rev = *v;
}
if (auto v = tbl["linkdb_source"].value<std::string>()) { if (auto v = tbl["linkdb_source"].value<std::string>()) {
pkg.linkdb_source = *v; pkg.linkdb_source = *v;
} }
if (auto v = tbl["find_package"].value<std::string>()) {
pkg.find_package = *v;
}
if (const auto* arr = tbl["targets"].as_array()) {
auto r = extract_string_array(*arr, "targets", path);
if (!r) {
return std::unexpected(r.error());
}
pkg.targets = std::move(*r);
}
if (auto v = tbl["pkg_config_module"].value<std::string>()) {
pkg.pkg_config_module = *v;
}
if (const auto* arr = tbl["brute_force_libs"].as_array()) {
auto r = extract_string_array(*arr, "brute_force_libs", path);
if (!r) {
return std::unexpected(r.error());
}
pkg.brute_force_libs = std::move(*r);
}
if (const auto* arr = tbl["brute_force_includes"].as_array()) {
auto r = extract_string_array(*arr, "brute_force_includes", path);
if (!r) {
return std::unexpected(r.error());
}
pkg.brute_force_includes = std::move(*r);
}
if (auto v = tbl["source_kind"].value<std::string>()) {
pkg.source_kind = *v;
}
if (auto v = tbl["source_path"].value<std::string>()) {
pkg.source_path = *v;
}
if (auto v = tbl["source_git_url"].value<std::string>()) {
pkg.source_git_url = *v;
}
if (auto v = tbl["source_git_commit"].value<std::string>()) {
pkg.source_git_commit = *v;
}
if (auto v = tbl["source_git_sha256"].value<std::string>()) {
pkg.source_git_sha256 = *v;
}
return pkg; return pkg;
} }
@@ -104,6 +152,12 @@ auto parse(const std::filesystem::path& path) -> util::Result<Lockfile> {
if (auto v = root["version"].value<int>()) { if (auto v = root["version"].value<int>()) {
lock.version = *v; lock.version = *v;
} }
if (auto v = root["nixpkgs_rev"].value<std::string>()) {
lock.nixpkgs_rev_pin = *v;
}
if (auto v = root["flake_utils_rev"].value<std::string>()) {
lock.flake_utils_rev_pin = *v;
}
if (const auto* arr = root["package"].as_array()) { if (const auto* arr = root["package"].as_array()) {
lock.packages.reserve(arr->size()); lock.packages.reserve(arr->size());
@@ -127,6 +181,12 @@ auto parse(const std::filesystem::path& path) -> util::Result<Lockfile> {
auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Result<void> { auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Result<void> {
toml::table root; toml::table root;
root.insert_or_assign("version", lock.version); root.insert_or_assign("version", lock.version);
if (lock.nixpkgs_rev_pin) {
root.insert_or_assign("nixpkgs_rev", *lock.nixpkgs_rev_pin);
}
if (lock.flake_utils_rev_pin) {
root.insert_or_assign("flake_utils_rev", *lock.flake_utils_rev_pin);
}
toml::array packages; toml::array packages;
for (const auto& p : lock.packages) { for (const auto& p : lock.packages) {
@@ -146,9 +206,57 @@ auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Res
if (p.nixpkgs_rev) { if (p.nixpkgs_rev) {
tbl.insert_or_assign("nixpkgs_rev", *p.nixpkgs_rev); tbl.insert_or_assign("nixpkgs_rev", *p.nixpkgs_rev);
} }
if (p.cargoxx_pkgs_attr) {
tbl.insert_or_assign("cargoxx_pkgs_attr", *p.cargoxx_pkgs_attr);
}
if (p.cargoxx_pkgs_rev) {
tbl.insert_or_assign("cargoxx_pkgs_rev", *p.cargoxx_pkgs_rev);
}
if (p.linkdb_source) { if (p.linkdb_source) {
tbl.insert_or_assign("linkdb_source", *p.linkdb_source); tbl.insert_or_assign("linkdb_source", *p.linkdb_source);
} }
if (p.find_package) {
tbl.insert_or_assign("find_package", *p.find_package);
}
if (!p.targets.empty()) {
toml::array arr;
for (const auto& t : p.targets) {
arr.push_back(t);
}
tbl.insert_or_assign("targets", std::move(arr));
}
if (p.pkg_config_module) {
tbl.insert_or_assign("pkg_config_module", *p.pkg_config_module);
}
if (!p.brute_force_libs.empty()) {
toml::array arr;
for (const auto& l : p.brute_force_libs) {
arr.push_back(l);
}
tbl.insert_or_assign("brute_force_libs", std::move(arr));
}
if (!p.brute_force_includes.empty()) {
toml::array arr;
for (const auto& i : p.brute_force_includes) {
arr.push_back(i);
}
tbl.insert_or_assign("brute_force_includes", std::move(arr));
}
if (p.source_kind) {
tbl.insert_or_assign("source_kind", *p.source_kind);
}
if (p.source_path) {
tbl.insert_or_assign("source_path", *p.source_path);
}
if (p.source_git_url) {
tbl.insert_or_assign("source_git_url", *p.source_git_url);
}
if (p.source_git_commit) {
tbl.insert_or_assign("source_git_commit", *p.source_git_commit);
}
if (p.source_git_sha256) {
tbl.insert_or_assign("source_git_sha256", *p.source_git_sha256);
}
packages.push_back(std::move(tbl)); packages.push_back(std::move(tbl));
} }
root.insert_or_assign("package", std::move(packages)); root.insert_or_assign("package", std::move(packages));

View File

@@ -9,22 +9,42 @@ export namespace cargoxx::lockfile {
struct LockfilePackage { struct LockfilePackage {
std::string name; std::string name;
std::string version; std::string version;
std::vector<std::string> dependencies; // "<name> <version>" entries; non-empty for the root std::vector<std::string> dependencies;
std::optional<std::string> nixpkgs_attr; std::optional<std::string> nixpkgs_attr;
std::optional<std::string> nixpkgs_rev; std::optional<std::string> nixpkgs_rev;
// Parallel to nixpkgs_attr/rev: the dep was resolved through the
// cargoxx-pkgs flake (https://git.amadey.xyz/mozart/cargoxx-pkgs).
// `cargoxx_pkgs_attr` is the attribute name in
// `cargoxx-pkgs.packages.<system>` (e.g. "greeter_0_1_1"), and
// `cargoxx_pkgs_rev` pins the cargoxx-pkgs repo itself.
std::optional<std::string> cargoxx_pkgs_attr;
std::optional<std::string> cargoxx_pkgs_rev;
std::optional<std::string> linkdb_source; std::optional<std::string> linkdb_source;
std::optional<std::string> find_package;
std::vector<std::string> targets;
std::optional<std::string> pkg_config_module;
std::vector<std::string> brute_force_libs;
std::vector<std::string> brute_force_includes;
// For cargoxx-source deps (not nixpkgs/linkdb-resolved).
// "cargoxx-path" → source_path only
// "cargoxx-git" → source_git_url + source_git_commit + source_git_sha256
std::optional<std::string> source_kind;
std::optional<std::string> source_path;
std::optional<std::string> source_git_url;
std::optional<std::string> source_git_commit;
std::optional<std::string> source_git_sha256;
bool operator==(const LockfilePackage&) const = default; bool operator==(const LockfilePackage&) const = default;
}; };
struct Lockfile { struct Lockfile {
int version = 1; int version = 1;
std::optional<std::string> nixpkgs_rev_pin;
std::optional<std::string> flake_utils_rev_pin;
std::vector<LockfilePackage> packages; std::vector<LockfilePackage> packages;
bool operator==(const Lockfile&) const = default; bool operator==(const Lockfile&) const = default;
// The nixpkgs revision is shared across every dep package per SPEC §5.
// Returns the first non-empty rev seen, or nullopt if no deps are pinned.
[[nodiscard]] auto nixpkgs_rev() const -> std::optional<std::string>; [[nodiscard]] auto nixpkgs_rev() const -> std::optional<std::string>;
}; };

View File

@@ -5,10 +5,20 @@ import cargoxx.util;
export namespace cargoxx::manifest { export namespace cargoxx::manifest {
enum class DepSource {
Auto, // string form or { version = ... } only → existing resolver chain
CargoxxPath, // { path = "../foo" } → recursive cargoxx build
CargoxxGit, // { git = "...", rev = "..." } → fetch + recursive cargoxx build
};
struct Dependency { struct Dependency {
std::string name; std::string name;
std::string version_spec; std::string version_spec;
std::vector<std::string> components; std::vector<std::string> components;
DepSource source = DepSource::Auto;
std::optional<std::string> path; // when source == CargoxxPath
std::optional<std::string> git_url; // when source == CargoxxGit
std::optional<std::string> git_rev; // when source == CargoxxGit (40-char)
bool operator==(const Dependency&) const = default; bool operator==(const Dependency&) const = default;
}; };
@@ -29,6 +39,9 @@ struct Package {
Edition edition = Edition::Cpp23; Edition edition = Edition::Cpp23;
std::vector<std::string> authors; std::vector<std::string> authors;
std::optional<std::string> license; std::optional<std::string> license;
std::optional<std::string> description;
std::optional<std::string> repository;
std::optional<std::string> homepage;
bool operator==(const Package&) const = default; bool operator==(const Package&) const = default;
}; };

View File

@@ -73,6 +73,7 @@ auto extract_string_array(const toml::array& arr, std::string_view field,
constexpr std::array PACKAGE_KNOWN_KEYS = { constexpr std::array PACKAGE_KNOWN_KEYS = {
"name", "version", "edition", "authors", "license", "description", "repository", "name", "version", "edition", "authors", "license", "description", "repository",
"homepage",
}; };
constexpr std::array BUILD_KNOWN_KEYS = { constexpr std::array BUILD_KNOWN_KEYS = {
@@ -138,6 +139,15 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path)
if (auto license = tbl["license"].value<std::string>()) { if (auto license = tbl["license"].value<std::string>()) {
pkg.license = *license; pkg.license = *license;
} }
if (auto v = tbl["description"].value<std::string>()) {
pkg.description = *v;
}
if (auto v = tbl["repository"].value<std::string>()) {
pkg.repository = *v;
}
if (auto v = tbl["homepage"].value<std::string>()) {
pkg.homepage = *v;
}
return pkg; return pkg;
} }
@@ -153,12 +163,49 @@ auto parse_dependency(std::string name, const toml::node& value,
} }
if (const auto* tbl = value.as_table()) { if (const auto* tbl = value.as_table()) {
// Path form: { path = "../foo" } → cargoxx-source dep.
if (auto path_str = (*tbl)["path"].value<std::string>()) {
dep.source = DepSource::CargoxxPath;
dep.path = *path_str;
if (auto v = (*tbl)["version"].value<std::string>()) {
dep.version_spec = *v;
} else {
dep.version_spec = "*";
}
return dep;
}
// Git form: { git = "...", rev = "<40-char>" } → cargoxx-source dep
// fetched at the pinned commit. Branch/tag resolution is not yet
// supported; require a literal commit.
if (auto git_url = (*tbl)["git"].value<std::string>()) {
auto rev = (*tbl)["rev"].value<std::string>();
if (!rev) {
return std::unexpected(err(
ErrorCode::ManifestInvalidField,
std::format("git dependency '{}' must specify a 'rev' (40-char commit)",
dep.name),
path, source_pos(value)));
}
dep.source = DepSource::CargoxxGit;
dep.git_url = *git_url;
dep.git_rev = *rev;
if (auto v = (*tbl)["version"].value<std::string>()) {
dep.version_spec = *v;
} else {
dep.version_spec = "*";
}
return dep;
}
if (auto v = (*tbl)["version"].value<std::string>()) { if (auto v = (*tbl)["version"].value<std::string>()) {
dep.version_spec = *v; dep.version_spec = *v;
} else { } else {
return std::unexpected(err( return std::unexpected(err(
ErrorCode::ManifestInvalidField, ErrorCode::ManifestInvalidField,
std::format("dependency '{}' table must have a 'version' string", dep.name), std::format(
"dependency '{}' table must have one of: 'version', 'path', 'git'",
dep.name),
path, source_pos(value))); path, source_pos(value)));
} }
if (const auto* comps = (*tbl)["components"].as_array()) { if (const auto* comps = (*tbl)["components"].as_array()) {

View File

@@ -44,12 +44,32 @@ auto build_table(const Manifest& m) -> toml::table {
if (m.package.license) { if (m.package.license) {
package.insert_or_assign("license", *m.package.license); package.insert_or_assign("license", *m.package.license);
} }
if (m.package.description) {
package.insert_or_assign("description", *m.package.description);
}
if (m.package.repository) {
package.insert_or_assign("repository", *m.package.repository);
}
if (m.package.homepage) {
package.insert_or_assign("homepage", *m.package.homepage);
}
root.insert_or_assign("package", std::move(package)); root.insert_or_assign("package", std::move(package));
auto deps_to_table = [](const std::vector<Dependency>& deps) { auto deps_to_table = [](const std::vector<Dependency>& deps) {
toml::table out; toml::table out;
for (const auto& dep : deps) { for (const auto& dep : deps) {
if (dep.components.empty()) { if (dep.source == DepSource::CargoxxPath) {
toml::table dep_tbl;
dep_tbl.insert_or_assign("path", *dep.path);
dep_tbl.is_inline(true);
out.insert_or_assign(dep.name, std::move(dep_tbl));
} else if (dep.source == DepSource::CargoxxGit) {
toml::table dep_tbl;
dep_tbl.insert_or_assign("git", *dep.git_url);
dep_tbl.insert_or_assign("rev", *dep.git_rev);
dep_tbl.is_inline(true);
out.insert_or_assign(dep.name, std::move(dep_tbl));
} else if (dep.components.empty()) {
out.insert_or_assign(dep.name, dep.version_spec); out.insert_or_assign(dep.name, dep.version_spec);
} else { } else {
toml::table dep_tbl; toml::table dep_tbl;

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

@@ -0,0 +1,233 @@
module;
#include <toml.hpp>
module cargoxx.resolver;
import std;
import cargoxx.util;
import cargoxx.exec;
namespace cargoxx::resolver {
namespace {
constexpr std::string_view CARGOXX_PKGS_OWNER = "mozart";
constexpr std::string_view CARGOXX_PKGS_REPO = "cargoxx-pkgs";
constexpr std::string_view CARGOXX_PKGS_HOST = "https://git.amadey.xyz";
auto error(util::ErrorCode code, std::string msg) -> util::Error {
return util::Error{code, std::move(msg), "", std::nullopt, std::nullopt};
}
auto curl_get(const std::string& url) -> util::Result<std::string> {
auto r = exec::run("curl", {"-fsSL", "--max-time", "30", url},
exec::ExecOptions{
.cwd = {},
.env_overrides = {},
.timeout = std::chrono::seconds{40},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code == 22) {
return std::unexpected(error(util::ErrorCode::ResolutionUnknownPackage,
std::format("HTTP 4xx for {}", url)));
}
if (r->exit_code != 0) {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
std::format("curl failed (exit {}) fetching {}: {}",
r->exit_code, url, r->stderr_text)));
}
return std::move(r->stdout_text);
}
// Returns the recipe basenames in `versions/` for `<name>`. Hits the
// Gitea contents API which returns a JSON array; we don't pull in a
// JSON library for this tiny shape — instead we scrape `"name":"X"`
// occurrences. The shape is documented at:
// /api/swagger#/repository/repoGetContentsList
auto extract_filenames(std::string_view json) -> std::vector<std::string> {
std::vector<std::string> out;
constexpr std::string_view key = R"("name":")";
std::size_t pos = 0;
while ((pos = json.find(key, pos)) != std::string_view::npos) {
pos += key.size();
auto end = json.find('"', pos);
if (end == std::string_view::npos) {
break;
}
out.emplace_back(json.substr(pos, end - pos));
pos = end + 1;
}
return out;
}
auto pick_best_version(const std::vector<std::string>& versions,
std::string_view spec) -> std::optional<std::string> {
std::optional<std::string> best;
for (const auto& v : versions) {
if (spec != "*" && !util::satisfies(v, spec)) {
continue;
}
if (!best) {
best = v;
continue;
}
// Both v and *best satisfy; pick the higher. `satisfies(v, "> best")`
// is the cheapest precedence test using only the existing helper.
if (util::satisfies(v, std::format("> {}", *best))) {
best = v;
}
}
return best;
}
} // namespace
auto parse_cargoxx_pkgs_versions(std::string_view contents_json)
-> util::Result<std::vector<std::string>> {
auto names = extract_filenames(contents_json);
std::vector<std::string> out;
out.reserve(names.size());
for (const auto& n : names) {
constexpr std::string_view suffix = ".toml";
if (n.size() <= suffix.size() || !n.ends_with(suffix)) {
continue;
}
out.push_back(n.substr(0, n.size() - suffix.size()));
}
return out;
}
auto parse_cargoxx_pkgs_recipe(std::string_view body) -> util::Result<PkgsHit> {
toml::table root;
try {
root = toml::parse(std::string{body});
} catch (const toml::parse_error& e) {
return std::unexpected(error(util::ErrorCode::ResolutionNetworkError,
std::format("cargoxx-pkgs recipe parse error: {}",
e.description())));
}
PkgsHit hit;
if (auto v = root["version"].value<std::string>()) {
hit.version = *v;
} else {
return std::unexpected(error(util::ErrorCode::ResolutionNetworkError,
"cargoxx-pkgs recipe missing 'version'"));
}
const auto* src = root["source"].as_table();
if (!src) {
return std::unexpected(error(util::ErrorCode::ResolutionNetworkError,
"cargoxx-pkgs recipe missing [source] table"));
}
if (auto v = (*src)["type"].value<std::string>(); !v || *v != "git") {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
"cargoxx-pkgs recipe [source].type must be 'git'"));
}
auto url = (*src)["url"].value<std::string>();
auto commit = (*src)["commit"].value<std::string>();
auto sha = (*src)["sha256"].value<std::string>();
if (!url || !commit || !sha) {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
"cargoxx-pkgs recipe [source] needs url + commit + sha256"));
}
hit.source_url = *url;
hit.source_commit = *commit;
hit.source_sha256 = *sha;
return hit;
}
// Returns the HEAD rev of cargoxx-pkgs's `master` branch. Used to pin
// `inputs.cargoxx-pkgs.url = ".../?rev=<rev>"` in the consumer's flake.
auto query_cargoxx_pkgs_head() -> util::Result<std::string> {
auto url = std::format("{}/api/v1/repos/{}/{}/branches/master",
CARGOXX_PKGS_HOST, CARGOXX_PKGS_OWNER,
CARGOXX_PKGS_REPO);
auto body = curl_get(url);
if (!body) {
return std::unexpected(body.error());
}
// Tiny ad-hoc extraction; Gitea returns `"commit":{"id":"<sha>", ...}`.
constexpr std::string_view key = R"("id":")";
auto pos = body->find(key);
if (pos == std::string::npos) {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
std::format("cargoxx-pkgs branches response from {} has no commit id",
url)));
}
pos += key.size();
auto end = body->find('"', pos);
if (end == std::string::npos) {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
std::format("cargoxx-pkgs branches response from {} is truncated",
url)));
}
return body->substr(pos, end - pos);
}
auto try_cargoxx_pkgs(const std::string& name, const std::string& version_spec)
-> util::Result<PkgsHit> {
if (name.empty()) {
return std::unexpected(error(util::ErrorCode::ResolutionUnknownPackage,
"package name is empty"));
}
// Step 1: list version recipes via the Gitea contents API.
auto list_url = std::format(
"{}/api/v1/repos/{}/{}/contents/recipes/{}/versions",
CARGOXX_PKGS_HOST, CARGOXX_PKGS_OWNER, CARGOXX_PKGS_REPO, name);
auto listing = curl_get(list_url);
if (!listing) {
return std::unexpected(listing.error());
}
auto versions_r = parse_cargoxx_pkgs_versions(*listing);
if (!versions_r) {
return std::unexpected(versions_r.error());
}
if (versions_r->empty()) {
return std::unexpected(error(util::ErrorCode::ResolutionUnknownPackage,
std::format("cargoxx-pkgs has no versions for '{}'",
name)));
}
auto chosen = pick_best_version(*versions_r, version_spec);
if (!chosen) {
return std::unexpected(error(
util::ErrorCode::ResolutionUnknownPackage,
std::format("cargoxx-pkgs has no version of '{}' satisfying '{}'",
name, version_spec)));
}
// Step 2: pull the recipe TOML.
auto recipe_url = std::format(
"{}/{}/{}/raw/branch/master/recipes/{}/versions/{}.toml",
CARGOXX_PKGS_HOST, CARGOXX_PKGS_OWNER, CARGOXX_PKGS_REPO, name, *chosen);
auto body = curl_get(recipe_url);
if (!body) {
return std::unexpected(body.error());
}
auto hit = parse_cargoxx_pkgs_recipe(*body);
if (!hit) {
return std::unexpected(hit.error());
}
if (hit->version != *chosen) {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
std::format("cargoxx-pkgs recipe at {} declares version '{}' "
"but the path implies '{}'",
recipe_url, hit->version, *chosen)));
}
auto rev = query_cargoxx_pkgs_head();
if (!rev) {
return std::unexpected(rev.error());
}
hit->repo_rev = *rev;
return hit;
}
} // namespace cargoxx::resolver

View File

@@ -48,6 +48,61 @@ auto recipe_from_nix_scan(const NixCmakeCandidate& n,
}; };
} }
// PkgConfig-shaped recipe. The synthesized `PkgConfig::<NAME>` target
// name must match what CMake's `pkg_check_modules(... IMPORTED_TARGET
// <module>)` produces: the third arg of pkg_check_modules becomes the
// prefix variable AND the imported-target suffix. We pass the
// uppercased module stem so the target is `PkgConfig::<UPPER>`.
auto upper_pc_name(std::string_view module_name) -> std::string {
std::string out;
out.reserve(module_name.size());
for (char c : module_name) {
if (std::isalnum(static_cast<unsigned char>(c))) {
out += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
} else {
out += '_';
}
}
return out;
}
auto recipe_from_pc(const PcCandidate& p, const std::string& nixpkgs_attr,
const std::string& source) -> linkdb::Recipe {
auto upper = upper_pc_name(p.pc_module);
return linkdb::Recipe{
.nixpkgs_attr = nixpkgs_attr,
.find_package = "PkgConfig REQUIRED",
.targets = {std::format("PkgConfig::{}", upper)},
.source = source,
.pkg_config_module = p.pc_module,
};
}
auto recipe_from_findmodule(const FindModuleCandidate& fm,
const std::string& nixpkgs_attr,
const std::string& source) -> linkdb::Recipe {
return linkdb::Recipe{
.nixpkgs_attr = nixpkgs_attr,
.find_package = fm.find_package,
.targets = fm.targets,
.source = source,
};
}
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 { struct Candidate {
std::string source; std::string source;
linkdb::Recipe recipe; linkdb::Recipe recipe;
@@ -56,7 +111,7 @@ struct Candidate {
auto try_verify(const Candidate& cand, const std::string& name, auto try_verify(const Candidate& cand, const std::string& name,
const std::string& version_spec, const std::string& version_spec,
const std::vector<std::string>& components, const std::vector<std::string>& components,
const fs::path& overlay_path, const fs::path& scratch_root, const fs::path& overlay_path, const fs::path& scratch_path,
const BuildFn& build_fn) -> util::Result<void> { const BuildFn& build_fn) -> util::Result<void> {
VerifyLinkRequest req{ VerifyLinkRequest req{
.candidate = cand.recipe, .candidate = cand.recipe,
@@ -65,7 +120,7 @@ auto try_verify(const Candidate& cand, const std::string& name,
.version_spec = version_spec, .version_spec = version_spec,
.components = components, .components = components,
.overlay_path = overlay_path, .overlay_path = overlay_path,
.scratch_root = scratch_root, .scratch_path = scratch_path,
}; };
return verify_link(req, build_fn); return verify_link(req, build_fn);
} }
@@ -83,10 +138,10 @@ auto discover(const std::string& name, const std::string& version_spec,
std::vector<Candidate> candidates; std::vector<Candidate> candidates;
if (auto c = conan_probe(name); c) { if (auto c = conan_probe_fuzzy(name); c) {
candidates.push_back({"conan", recipe_from_conan(*c, name, "conan")}); candidates.push_back({"conan", recipe_from_conan(*c, name, "conan")});
} }
if (auto v = vcpkg_probe(name); v) { if (auto v = vcpkg_probe_fuzzy(name); v) {
candidates.push_back({"vcpkg", recipe_from_vcpkg(*v, name, "vcpkg")}); candidates.push_back({"vcpkg", recipe_from_vcpkg(*v, name, "vcpkg")});
} }
// Multi-output nix packages keep CMake configs in the `dev` output. // Multi-output nix packages keep CMake configs in the `dev` output.
@@ -109,16 +164,45 @@ auto discover(const std::string& name, const std::string& version_spec,
return std::nullopt; return std::nullopt;
}; };
std::optional<NixCmakeCandidate> scan_hit; std::optional<NixCmakeCandidate> scan_hit;
std::optional<PcCandidate> pc_hit;
std::string realized_dev_path;
if (!info->dev_path.empty()) { if (!info->dev_path.empty()) {
scan_hit = realize_and_scan("dev"); scan_hit = realize_and_scan("dev");
if (auto realized = realize_path(std::format("{}.dev", name)); realized) {
realized_dev_path = *realized;
}
} }
if (!scan_hit) { if (!scan_hit) {
scan_hit = realize_and_scan(""); scan_hit = realize_and_scan("");
} }
if (realized_dev_path.empty()) {
if (auto realized = realize_path(name); realized) {
realized_dev_path = *realized;
}
}
if (scan_hit) { if (scan_hit) {
candidates.push_back( candidates.push_back(
{"nix-probe", recipe_from_nix_scan(*scan_hit, name, "nix-probe")}); {"nix-probe", recipe_from_nix_scan(*scan_hit, name, "nix-probe")});
} }
if (auto fm = findmodule_scan(name); fm) {
candidates.push_back(
{"cmake-findmodule", recipe_from_findmodule(*fm, name, "cmake-findmodule")});
}
if (!realized_dev_path.empty()) {
if (auto p = pc_scan(fs::path{realized_dev_path}, name); p) {
pc_hit = std::move(*p);
}
}
if (pc_hit) {
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()) { if (candidates.empty()) {
return std::unexpected(error( return std::unexpected(error(
@@ -132,9 +216,12 @@ auto discover(const std::string& name, const std::string& version_spec,
std::format("no candidate for '{}' verified", name), "", std::format("no candidate for '{}' verified", name), "",
std::nullopt, std::nullopt, std::nullopt, std::nullopt,
}; };
for (auto& cand : candidates) { for (std::size_t i = 0; i < candidates.size(); ++i) {
auto& cand = candidates[i];
auto subdir = std::format("{:02}-{}", i + 1, cand.source);
auto scratch_path = scratch_root / subdir;
auto verified = try_verify(cand, name, version_spec, components, overlay_path, auto verified = try_verify(cand, name, version_spec, components, overlay_path,
scratch_root, build_fn); scratch_path, build_fn);
if (verified) { if (verified) {
return Discovered{ return Discovered{
.recipe = std::move(cand.recipe), .recipe = std::move(cand.recipe),
@@ -147,4 +234,17 @@ auto discover(const std::string& name, const std::string& version_spec,
return std::unexpected(last_error); return std::unexpected(last_error);
} }
auto last_failure_dir(const std::string& package_name) -> fs::path {
auto base = []() -> fs::path {
if (auto* xdg = std::getenv("XDG_CACHE_HOME"); xdg && *xdg) {
return fs::path{xdg} / "cargoxx" / "last-failure";
}
if (auto* home = std::getenv("HOME"); home && *home) {
return fs::path{home} / ".cache" / "cargoxx" / "last-failure";
}
return fs::current_path() / ".cargoxx-last-failure";
}();
return base / package_name;
}
} // namespace cargoxx::resolver } // namespace cargoxx::resolver

View File

@@ -0,0 +1,213 @@
module cargoxx.resolver;
import std;
import cargoxx.exec;
import cargoxx.util;
namespace cargoxx::resolver {
namespace fs = std::filesystem;
namespace {
// Mirrors nix_cmake_scan / pc_scan. Kept local; Phase A refactor will
// extract this to a shared `name_match` module.
auto normalize(std::string_view s) -> std::string {
std::string out;
out.reserve(s.size());
for (char c : s) {
out += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
if (out.size() > 3 && out.starts_with("lib")) {
out.erase(0, 3);
}
auto is_vchar = [](char c) {
return std::isdigit(static_cast<unsigned char>(c)) || c == '.'
|| c == '-' || c == '_';
};
std::size_t end = out.size();
while (end > 0 && is_vchar(out[end - 1])) {
--end;
}
bool has_digit = false;
for (auto i = end; i < out.size(); ++i) {
if (std::isdigit(static_cast<unsigned char>(out[i]))) {
has_digit = true;
break;
}
}
if (has_digit) {
out.erase(end);
if (!out.empty() && (out.back() == '-' || out.back() == '_')) {
out.pop_back();
}
}
std::string compact;
compact.reserve(out.size());
for (char c : out) {
if (std::isalnum(static_cast<unsigned char>(c))) {
compact += c;
}
}
return compact;
}
auto match_score(std::string_view stem, std::string_view pkg) -> int {
auto s = normalize(stem);
auto q = normalize(pkg);
if (q.empty()) {
return 2;
}
if (s == q) {
return 0;
}
if (!s.empty() && (s.starts_with(q) || q.starts_with(s))) {
return 1;
}
return 2;
}
// Find CMake's bundled Modules dir by running a one-line script that
// prints `CMAKE_ROOT`. We can't use `cmake -E capabilities` because
// CMake 4.x dropped the `cmakeRoot` field; `cmake -P` of a script with
// `message("${CMAKE_ROOT}")` is the portable path. The message text
// goes to stderr in `cmake -P` mode.
auto find_modules_dir() -> std::optional<fs::path> {
auto script = fs::temp_directory_path() /
std::format("cargoxx-findroot-{}.cmake",
std::random_device{}());
{
std::ofstream out{script};
if (!out) {
return std::nullopt;
}
out << "message(\"${CMAKE_ROOT}\")\n";
}
auto r = exec::run("cmake", {"-P", script.string()},
exec::ExecOptions{
.cwd = fs::current_path(),
.env_overrides = {},
.timeout = std::chrono::seconds{5},
.inherit_stdio = false,
});
std::error_code ec;
fs::remove(script, ec);
if (!r || r->exit_code != 0) {
return std::nullopt;
}
// The message goes to stderr in script mode; trim and use it.
std::string_view body = r->stderr_text;
while (!body.empty() && (body.back() == '\n' || body.back() == '\r' ||
body.back() == ' ' || body.back() == '\t')) {
body.remove_suffix(1);
}
if (body.empty()) {
return std::nullopt;
}
fs::path modules = fs::path{std::string{body}} / "Modules";
if (!fs::exists(modules, ec) || ec) {
return std::nullopt;
}
return modules;
}
// Strip a leading "Find" and trailing ".cmake" from a filename to get
// the find_package stem.
auto module_stem(const fs::path& path) -> std::string {
auto s = path.stem().string(); // e.g. "FindSQLite3"
if (s.starts_with("Find")) {
s.erase(0, 4);
}
return s;
}
} // namespace
auto findmodule_scan(const std::string& package_name)
-> util::Result<FindModuleCandidate> {
auto modules_dir = find_modules_dir();
if (!modules_dir) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
"cannot locate CMake's bundled Modules directory",
"", std::nullopt, std::nullopt,
});
}
struct Match {
int score;
std::string stem;
fs::path path;
};
std::vector<Match> matches;
std::error_code ec;
for (const auto& entry : fs::directory_iterator{
*modules_dir, fs::directory_options::skip_permission_denied, ec}) {
if (!entry.is_regular_file()) {
continue;
}
auto name = entry.path().filename().string();
if (!name.starts_with("Find") || !name.ends_with(".cmake")) {
continue;
}
auto stem = module_stem(entry.path());
matches.push_back(Match{
.score = match_score(stem, package_name),
.stem = std::move(stem),
.path = entry.path(),
});
}
if (matches.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no Find*.cmake under '{}'", modules_dir->string()),
"", std::nullopt, std::nullopt,
});
}
std::ranges::stable_sort(matches, [](const Match& a, const Match& b) {
if (a.score != b.score) {
return a.score < b.score;
}
if (a.stem.size() != b.stem.size()) {
return a.stem.size() < b.stem.size();
}
return a.stem < b.stem;
});
if (matches.front().score >= 2) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no Find*.cmake matches package name '{}'", package_name),
"", std::nullopt, std::nullopt,
});
}
const auto& winner = matches.front();
// FindModules rarely declare IMPORTED targets the same way Config
// files do, so scan_imported_targets often comes back empty.
// Default to CMake's modern convention `<X>::<X>` and let the
// namespaced-from-the-module-body pick override when present.
std::vector<std::string> targets;
std::ifstream in{winner.path};
if (in) {
std::string text{std::istreambuf_iterator<char>{in}, {}};
targets = scan_imported_targets(text);
}
if (targets.empty()) {
targets.push_back(std::format("{}::{}", winner.stem, winner.stem));
} else {
targets = filter_public_targets(std::move(targets), winner.stem);
}
return FindModuleCandidate{
.find_package = std::format("{} REQUIRED", winner.stem),
.targets = std::move(targets),
.module_file = winner.path,
};
}
} // namespace cargoxx::resolver

View File

@@ -0,0 +1,193 @@
module;
#include <json.hpp>
module cargoxx.resolver;
import std;
import cargoxx.exec;
import cargoxx.util;
namespace cargoxx::resolver {
namespace fs = std::filesystem;
namespace {
auto network_error(std::string msg) -> util::Error {
return util::Error{util::ErrorCode::ResolutionNetworkError, std::move(msg),
"", std::nullopt, std::nullopt};
}
auto fetch_tree_paths(const std::string& url) -> util::Result<std::vector<std::string>> {
auto r = exec::run("curl", {"-fsSL", "--max-time", "20", url},
exec::ExecOptions{
.cwd = {},
.env_overrides = {},
.timeout = std::chrono::seconds{30},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code != 0) {
return std::unexpected(network_error(std::format(
"curl failed (exit {}): {}", r->exit_code, r->stderr_text)));
}
nlohmann::json j;
try {
j = nlohmann::json::parse(r->stdout_text);
} catch (const nlohmann::json::parse_error& e) {
return std::unexpected(
network_error(std::format("tree listing not valid JSON: {}", e.what())));
}
if (!j.contains("tree") || !j["tree"].is_array()) {
return std::unexpected(network_error("tree listing missing 'tree' array"));
}
std::vector<std::string> out;
for (const auto& entry : j["tree"]) {
if (entry.contains("path") && entry["path"].is_string()) {
out.push_back(entry["path"].get<std::string>());
}
}
return out;
}
auto cache_root() -> fs::path {
if (auto* xdg = std::getenv("XDG_CACHE_HOME"); xdg && *xdg) {
return fs::path{xdg} / "cargoxx";
}
if (auto* home = std::getenv("HOME"); home && *home) {
return fs::path{home} / ".cache" / "cargoxx";
}
return fs::temp_directory_path() / "cargoxx";
}
constexpr auto INDEX_TTL = std::chrono::hours{24};
auto load_or_fetch(const std::string& cache_key, const std::string& url)
-> util::Result<std::vector<std::string>> {
auto path = cache_root() / std::format("{}-index.txt", cache_key);
std::error_code ec;
if (fs::exists(path, ec) && !ec) {
auto age = std::chrono::system_clock::now() -
std::chrono::file_clock::to_sys(fs::last_write_time(path));
if (age < INDEX_TTL) {
std::ifstream in{path};
if (in) {
std::vector<std::string> out;
std::string line;
while (std::getline(in, line)) {
if (!line.empty()) {
out.push_back(std::move(line));
}
}
if (!out.empty()) {
return out;
}
}
}
}
auto fresh = fetch_tree_paths(url);
if (!fresh) {
return std::unexpected(fresh.error());
}
fs::create_directories(path.parent_path(), ec);
if (std::ofstream out{path}; out) {
for (const auto& p : *fresh) {
out << p << '\n';
}
}
return fresh;
}
// Levenshtein top-k filter with a max-distance gate of ⌈len/4⌉ (min 1).
auto top_fuzzy(std::string_view query, const std::vector<std::string>& corpus,
std::size_t k) -> std::vector<std::string> {
const std::size_t cap = std::max<std::size_t>(1, (query.size() + 3) / 4);
struct Scored {
std::size_t dist;
std::string name;
};
std::vector<Scored> scored;
scored.reserve(corpus.size());
for (const auto& c : corpus) {
auto d = util::levenshtein(query, c);
if (d <= cap) {
scored.push_back({d, c});
}
}
std::ranges::sort(scored, [](const auto& a, const auto& b) {
if (a.dist != b.dist) {
return a.dist < b.dist;
}
return a.name < b.name;
});
std::vector<std::string> out;
for (std::size_t i = 0; i < std::min(k, scored.size()); ++i) {
out.push_back(std::move(scored[i].name));
}
return out;
}
constexpr auto FUZZY_K = std::size_t{3};
} // namespace
auto conan_probe_fuzzy(const std::string& name) -> util::Result<ConanRecipe> {
if (auto exact = conan_probe(name); exact) {
return exact;
}
auto index = load_or_fetch(
"conan",
"https://api.github.com/repos/conan-io/conan-center-index/git/trees/master:recipes");
if (!index) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no Conan recipe for '{}' and index fetch failed", name),
"", std::nullopt, std::nullopt,
});
}
auto candidates = top_fuzzy(name, *index, FUZZY_K);
for (const auto& cand : candidates) {
if (auto r = conan_probe(cand); r) {
return r;
}
}
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no Conan recipe matches '{}' (tried exact + fuzzy top-{})",
name, FUZZY_K),
"", std::nullopt, std::nullopt,
});
}
auto vcpkg_probe_fuzzy(const std::string& name) -> util::Result<VcpkgRecipe> {
if (auto exact = vcpkg_probe(name); exact) {
return exact;
}
auto index = load_or_fetch(
"vcpkg",
"https://api.github.com/repos/microsoft/vcpkg/git/trees/master:ports");
if (!index) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no vcpkg port for '{}' and index fetch failed", name),
"", std::nullopt, std::nullopt,
});
}
auto candidates = top_fuzzy(name, *index, FUZZY_K);
for (const auto& cand : candidates) {
if (auto r = vcpkg_probe(cand); r) {
return r;
}
}
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no vcpkg port matches '{}' (tried exact + fuzzy top-{})",
name, FUZZY_K),
"", std::nullopt, std::nullopt,
});
}
} // namespace cargoxx::resolver

View File

@@ -31,8 +31,8 @@ auto make_error(util::ErrorCode code, std::string msg) -> util::Error {
// nix eval emits these markers when an attribute is missing on the flake. // nix eval emits these markers when an attribute is missing on the flake.
auto looks_like_missing_attribute(std::string_view stderr_text) -> bool { auto looks_like_missing_attribute(std::string_view stderr_text) -> bool {
return stderr_text.find("does not provide attribute") != std::string_view::npos || return stderr_text.find("does not provide attribute") != std::string_view::npos ||
stderr_text.find("attribute '") != std::string_view::npos && (stderr_text.find("attribute '") != std::string_view::npos &&
stderr_text.find("missing") != std::string_view::npos; stderr_text.find("missing") != std::string_view::npos);
} }
} // namespace } // namespace
@@ -162,4 +162,124 @@ auto realize_path(const std::string& flake_attr) -> util::Result<std::string> {
return path; return path;
} }
auto realize_path_at_rev(const std::string& rev, const std::string& attr)
-> util::Result<std::string> {
if (rev.empty() || attr.empty()) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionUnknownPackage,
"realize_path_at_rev: rev and attr must be non-empty"));
}
std::vector<std::string> args{
"--extra-experimental-features", "nix-command flakes",
"build", std::format("github:NixOS/nixpkgs/{}#{}", rev, attr),
"--no-link", "--print-out-paths",
};
auto r = exec::run("nix", args,
exec::ExecOptions{
.cwd = {},
.env_overrides = {},
.timeout = std::chrono::seconds{600},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code != 0) {
if (looks_like_missing_attribute(r->stderr_text)) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionUnknownPackage,
std::format("nixpkgs/{} has no attribute '{}'", rev, attr)));
}
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix build failed (exit {}): {}", r->exit_code,
r->stderr_text)));
}
auto path = r->stdout_text;
while (!path.empty() && (path.back() == '\n' || path.back() == ' ')) {
path.pop_back();
}
if (path.empty()) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix build emitted no path for '{}#{}'", rev, attr)));
}
return path;
}
namespace {
auto extract_json_string(std::string_view body, std::string_view key)
-> std::optional<std::string> {
auto needle = std::format("\"{}\":\"", key);
auto pos = body.find(needle);
if (pos == std::string_view::npos) {
return std::nullopt;
}
pos += needle.size();
auto end = body.find('"', pos);
if (end == std::string_view::npos) {
return std::nullopt;
}
return std::string{body.substr(pos, end - pos)};
}
} // namespace
auto prefetch_flake_source(const std::string& flake_ref)
-> util::Result<PrefetchedSource> {
if (flake_ref.empty()) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionUnknownPackage,
"prefetch_flake_source: flake_ref is empty"));
}
std::vector<std::string> args{
"--extra-experimental-features", "nix-command flakes",
"flake", "prefetch", flake_ref, "--json",
};
auto r = exec::run("nix", args,
exec::ExecOptions{
.cwd = {},
.env_overrides = {},
.timeout = std::chrono::seconds{300},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code != 0) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix flake prefetch failed (exit {}): {}",
r->exit_code, r->stderr_text)));
}
auto store_path = extract_json_string(r->stdout_text, "storePath");
auto hash = extract_json_string(r->stdout_text, "hash");
if (!store_path) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix flake prefetch emitted no storePath for '{}'",
flake_ref)));
}
if (!hash) {
return std::unexpected(make_error(
util::ErrorCode::ResolutionNetworkError,
std::format("nix flake prefetch emitted no hash for '{}'",
flake_ref)));
}
return PrefetchedSource{
.store_path = std::move(*store_path),
.hash = std::move(*hash),
};
}
auto realize_flake_source(const std::string& flake_ref)
-> util::Result<std::string> {
auto r = prefetch_flake_source(flake_ref);
if (!r) {
return std::unexpected(r.error());
}
return std::move(r->store_path);
}
} // namespace cargoxx::resolver } // namespace cargoxx::resolver

161
src/resolver/pc_scan.cpp Normal file
View File

@@ -0,0 +1,161 @@
module cargoxx.resolver;
import std;
import cargoxx.util;
namespace cargoxx::resolver {
namespace fs = std::filesystem;
namespace {
// Mirrors `normalize` in nix_cmake_scan.cpp. Kept local rather than
// extracted so each scanner is self-contained; a future Phase A
// refactor will hoist this into a shared module.
auto normalize(std::string_view s) -> std::string {
std::string out;
out.reserve(s.size());
for (char c : s) {
out += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
if (out.size() > 3 && out.starts_with("lib")) {
out.erase(0, 3);
}
auto is_vchar = [](char c) {
return std::isdigit(static_cast<unsigned char>(c)) || c == '.'
|| c == '-' || c == '_';
};
std::size_t end = out.size();
while (end > 0 && is_vchar(out[end - 1])) {
--end;
}
bool has_digit = false;
for (auto i = end; i < out.size(); ++i) {
if (std::isdigit(static_cast<unsigned char>(out[i]))) {
has_digit = true;
break;
}
}
if (has_digit) {
out.erase(end);
if (!out.empty() && (out.back() == '-' || out.back() == '_')) {
out.pop_back();
}
}
std::string compact;
compact.reserve(out.size());
for (char c : out) {
if (std::isalnum(static_cast<unsigned char>(c))) {
compact += c;
}
}
return compact;
}
// Same scoring as nix_cmake_scan: 0 exact, 1 prefix-either, 2 fallback.
auto match_score(std::string_view stem, std::string_view pkg) -> int {
auto s = normalize(stem);
auto q = normalize(pkg);
if (q.empty()) {
return 2;
}
if (s == q) {
return 0;
}
if (!s.empty() && (s.starts_with(q) || q.starts_with(s))) {
return 1;
}
return 2;
}
// Defensive: read a few KB to verify the .pc has at least one Name: or
// Libs: line. We don't parse the file — `pkg_check_modules` does that
// at CMake time — but a sanity check rejects empty/junk files.
auto looks_like_pc(const fs::path& path) -> bool {
std::ifstream in{path};
if (!in) {
return false;
}
char buf[4096];
in.read(buf, sizeof(buf));
std::string_view chunk{buf, static_cast<std::size_t>(in.gcount())};
return chunk.find("\nName:") != std::string_view::npos ||
chunk.starts_with("Name:") ||
chunk.find("\nLibs:") != std::string_view::npos ||
chunk.starts_with("Libs:");
}
} // namespace
auto pc_scan(const fs::path& store_path, const std::string& package_name)
-> util::Result<PcCandidate> {
const auto pc_dir = store_path / "lib" / "pkgconfig";
std::error_code ec;
if (!fs::exists(pc_dir, ec) || ec) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no pkgconfig directory under '{}'", pc_dir.string()),
"", store_path, std::nullopt,
});
}
struct Match {
int score;
std::string stem;
fs::path path;
};
std::vector<Match> matches;
for (const auto& entry : fs::directory_iterator{
pc_dir, fs::directory_options::skip_permission_denied, ec}) {
if (!entry.is_regular_file()) {
continue;
}
if (entry.path().extension() != ".pc") {
continue;
}
if (!looks_like_pc(entry.path())) {
continue;
}
auto stem = entry.path().stem().string();
matches.push_back(Match{
.score = match_score(stem, package_name),
.stem = stem,
.path = entry.path(),
});
}
if (matches.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no usable .pc file under '{}'", pc_dir.string()),
"", store_path, std::nullopt,
});
}
std::ranges::stable_sort(matches, [](const Match& a, const Match& b) {
if (a.score != b.score) {
return a.score < b.score;
}
if (a.stem.size() != b.stem.size()) {
return a.stem.size() < b.stem.size();
}
return a.stem < b.stem;
});
if (matches.front().score >= 2) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no .pc file under '{}' matches package name '{}'",
pc_dir.string(), package_name),
"", store_path, std::nullopt,
});
}
return PcCandidate{
.pc_module = std::move(matches.front().stem),
.pc_file = std::move(matches.front().path),
};
}
} // namespace cargoxx::resolver

View File

@@ -45,6 +45,67 @@ auto nixpkgs_probe(const std::string& attr) -> util::Result<NixpkgsInfo>;
// for build / network errors. // for build / network errors.
auto realize_path(const std::string& flake_attr) -> util::Result<std::string>; auto realize_path(const std::string& flake_attr) -> util::Result<std::string>;
// Like `realize_path`, but pins the nixpkgs revision instead of using the
// registry alias. Builds `github:NixOS/nixpkgs/<rev>#<attr>` and returns
// the resulting `/nix/store/...` path. Used by the vendor subcommand to
// materialize each lockfile-pinned dep without depending on the user's
// flake registry.
auto realize_path_at_rev(const std::string& rev, const std::string& attr)
-> util::Result<std::string>;
// Returns the source store path for `github:NixOS/nixpkgs/<rev>` (the
// path Nix would set as `nixpkgs.outPath` when imported). Used by the
// vendor subcommand to record nixpkgs/flake-utils source locations.
auto realize_flake_source(const std::string& flake_ref)
-> util::Result<std::string>;
struct PrefetchedSource {
std::string store_path;
std::string hash; // SRI form, e.g. "sha256-<base64>"
};
// Result of looking a package up in the cargoxx-pkgs Gitea repo
// (https://git.amadey.xyz/mozart/cargoxx-pkgs). Mirrors what
// `nixpkgs_probe` returns for the nixpkgs path: a confirmed (name,
// version) pair plus the source-of-truth flake rev so cargoxx build
// can pin `inputs.cargoxx-pkgs.url = ".../?rev=<repo_rev>"`. The
// recipe-source fields (url/commit/sha256) are surfaced for
// diagnostics; consumers pull the dep transitively through the
// cargoxx-pkgs flake, not by re-fetching directly.
struct PkgsHit {
std::string version; // the concrete version that matched (e.g. "0.1.1")
std::string repo_rev; // HEAD rev of cargoxx-pkgs at lookup time
std::string source_url; // [source].url from the recipe TOML (diag)
std::string source_commit; // [source].commit (diag)
std::string source_sha256; // [source].sha256 (SRI form) (diag)
};
// Pure: parse a Gitea `/contents/<dir>` JSON listing and extract the
// versions (basenames stripped of `.toml`). Returns the versions in
// the order they appear in the JSON.
auto parse_cargoxx_pkgs_versions(std::string_view contents_json)
-> util::Result<std::vector<std::string>>;
// Pure: parse a single recipe TOML (the file at
// `recipes/<name>/versions/<v>.toml`) into a PkgsHit. The caller fills
// in `version` from the URL it asked for; `source_*` come from the
// recipe's `[source]` table.
auto parse_cargoxx_pkgs_recipe(std::string_view body) -> util::Result<PkgsHit>;
// Hits the cargoxx-pkgs Gitea repo for `<name>`'s recipe list, picks
// the highest version satisfying `version_spec` (`*` → highest), fetches
// the recipe TOML, and returns a PkgsHit. Returns
// `ResolutionUnknownPackage` when the package directory or no version
// matching the spec exists.
auto try_cargoxx_pkgs(const std::string& name, const std::string& version_spec)
-> util::Result<PkgsHit>;
// Same as realize_flake_source but also returns the SRI hash so the
// caller can persist it in a lockfile and feed it to `pkgs.fetchgit`
// as a fixed-output derivation pin. Used by cargoxx-git deps.
auto prefetch_flake_source(const std::string& flake_ref)
-> util::Result<PrefetchedSource>;
// One CMake config-file's IMPORTED targets together with the find_package // One CMake config-file's IMPORTED targets together with the find_package
// expression derived from its filename stem. // expression derived from its filename stem.
struct NixCmakeCandidate { struct NixCmakeCandidate {
@@ -77,6 +138,52 @@ auto nix_cmake_scan(const std::filesystem::path& store_path,
const std::string& package_name) const std::string& package_name)
-> util::Result<NixCmakeCandidate>; -> util::Result<NixCmakeCandidate>;
// A CMake builtin FindModule-shaped recipe. CMake ships ~160 `Find*.cmake`
// modules with the installation; for libraries that have no `Config.cmake`
// but a corresponding builtin (sqlite has `FindSQLite3.cmake`, openssl has
// `FindOpenSSL.cmake`, threads has `FindThreads.cmake`, …) the recipe
// emits `find_package(<X> REQUIRED)` without the CONFIG keyword.
struct FindModuleCandidate {
std::string find_package; // e.g. "SQLite3 REQUIRED"
std::vector<std::string> targets; // e.g. ["SQLite::SQLite3"]
std::filesystem::path module_file; // the FindX.cmake we matched
};
// Walks CMake's bundled `Modules/Find*.cmake` and picks the best match
// for `package_name`. Returns ResolutionUnknownPackage when no module
// scores acceptably.
auto findmodule_scan(const std::string& package_name)
-> util::Result<FindModuleCandidate>;
// A pkg-config-shaped recipe: the package ships a `.pc` file rather
// than a CMake config. Consumed via `find_package(PkgConfig REQUIRED)`
// + `pkg_check_modules(<NAME> REQUIRED IMPORTED_TARGET <pc_module>)`,
// linking against the generated `PkgConfig::<NAME>` target.
struct PcCandidate {
std::string pc_module; // file stem, e.g. "sqlite3"
std::filesystem::path pc_file; // path to the .pc on disk
};
// Walks <store_path>/lib/pkgconfig/*.pc and picks the best match for
// `package_name`. Returns ResolutionUnknownPackage when no `.pc` file
// is present or none scores acceptably.
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. // Output of a conan-center-index recipe scrape.
struct ConanRecipe { struct ConanRecipe {
std::string find_package; // e.g. "fmt CONFIG REQUIRED" std::string find_package; // e.g. "fmt CONFIG REQUIRED"
@@ -97,6 +204,13 @@ auto parse_conanfile(std::string_view conanfile_text, const std::string& fallbac
// ResolutionNetworkError. // ResolutionNetworkError.
auto conan_probe(const std::string& name) -> util::Result<ConanRecipe>; auto conan_probe(const std::string& name) -> util::Result<ConanRecipe>;
// Like `conan_probe`, but on exact-name miss falls back to a fuzzy
// match against the conan-center-index recipe listing using
// Levenshtein distance ≤ ⌈len/4⌉. Returns the first fuzzy candidate
// whose conanfile.py parses cleanly. Internal-only: the user's
// originally-typed package name is preserved by the caller.
auto conan_probe_fuzzy(const std::string& name) -> util::Result<ConanRecipe>;
// Output of a microsoft/vcpkg port usage-file scrape. // Output of a microsoft/vcpkg port usage-file scrape.
struct VcpkgRecipe { struct VcpkgRecipe {
std::string find_package; // e.g. "fmt CONFIG REQUIRED" std::string find_package; // e.g. "fmt CONFIG REQUIRED"
@@ -115,6 +229,10 @@ auto parse_vcpkg_usage(std::string_view usage_text)
// ResolutionUnknownPackage; transport errors → ResolutionNetworkError. // ResolutionUnknownPackage; transport errors → ResolutionNetworkError.
auto vcpkg_probe(const std::string& name) -> util::Result<VcpkgRecipe>; auto vcpkg_probe(const std::string& name) -> util::Result<VcpkgRecipe>;
// Like `vcpkg_probe`, but on exact-name miss falls back to fuzzy
// matching against the vcpkg/ports listing (Levenshtein ≤ ⌈len/4⌉).
auto vcpkg_probe_fuzzy(const std::string& name) -> util::Result<VcpkgRecipe>;
// Caller-supplied closure that runs `cargoxx build` (or any equivalent // Caller-supplied closure that runs `cargoxx build` (or any equivalent
// build) on a project rooted at the given path. Injected so the resolver // build) on a project rooted at the given path. Injected so the resolver
// stays decoupled from `cargoxx.cli`. // stays decoupled from `cargoxx.cli`.
@@ -123,12 +241,17 @@ using BuildFn =
struct VerifyLinkRequest { struct VerifyLinkRequest {
linkdb::Recipe candidate; // recipe under test linkdb::Recipe candidate; // recipe under test
std::string source; // "conan" | "vcpkg" | "nix-probe" std::string source; // "conan" | "vcpkg" | "nix-probe" | "pkg-config" | …
std::string package_name; std::string package_name;
std::string version_spec; // user-supplied spec (e.g. "*", "1.2") std::string version_spec; // user-supplied spec (e.g. "*", "1.2")
std::vector<std::string> components; std::vector<std::string> components;
std::filesystem::path overlay_path; // sqlite file we read/write std::filesystem::path overlay_path; // sqlite file we read/write
std::filesystem::path scratch_root; // parent dir for the tmp project // Exact directory the verify project lives in. verify_link creates
// it (and its `src/` subdir) but never deletes it; the caller is
// responsible for cleanup. discover() puts each probe's project at
// a distinct path under `<last-failure-dir>/<NN>-<probe>/` so
// every attempt is preserved for inspection.
std::filesystem::path scratch_path;
}; };
// Scaffolds a tiny Cargoxx project under `req.scratch_root`, writes a // Scaffolds a tiny Cargoxx project under `req.scratch_root`, writes a
@@ -147,20 +270,34 @@ struct Discovered {
std::string source; std::string source;
}; };
// Walks the full auto-resolution chain for a package not present in the // Walks the full auto-resolution chain for a package not present in
// curated linkdb or the user's overlay: // the user's overlay. Each candidate produced by a probe gets its own
// 1. nixpkgs_probe(name) — confirms the attribute exists, captures // verify_link attempt at
// version + out_path // <scratch_root>/<NN>-<probe>/
// 2. for each of conan_probe, vcpkg_probe, nix_cmake_scan(out_path,…): // e.g. `01-conan/`, `02-vcpkg/`, `03-nix-probe/`, `04-pkg-config/`.
// build a candidate linkdb::Recipe, run verify_link on it, return // Subdirs are NOT cleaned up — they're meant for the user to inspect
// on first success // after a failed `cargoxx add`. The caller wipes `<scratch_root>`
// 3. all candidates failed → ResolutionUnsatisfiable // clean at the start of each invocation (cmd_add / cmd_build).
//
// Returns `Discovered` on the first verify_link success;
// `ResolutionUnsatisfiable` when all probes are exhausted; or the
// underlying error from `nixpkgs_probe`.
auto discover(const std::string& name, const std::string& version_spec, auto discover(const std::string& name, const std::string& version_spec,
const std::vector<std::string>& components, const std::vector<std::string>& components,
const std::filesystem::path& overlay_path, const std::filesystem::path& overlay_path,
const std::filesystem::path& scratch_root, const BuildFn& build_fn) const std::filesystem::path& scratch_root, const BuildFn& build_fn)
-> util::Result<Discovered>; -> util::Result<Discovered>;
// The on-disk parent dir that holds per-package verify_link scratch
// projects. Resolves to:
// $XDG_CACHE_HOME/cargoxx/last-failure/<pkg>/ (when XDG_CACHE_HOME is set)
// $HOME/.cache/cargoxx/last-failure/<pkg>/ (else if HOME is set)
// <cwd>/.cargoxx-last-failure/<pkg>/ (fallback)
// Each `cargoxx add <pkg>` (and `cmd_build`'s auto-resolve) wipes
// this dir clean, then discover() repopulates it with one subdir per
// probe attempt.
auto last_failure_dir(const std::string& package_name) -> std::filesystem::path;
// Output of devbox's /v1/resolve API. We capture only the fields cargoxx // Output of devbox's /v1/resolve API. We capture only the fields cargoxx
// uses; the response carries far more metadata (license, summary, per- // uses; the response carries far more metadata (license, summary, per-
// system store hashes) that we deliberately ignore. // system store hashes) that we deliberately ignore.

View File

@@ -31,24 +31,6 @@ auto write_text(const fs::path& path, std::string_view content) -> util::Result<
return {}; return {};
} }
class TmpProject {
public:
explicit TmpProject(fs::path root) : root_(std::move(root)) {}
~TmpProject() {
std::error_code ec;
fs::remove_all(root_, ec);
}
TmpProject(const TmpProject&) = delete;
TmpProject& operator=(const TmpProject&) = delete;
TmpProject(TmpProject&&) = delete;
TmpProject& operator=(TmpProject&&) = delete;
[[nodiscard]] auto path() const -> const fs::path& { return root_; }
private:
fs::path root_;
};
} // namespace } // namespace
auto verify_link(const VerifyLinkRequest& req, const BuildFn& build_fn) auto verify_link(const VerifyLinkRequest& req, const BuildFn& build_fn)
@@ -61,9 +43,6 @@ auto verify_link(const VerifyLinkRequest& req, const BuildFn& build_fn)
}); });
} }
// Insert the provisional overlay row. It persists through the build's
// own Database::open() call, which is how the candidate recipe gets
// surfaced to cmake_lists codegen via Database::resolve.
{ {
auto db = linkdb::Database::open(req.overlay_path); auto db = linkdb::Database::open(req.overlay_path);
if (!db) { if (!db) {
@@ -74,26 +53,12 @@ auto verify_link(const VerifyLinkRequest& req, const BuildFn& build_fn)
!r) { !r) {
return std::unexpected(r.error()); return std::unexpected(r.error());
} }
} // close db before re-opening it inside build_fn
// Scaffold a tmp project. We bypass cargoxx::cli::cmd_new to avoid a
// resolver-on-cli dependency cycle; the manifest + src/main.cpp are
// exactly what cmd_build needs for its codegen.
std::error_code ec;
fs::create_directories(req.scratch_root, ec);
if (ec) {
return std::unexpected(io_error(
std::format("cannot create scratch root: {}", ec.message()),
req.scratch_root));
} }
auto proj_root = req.scratch_root /
std::format("cargoxx-verify-{}-{}", req.package_name,
std::random_device{}());
TmpProject scope{proj_root};
const auto& proj_root = req.scratch_path;
std::error_code ec;
fs::create_directories(proj_root / "src", ec); fs::create_directories(proj_root / "src", ec);
if (ec) { if (ec) {
// Roll back the provisional row before bailing.
auto db = linkdb::Database::open(req.overlay_path); auto db = linkdb::Database::open(req.overlay_path);
if (db) { if (db) {
(void)db->abort_provisional(req.package_name, req.version_spec, (void)db->abort_provisional(req.package_name, req.version_spec,
@@ -129,8 +94,6 @@ auto verify_link(const VerifyLinkRequest& req, const BuildFn& build_fn)
return std::unexpected(r.error()); return std::unexpected(r.error());
} }
// Empty main — exercises find_package + target + linker without
// requiring per-package symbol knowledge.
if (auto r = write_text(proj_root / "src" / "main.cpp", "int main() {}\n"); !r) { if (auto r = write_text(proj_root / "src" / "main.cpp", "int main() {}\n"); !r) {
auto db = linkdb::Database::open(req.overlay_path); auto db = linkdb::Database::open(req.overlay_path);
if (db) { if (db) {

30
src/util/levenshtein.cpp Normal file
View File

@@ -0,0 +1,30 @@
module cargoxx.util;
import std;
namespace cargoxx::util {
auto levenshtein(std::string_view a, std::string_view b) -> std::size_t {
if (a.size() < b.size()) {
std::swap(a, b);
}
std::vector<std::size_t> prev(b.size() + 1);
std::vector<std::size_t> curr(b.size() + 1);
std::iota(prev.begin(), prev.end(), std::size_t{0});
for (std::size_t i = 1; i <= a.size(); ++i) {
curr[0] = i;
for (std::size_t j = 1; j <= b.size(); ++j) {
auto cost = (a[i - 1] == b[j - 1]) ? std::size_t{0} : std::size_t{1};
curr[j] = std::min({
prev[j] + 1,
curr[j - 1] + 1,
prev[j - 1] + cost,
});
}
std::swap(prev, curr);
}
return prev[b.size()];
}
} // namespace cargoxx::util

View File

@@ -47,6 +47,11 @@ using Result = std::expected<T, Error>;
auto format(const Error& e) -> std::string; auto format(const Error& e) -> std::string;
// Classic Levenshtein edit distance. Used by the resolver's
// Conan/vcpkg fuzzy match when the user's nixpkgs name doesn't appear
// verbatim in those repositories' indexes (e.g. `sqlite` ↔ `sqlite3`).
auto levenshtein(std::string_view a, std::string_view b) -> std::size_t;
// Returns true if `version` (e.g. "10.2", "1.84.0") satisfies `range`. // Returns true if `version` (e.g. "10.2", "1.84.0") satisfies `range`.
// //
// Supported range syntax: // Supported range syntax:

View File

@@ -1,38 +0,0 @@
find_package(Catch2 3 REQUIRED CONFIG)
include(Catch)
function(cargoxx_add_test name)
add_executable(${name} ${name}.cpp)
target_link_libraries(${name} PRIVATE cargoxx Catch2::Catch2WithMain)
catch_discover_tests(${name})
endfunction()
cargoxx_add_test(util_error)
cargoxx_add_test(semver_satisfies)
cargoxx_add_test(exec_run)
cargoxx_add_test(manifest_parse)
cargoxx_add_test(manifest_write)
cargoxx_add_test(layout_discovery)
cargoxx_add_test(lockfile_round_trip)
cargoxx_add_test(linkdb_lookup)
cargoxx_add_test(linkdb_overlay)
cargoxx_add_test(codegen_flake)
cargoxx_add_test(codegen_cmake)
cargoxx_add_test(cmd_new)
cargoxx_add_test(cmd_build)
cargoxx_add_test(cmd_run)
cargoxx_add_test(cmd_clean)
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)
cargoxx_add_test(conan_probe_parse)
cargoxx_add_test(conan_probe_live)
cargoxx_add_test(vcpkg_probe_parse)
cargoxx_add_test(vcpkg_probe_live)
cargoxx_add_test(verify_link_unit)
cargoxx_add_test(devbox_resolve_parse)
cargoxx_add_test(devbox_resolve_live)
cargoxx_add_test(nixpkgs_git_resolve)

View File

@@ -0,0 +1,89 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.resolver;
import cargoxx.util;
import std;
using cargoxx::resolver::brute_scan;
using cargoxx::util::ErrorCode;
namespace {
auto fresh_store() -> std::filesystem::path {
auto d = std::filesystem::temp_directory_path() /
std::format("cargoxx-brute-scan-{}", std::random_device{}());
std::filesystem::create_directories(d / "lib");
std::filesystem::create_directories(d / "include");
return d;
}
void touch_lib(const std::filesystem::path& store, std::string_view name) {
std::ofstream{store / "lib" / std::string{name}};
}
void touch_include(const std::filesystem::path& store, std::string_view rel) {
auto p = store / "include" / rel;
std::filesystem::create_directories(p.parent_path());
std::ofstream{p};
}
} // namespace
TEST_CASE("brute_scan collects lib*.a and lib*.so files",
"[resolver][brute_scan]") {
auto store = fresh_store();
touch_lib(store, "libfoo.a");
touch_lib(store, "libbar.so");
touch_lib(store, "libbaz.so.1.2.3");
touch_lib(store, "not-a-lib.txt");
touch_include(store, "foo.h");
auto r = brute_scan(store, "foo");
REQUIRE(r.has_value());
REQUIRE(r->lib_files.size() == 3);
auto has = [&](std::string_view suffix) {
return std::ranges::any_of(r->lib_files, [&](const auto& p) {
return std::string_view{p}.ends_with(suffix);
});
};
REQUIRE(has("libfoo.a"));
REQUIRE(has("libbar.so"));
REQUIRE(has("libbaz.so.1.2.3"));
}
TEST_CASE("brute_scan exposes include/ as a single search directory",
"[resolver][brute_scan]") {
auto store = fresh_store();
touch_lib(store, "libsdl.a");
touch_include(store, "SDL2/SDL.h");
touch_include(store, "SDL2/SDL_video.h");
auto r = brute_scan(store, "sdl");
REQUIRE(r.has_value());
REQUIRE(r->include_dirs.size() == 1);
REQUIRE(std::string_view{r->include_dirs[0]}.ends_with("/include"));
}
TEST_CASE("brute_scan errors when neither libs nor headers are present",
"[resolver][brute_scan]") {
auto d = std::filesystem::temp_directory_path() /
std::format("cargoxx-brute-empty-{}", std::random_device{}());
std::filesystem::create_directories(d);
auto r = brute_scan(d, "ghost");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
}
TEST_CASE("brute_scan emits sorted lib paths for deterministic codegen",
"[resolver][brute_scan]") {
auto store = fresh_store();
touch_lib(store, "libzz.a");
touch_lib(store, "libaa.a");
touch_lib(store, "libmm.so");
auto r = brute_scan(store, "x");
REQUIRE(r.has_value());
REQUIRE(r->lib_files.size() == 3);
REQUIRE(std::ranges::is_sorted(r->lib_files));
}

View File

@@ -64,18 +64,18 @@ TEST_CASE("cmd_build generates files for a no-deps binary project",
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());
REQUIRE(std::filesystem::exists(root / "flake.nix")); REQUIRE(std::filesystem::exists(root / "build" / "flake.nix"));
REQUIRE(std::filesystem::exists(root / "build" / "CMakeLists.txt")); REQUIRE(std::filesystem::exists(root / "build" / "CMakeLists.txt"));
REQUIRE(std::filesystem::exists(root / "Cargoxx.lock")); REQUIRE(std::filesystem::exists(root / "Cargoxx.lock"));
auto cmake_text = read_file(root / "build" / "CMakeLists.txt"); auto cmake_text = read_file(root / "build" / "CMakeLists.txt");
REQUIRE(cmake_text.find("project(hello LANGUAGES CXX)") != std::string::npos); REQUIRE(cmake_text.find("project(hello VERSION 0.1.0 LANGUAGES CXX)") != std::string::npos);
REQUIRE(cmake_text.find("add_executable(hello_bin ../src/main.cpp)") != REQUIRE(cmake_text.find("add_executable(hello_bin ../src/main.cpp)") !=
std::string::npos); std::string::npos);
auto flake_text = read_file(root / "flake.nix"); auto flake_text = read_file(root / "build" / "flake.nix");
REQUIRE(flake_text.find("description = \"hello\";") != std::string::npos); REQUIRE(flake_text.find("description = \"hello\";") != std::string::npos);
REQUIRE(flake_text.find("github:NixOS/nixpkgs/nixos-unstable") != std::string::npos); REQUIRE(flake_text.find("github:NixOS/nixpkgs/") != std::string::npos);
} }
TEST_CASE("cmd_build generates files for a library project", "[cli][build]") { TEST_CASE("cmd_build generates files for a library project", "[cli][build]") {
@@ -113,7 +113,7 @@ TEST_CASE("cmd_build resolves a manually-seeded dep into find_package + targets"
REQUIRE(cmake_text.find("find_package(fmt CONFIG REQUIRED)") != std::string::npos); REQUIRE(cmake_text.find("find_package(fmt CONFIG REQUIRED)") != std::string::npos);
REQUIRE(cmake_text.find("fmt::fmt") != std::string::npos); REQUIRE(cmake_text.find("fmt::fmt") != std::string::npos);
auto flake_text = read_file(root / "flake.nix"); auto flake_text = read_file(root / "build" / "flake.nix");
REQUIRE(flake_text.find("pkgs.fmt_10") != std::string::npos); REQUIRE(flake_text.find("pkgs.fmt_10") != std::string::npos);
} }
@@ -156,7 +156,7 @@ TEST_CASE("cmd_build fails for an unknown dep", "[cli][build]") {
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_FALSE(r.has_value()); REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::LinkdbUnknownPackage); REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
} }
TEST_CASE("cmd_build --release writes a release-typed build/CMakeLists", TEST_CASE("cmd_build --release writes a release-typed build/CMakeLists",
@@ -197,11 +197,11 @@ TEST_CASE("cmd_build is idempotent — second run produces identical files",
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");
auto first_flake = read_file(root / "flake.nix"); auto first_flake = read_file(root / "build" / "flake.nix");
auto first_lock = read_file(root / "Cargoxx.lock"); auto first_lock = read_file(root / "Cargoxx.lock");
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());
REQUIRE(read_file(root / "build" / "CMakeLists.txt") == first_cmake); REQUIRE(read_file(root / "build" / "CMakeLists.txt") == first_cmake);
REQUIRE(read_file(root / "flake.nix") == first_flake); REQUIRE(read_file(root / "build" / "flake.nix") == first_flake);
REQUIRE(read_file(root / "Cargoxx.lock") == first_lock); REQUIRE(read_file(root / "Cargoxx.lock") == first_lock);
} }

65
tests/cmd_linkdb_add.cpp Normal file
View File

@@ -0,0 +1,65 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.cli;
import cargoxx.linkdb;
import cargoxx.util;
import std;
using cargoxx::cli::cmd_linkdb_add;
using cargoxx::linkdb::Database;
namespace {
auto fresh_overlay() -> std::filesystem::path {
auto d = std::filesystem::temp_directory_path() /
std::format("cargoxx-linkdb-add-test-{}", std::random_device{}());
std::filesystem::create_directories(d);
return d / "overlay.sqlite";
}
} // namespace
TEST_CASE("cmd_linkdb_add inserts a recipe that resolve() can read back",
"[cli][linkdb_add]") {
auto overlay = fresh_overlay();
auto r = cmd_linkdb_add(
"sqlite3", "*", "SQLite3 REQUIRED", {"SQLite::SQLite3"}, "sqlite",
overlay);
REQUIRE(r.has_value());
auto db = Database::open(overlay);
REQUIRE(db.has_value());
auto rec = db->resolve("sqlite3", "*");
REQUIRE(rec.has_value());
REQUIRE(rec->source == "manual");
REQUIRE(rec->find_package == "SQLite3 REQUIRED");
REQUIRE(rec->targets == std::vector<std::string>{"SQLite::SQLite3"});
REQUIRE(rec->nixpkgs_attr == "sqlite");
}
TEST_CASE("cmd_linkdb_add evicts auto-discovered rows for the same package",
"[cli][linkdb_add]") {
auto overlay = fresh_overlay();
auto db = Database::open(overlay);
REQUIRE(db.has_value());
// Seed an auto-source row first.
cargoxx::linkdb::Recipe auto_recipe{
.nixpkgs_attr = "sqlite",
.find_package = "auto CONFIG REQUIRED",
.targets = {"auto::auto"},
.source = "nix-probe",
};
REQUIRE(db->insert_provisional("sqlite3", "*", auto_recipe, "nix-probe")
.has_value());
REQUIRE(db->confirm_provisional("sqlite3", "*", "nix-probe").has_value());
REQUIRE(cmd_linkdb_add("sqlite3", "*", "SQLite3 REQUIRED",
{"SQLite::SQLite3"}, "sqlite", overlay)
.has_value());
auto rec = db->resolve("sqlite3", "*");
REQUIRE(rec.has_value());
REQUIRE(rec->source == "manual");
}

View File

@@ -0,0 +1,93 @@
// Validation gates for `cargoxx publish` that fail BEFORE any network
// I/O. The publish flow performs `nix flake prefetch` + tea API calls,
// both of which need live infra. Everything tested here happens earlier
// — schema checks of Cargoxx.toml + Cargoxx.lock + git state.
#include <catch2/catch_test_macros.hpp>
import cargoxx.cli;
import cargoxx.manifest;
import cargoxx.lockfile;
import cargoxx.util;
import std;
using cargoxx::cli::cmd_publish;
using cargoxx::util::ErrorCode;
namespace fs = std::filesystem;
namespace manifest = cargoxx::manifest;
namespace lockfile = cargoxx::lockfile;
namespace {
auto fresh_dir() -> fs::path {
auto d = fs::temp_directory_path() /
std::format("cargoxx-publish-test-{}", std::random_device{}());
fs::create_directories(d);
return d;
}
auto write_manifest(const fs::path& dir, const manifest::Manifest& m) {
REQUIRE(manifest::write(m, dir / "Cargoxx.toml").has_value());
}
auto write_lock(const fs::path& dir) {
lockfile::Lockfile lock{
.version = 1,
.packages = {lockfile::LockfilePackage{.name = "foo", .version = "0.1.0"}},
};
REQUIRE(lockfile::write(lock, dir / "Cargoxx.lock").has_value());
}
auto minimal_pkg() -> manifest::Package {
return manifest::Package{
.name = "foo",
.version = "0.1.0",
.edition = manifest::Edition::Cpp23,
.license = "MIT",
};
}
} // namespace
TEST_CASE("publish rejects a manifest missing [package].license",
"[cli][publish]") {
auto root = fresh_dir();
auto pkg = minimal_pkg();
pkg.license = std::nullopt;
write_manifest(root, manifest::Manifest{pkg, {}, {}});
write_lock(root);
auto r = cmd_publish(root, /*dry_run=*/true);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
REQUIRE(r.error().message.find("license") != std::string::npos);
}
TEST_CASE("publish rejects a project without Cargoxx.lock", "[cli][publish]") {
auto root = fresh_dir();
write_manifest(root, manifest::Manifest{minimal_pkg(), {}, {}});
// No lockfile.
auto r = cmd_publish(root, /*dry_run=*/true);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestNotFound);
}
TEST_CASE("publish rejects path-form dependencies", "[cli][publish]") {
auto root = fresh_dir();
auto pkg = minimal_pkg();
manifest::Dependency dep{
.name = "sibling",
.version_spec = "*",
.source = manifest::DepSource::CargoxxPath,
.path = "../sibling",
};
write_manifest(root, manifest::Manifest{pkg, {dep}, {}});
write_lock(root);
auto r = cmd_publish(root, /*dry_run=*/true);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
REQUIRE(r.error().message.find("path dep") != std::string::npos);
}

View File

@@ -72,10 +72,15 @@ TEST_CASE("cmake_lists for a binary-only project", "[codegen][cmake]") {
GenerateInputs in{m, layout, lock, {}, {}, ROOT}; GenerateInputs in{m, layout, lock, {}, {}, ROOT};
auto out = cmake_lists(in); auto out = cmake_lists(in);
REQUIRE(out.find("project(hello LANGUAGES CXX)") != std::string::npos); REQUIRE(out.find("project(hello VERSION 0.1.0 LANGUAGES CXX)") != std::string::npos);
REQUIRE(out.find("include(GNUInstallDirs)") != std::string::npos);
REQUIRE(out.find("set(CMAKE_CXX_STANDARD 23)") != std::string::npos); REQUIRE(out.find("set(CMAKE_CXX_STANDARD 23)") != std::string::npos);
REQUIRE(out.find("add_executable(hello_bin ../src/main.cpp)") != std::string::npos); REQUIRE(out.find("add_executable(hello_bin ../src/main.cpp)") != std::string::npos);
REQUIRE(out.find("set_target_properties(hello_bin PROPERTIES OUTPUT_NAME hello)") != REQUIRE(out.find("set_target_properties(hello_bin PROPERTIES\n"
" OUTPUT_NAME hello\n"
" RUNTIME_OUTPUT_DIRECTORY \"${CMAKE_BINARY_DIR}/bin\")") !=
std::string::npos);
REQUIRE(out.find("install(TARGETS hello_bin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})") !=
std::string::npos); std::string::npos);
REQUIRE(out.find("add_library") == std::string::npos); REQUIRE(out.find("add_library") == std::string::npos);
REQUIRE(out.find("enable_testing") == std::string::npos); REQUIRE(out.find("enable_testing") == std::string::npos);
@@ -95,9 +100,18 @@ TEST_CASE("cmake_lists for a library-only project", "[codegen][cmake]") {
auto out = cmake_lists(in); auto out = cmake_lists(in);
REQUIRE(out.find("add_library(widget STATIC)") != std::string::npos); REQUIRE(out.find("add_library(widget STATIC)") != std::string::npos);
REQUIRE(out.find("FILE_SET CXX_MODULES FILES") != std::string::npos); REQUIRE(out.find("FILE_SET CXX_MODULES") != std::string::npos);
REQUIRE(out.find("../src/lib.cppm") != std::string::npos); REQUIRE(out.find("../src/lib.cppm") != std::string::npos);
REQUIRE(out.find("add_executable") == std::string::npos); REQUIRE(out.find("add_executable") == std::string::npos);
// Library projects emit install rules + Config.cmake + .pc.
REQUIRE(out.find("install(TARGETS widget\n EXPORT widgetTargets") !=
std::string::npos);
REQUIRE(out.find("install(EXPORT widgetTargets") != std::string::npos);
REQUIRE(out.find("configure_package_config_file(") != std::string::npos);
REQUIRE(out.find("write_basic_package_version_file(") != std::string::npos);
REQUIRE(out.find("widget.pc.in") != std::string::npos);
REQUIRE(out.find("DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig") !=
std::string::npos);
} }
TEST_CASE("cmake_lists wires up library + primary binary", "[codegen][cmake]") { TEST_CASE("cmake_lists wires up library + primary binary", "[codegen][cmake]") {
@@ -138,6 +152,13 @@ TEST_CASE("cmake_lists emits extra binaries from src/bin/", "[codegen][cmake]")
auto out = cmake_lists(in); auto out = cmake_lists(in);
REQUIRE(out.find("add_executable(app_bin ../src/main.cpp)") != std::string::npos); REQUIRE(out.find("add_executable(app_bin ../src/main.cpp)") != std::string::npos);
REQUIRE(out.find("add_executable(tool ../src/bin/tool.cpp)") != std::string::npos); REQUIRE(out.find("add_executable(tool ../src/bin/tool.cpp)") != std::string::npos);
REQUIRE(out.find("set_target_properties(app_bin PROPERTIES\n"
" OUTPUT_NAME app\n"
" RUNTIME_OUTPUT_DIRECTORY \"${CMAKE_BINARY_DIR}/bin\")") !=
std::string::npos);
REQUIRE(out.find("set_target_properties(tool PROPERTIES\n"
" RUNTIME_OUTPUT_DIRECTORY \"${CMAKE_BINARY_DIR}/bin\")") !=
std::string::npos);
} }
TEST_CASE("cmake_lists emits tests with add_test", "[codegen][cmake]") { TEST_CASE("cmake_lists emits tests with add_test", "[codegen][cmake]") {
@@ -290,7 +311,8 @@ TEST_CASE("cmake_lists emits baseline warnings", "[codegen][cmake]") {
GenerateInputs in{m, layout, lock, {}, {}, ROOT}; GenerateInputs in{m, layout, lock, {}, {}, ROOT};
auto out = cmake_lists(in); auto out = cmake_lists(in);
REQUIRE(out.find("add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)") != REQUIRE(out.find("add_compile_options(-Wall -Wextra -Wpedantic -Wconversion "
"-Wno-missing-field-initializers)") !=
std::string::npos); std::string::npos);
} }
@@ -342,3 +364,70 @@ TEST_CASE("cmake_lists threads dev_recipes through find_package and tests",
auto block = out.substr(link, end - link); auto block = out.substr(link, end - link);
REQUIRE(block.find("Catch2::Catch2WithMain") != std::string::npos); REQUIRE(block.find("Catch2::Catch2WithMain") != std::string::npos);
} }
TEST_CASE("cmake_lists emits pkg_check_modules for pkg_config recipes",
"[codegen][cmake]") {
Manifest m{
.package = pkg("app"),
.dependencies = {{.name = "sqlite", .version_spec = "*"}},
};
DiscoveredLayout layout{
.library = std::nullopt,
.binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")},
.tests = {},
.examples = {},
};
Lockfile lock = lock_minimal();
Recipe sqlite{
.nixpkgs_attr = "sqlite",
.find_package = "PkgConfig REQUIRED",
.targets = {"PkgConfig::SQLITE3"},
.source = "pkg-config",
.pkg_config_module = "sqlite3",
};
GenerateInputs in{m, layout, lock, {sqlite}, {}, ROOT};
auto out = cmake_lists(in);
REQUIRE(out.find("find_package(PkgConfig REQUIRED)") != std::string::npos);
REQUIRE(out.find("pkg_check_modules(SQLITE3 REQUIRED IMPORTED_TARGET sqlite3)") !=
std::string::npos);
auto link = out.find("target_link_libraries(app_bin PRIVATE");
REQUIRE(link != std::string::npos);
auto block = out.substr(link, out.find(')', link) - link);
REQUIRE(block.find("PkgConfig::SQLITE3") != std::string::npos);
}
TEST_CASE("cmake_lists synthesizes INTERFACE IMPORTED target for brute-force "
"recipes",
"[codegen][cmake]") {
Manifest m{
.package = pkg("app"),
.dependencies = {{.name = "obscure", .version_spec = "*"}},
};
DiscoveredLayout layout{
.library = std::nullopt,
.binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")},
.tests = {},
.examples = {},
};
Lockfile lock = lock_minimal();
Recipe brute{
.nixpkgs_attr = "obscure",
.find_package = "",
.targets = {"obscure::obscure"},
.source = "brute-force",
.brute_force_libs = {"/nix/store/abc-obscure/lib/libobscure.a"},
.brute_force_includes = {"/nix/store/abc-obscure/include"},
};
GenerateInputs in{m, layout, lock, {brute}, {}, ROOT};
auto out = cmake_lists(in);
REQUIRE(out.find("add_library(obscure::obscure INTERFACE IMPORTED)") !=
std::string::npos);
REQUIRE(out.find("INTERFACE_LINK_LIBRARIES") != std::string::npos);
REQUIRE(out.find("/nix/store/abc-obscure/lib/libobscure.a") !=
std::string::npos);
REQUIRE(out.find("INTERFACE_INCLUDE_DIRECTORIES") != std::string::npos);
REQUIRE(out.find("/nix/store/abc-obscure/include") != std::string::npos);
REQUIRE(out.find("find_package()") == std::string::npos);
}

View File

@@ -73,11 +73,42 @@ auto dep_pkg(std::string name, std::string version,
} // namespace } // namespace
TEST_CASE("flake_nix adds pkgs.pkg-config to nativeBuildInputs only when needed",
"[codegen][flake]") {
Manifest m{pkg("app"), {dep("sqlite", "*")}, {}};
DiscoveredLayout layout{};
Lockfile lock{.version = 1, .packages = {root_pkg("app", "0.1.0")}};
std::vector<Recipe> recipes = {Recipe{
.nixpkgs_attr = "sqlite",
.find_package = "PkgConfig REQUIRED",
.targets = {"PkgConfig::SQLITE3"},
.source = "pkg-config",
.pkg_config_module = "sqlite3",
}};
GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"};
auto out = flake_nix(in);
REQUIRE(out.find("pkgs.pkg-config") != std::string::npos);
REQUIRE(out.find("pkgs.sqlite") != std::string::npos);
}
TEST_CASE("flake_nix omits pkgs.pkg-config when no recipe needs it",
"[codegen][flake]") {
Manifest m{pkg("hello"), {dep("fmt", "*")}, {}};
DiscoveredLayout layout{};
Lockfile lock{.version = 1, .packages = {root_pkg("hello", "0.1.0")}};
std::vector<Recipe> recipes = {recipe("fmt_10")};
GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/hello"};
auto out = flake_nix(in);
REQUIRE(out.find("pkgs.pkg-config") == std::string::npos);
}
TEST_CASE("flake_nix always emits the shared nixos-unstable nixpkgs input", TEST_CASE("flake_nix always emits the shared nixos-unstable nixpkgs input",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("hello"), {}, {}}; Manifest m{pkg("hello"), {}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; Lockfile lock{.version = 1, .packages = {root_pkg("hello", "0.1.0")}};
GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"};
auto out = flake_nix(in); auto out = flake_nix(in);
@@ -91,7 +122,7 @@ TEST_CASE("flake_nix always emits the shared nixos-unstable nixpkgs input",
TEST_CASE("flake_nix emits a per-pinned-dep nixpkgs input", "[codegen][flake]") { TEST_CASE("flake_nix emits a per-pinned-dep nixpkgs input", "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "10.2.1")}, {}}; Manifest m{pkg("app"), {dep("fmt", "10.2.1")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("fmt", "10.2.1", "abc123def456"), dep_pkg("fmt", "10.2.1", "abc123def456"),
}}; }};
@@ -115,7 +146,7 @@ TEST_CASE("flake_nix uses shared `pkgs` for unpinned deps",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "*")}, {}}; Manifest m{pkg("app"), {dep("fmt", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("app", "0.1.0"), dep_pkg("fmt", "*", std::nullopt)}}; Lockfile lock{.version = 1, .packages = {root_pkg("app", "0.1.0"), dep_pkg("fmt", "*", std::nullopt)}};
std::vector<Recipe> recipes = {recipe("fmt_10")}; std::vector<Recipe> recipes = {recipe("fmt_10")};
GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"}; GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"};
@@ -127,7 +158,7 @@ TEST_CASE("flake_nix uses shared `pkgs` for unpinned deps",
TEST_CASE("flake_nix mixes pinned and unpinned deps", "[codegen][flake]") { TEST_CASE("flake_nix mixes pinned and unpinned deps", "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("zlib", "*")}, {}}; Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("zlib", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("fmt", "10.2.1", "abc"), dep_pkg("fmt", "10.2.1", "abc"),
dep_pkg("zlib", "*", std::nullopt), dep_pkg("zlib", "*", std::nullopt),
@@ -144,7 +175,7 @@ TEST_CASE("flake_nix emits an empty buildInputs list when there are no deps",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("hello"), {}, {}}; Manifest m{pkg("hello"), {}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; Lockfile lock{.version = 1, .packages = {root_pkg("hello", "0.1.0")}};
GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"};
auto out = flake_nix(in); auto out = flake_nix(in);
@@ -157,7 +188,7 @@ TEST_CASE("flake_nix dedupes deps that share input + attr",
{dep("boost", "1.84.0"), dep("boost", "1.84.0")}, {dep("boost", "1.84.0"), dep("boost", "1.84.0")},
{}}; {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("boost", "1.84.0", "rev42"), dep_pkg("boost", "1.84.0", "rev42"),
}}; }};
@@ -174,7 +205,7 @@ TEST_CASE("flake_nix dedupes deps that share input + attr",
TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") { TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("spdlog", "*")}, {}}; Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("spdlog", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("fmt", "10.2.1", "abc"), dep_pkg("fmt", "10.2.1", "abc"),
dep_pkg("spdlog", "*", std::nullopt), dep_pkg("spdlog", "*", std::nullopt),
@@ -188,7 +219,7 @@ TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") {
TEST_CASE("flake_nix output ends with a newline", "[codegen][flake]") { TEST_CASE("flake_nix output ends with a newline", "[codegen][flake]") {
Manifest m{pkg("hello"), {}, {}}; Manifest m{pkg("hello"), {}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; Lockfile lock{.version = 1, .packages = {root_pkg("hello", "0.1.0")}};
GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"};
auto out = flake_nix(in); auto out = flake_nix(in);
@@ -200,7 +231,7 @@ TEST_CASE("flake_nix sanitizes hyphens and dots in dep names",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("app"), {dep("range-v3", "0.12.0")}, {}}; Manifest m{pkg("app"), {dep("range-v3", "0.12.0")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, { Lockfile lock{.version = 1, .packages = {
root_pkg("app", "0.1.0"), root_pkg("app", "0.1.0"),
dep_pkg("range-v3", "0.12.0", "rev123"), dep_pkg("range-v3", "0.12.0", "rev123"),
}}; }};

View File

@@ -0,0 +1,7 @@
[package]
name = "e2e_demo"
version = "0.1.0"
edition = "cpp23"
[dependencies]
nlohmann_json = "*"

View File

@@ -0,0 +1,10 @@
{
description = "e2e buildCppPackage smoke";
inputs.cargoxx.url = "path:../../..";
outputs = { self, cargoxx }: {
packages.x86_64-linux.default =
cargoxx.lib.x86_64-linux.buildCppPackage { src = ./.; };
};
}

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${here}/../../.." && pwd)"
cargoxx_bin="${CARGOXX_BIN:-${repo}/build/debug/cargoxx}"
if [[ ! -x "${cargoxx_bin}" ]]; then
echo "error: cargoxx binary not found at ${cargoxx_bin}" >&2
echo "build it first: nix develop --command cmake --build build/debug" >&2
exit 1
fi
work="$(mktemp -d -t cargoxx-e2e-XXXXXX)"
trap 'rm -rf "${work}"' EXIT
cp -r "${here}/." "${work}/"
sed -i "s|path:\\.\\./\\.\\./\\.\\.|path:${repo}|" "${work}/flake.nix"
cd "${work}"
echo "=== cargoxx build --no-build"
"${cargoxx_bin}" build --no-build
[[ -f Cargoxx.lock ]] || { echo "Cargoxx.lock missing"; exit 1; }
[[ -f build/flake.nix ]] || { echo "build/flake.nix missing"; exit 1; }
echo "=== nix build .#default"
out="$(nix build .#default --no-link --print-out-paths \
--extra-experimental-features 'nix-command flakes')"
[[ -n "${out}" ]] || { echo "nix build produced no output path"; exit 1; }
[[ -x "${out}/bin/e2e_demo" ]] || { echo "missing ${out}/bin/e2e_demo"; exit 1; }
echo "=== execute"
"${out}/bin/e2e_demo"
echo "ok"

View File

@@ -0,0 +1,8 @@
#include <nlohmann/json.hpp>
import std;
int main() {
nlohmann::json j;
j["from"] = "extra";
std::println("{}", j["from"].get<std::string>());
return 0;
}

View File

@@ -0,0 +1,9 @@
#include <nlohmann/json.hpp>
import std;
int main() {
nlohmann::json j;
j["hello"] = "world";
std::println("Hello from {}!", j["hello"].get<std::string>());
return 0;
}

View File

@@ -0,0 +1,7 @@
[package]
name = "consumer"
version = "0.1.0"
edition = "cpp23"
[dependencies]
greeter = { path = "./greeter" }

View File

@@ -0,0 +1,10 @@
{
description = "e2e cargoxx path-dep smoke";
inputs.cargoxx.url = "path:../../..";
outputs = { self, cargoxx }: {
packages.x86_64-linux.default =
cargoxx.lib.x86_64-linux.buildCppPackage { src = ./.; };
};
}

View File

@@ -0,0 +1,4 @@
[package]
name = "greeter"
version = "0.1.0"
edition = "cpp23"

View File

@@ -0,0 +1,8 @@
export module greeter;
import std;
export namespace greeter {
auto hello(std::string_view who) -> std::string {
return std::format("Hello from greeter, {}!", who);
}
} // namespace greeter

48
tests/e2e/pathDep/run.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${here}/../../.." && pwd)"
cargoxx_bin="${CARGOXX_BIN:-${repo}/build/debug/cargoxx}"
if [[ ! -x "${cargoxx_bin}" ]]; then
echo "error: cargoxx binary not found at ${cargoxx_bin}" >&2
echo "build it first: nix develop --command cmake --build build/debug" >&2
exit 1
fi
work="$(mktemp -d -t cargoxx-e2e-pathdep-XXXXXX)"
trap 'rm -rf "${work}"' EXIT
cp -r "${here}/." "${work}/"
sed -i "s|path:\\.\\./\\.\\./\\.\\.|path:${repo}|" "${work}/flake.nix"
cd "${work}"
echo "=== cargoxx build --no-build in greeter (path dep generates its own lock)"
(cd greeter && "${cargoxx_bin}" build --no-build)
echo "=== cargoxx build --no-build in consumer"
"${cargoxx_bin}" build --no-build
[[ -f Cargoxx.lock ]] || { echo "Cargoxx.lock missing"; exit 1; }
grep -q "source_kind = 'cargoxx-path'" Cargoxx.lock || \
{ echo "Cargoxx.lock missing source_kind = cargoxx-path"; exit 1; }
# nix build needs the source tree to be a git tree so 'path:' input copies
# Cargoxx.lock into the store. Init a throwaway git here.
git init -q
git add -A
git -c user.email=e2e@cargoxx -c user.name=e2e commit -q -m fixture
echo "=== nix build .#default"
out="$(nix build .#default --no-link --print-out-paths \
--extra-experimental-features 'nix-command flakes')"
[[ -n "${out}" ]] || { echo "nix build produced no output path"; exit 1; }
[[ -x "${out}/bin/consumer" ]] || { echo "missing ${out}/bin/consumer"; exit 1; }
echo "=== execute"
"${out}/bin/consumer"
echo "ok"

View File

@@ -0,0 +1,7 @@
import std;
import greeter;
int main() {
std::println("{}", greeter::hello("world"));
return 0;
}

View File

@@ -0,0 +1,67 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.resolver;
import cargoxx.util;
import std;
using cargoxx::resolver::findmodule_scan;
using cargoxx::util::ErrorCode;
// `findmodule_scan` shells out to `cmake -P` to discover CMAKE_ROOT.
// Gate the test on the same env var we use for other live probes so
// CI without cmake can still pass.
namespace {
auto live_tests_enabled() -> bool {
auto* e = std::getenv("CARGOXX_NETWORK_TESTS");
return e != nullptr && *e != 0;
}
} // namespace
TEST_CASE("findmodule_scan picks FindSQLite3 for 'sqlite'",
"[resolver][findmodule_scan][live]") {
if (!live_tests_enabled()) {
SKIP("CARGOXX_NETWORK_TESTS not set");
}
auto r = findmodule_scan("sqlite");
REQUIRE(r.has_value());
REQUIRE(r->find_package == "SQLite3 REQUIRED");
REQUIRE_FALSE(r->targets.empty());
auto has = [&](std::string_view t) {
return std::ranges::find(r->targets, std::string{t}) != r->targets.end();
};
REQUIRE((has("SQLite3::SQLite3") || has("SQLite::SQLite3")));
REQUIRE(r->module_file.filename() == "FindSQLite3.cmake");
}
TEST_CASE("findmodule_scan picks FindZLIB for 'zlib'",
"[resolver][findmodule_scan][live]") {
if (!live_tests_enabled()) {
SKIP("CARGOXX_NETWORK_TESTS not set");
}
auto r = findmodule_scan("zlib");
REQUIRE(r.has_value());
REQUIRE(r->find_package == "ZLIB REQUIRED");
REQUIRE(r->module_file.filename() == "FindZLIB.cmake");
}
TEST_CASE("findmodule_scan picks FindThreads for 'threads'",
"[resolver][findmodule_scan][live]") {
if (!live_tests_enabled()) {
SKIP("CARGOXX_NETWORK_TESTS not set");
}
auto r = findmodule_scan("threads");
REQUIRE(r.has_value());
REQUIRE(r->find_package == "Threads REQUIRED");
}
TEST_CASE("findmodule_scan errors for a totally unknown name",
"[resolver][findmodule_scan][live]") {
if (!live_tests_enabled()) {
SKIP("CARGOXX_NETWORK_TESTS not set");
}
auto r = findmodule_scan("definitelynotacmaketmodule12345");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
}

View File

@@ -0,0 +1,59 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.resolver;
import std;
using cargoxx::resolver::last_failure_dir;
namespace {
struct EnvScope {
EnvScope(const char* k, std::optional<std::string> v) : key(k) {
if (auto* prior = std::getenv(key)) {
previous = std::string{prior};
}
if (v) {
setenv(key, v->c_str(), 1);
} else {
unsetenv(key);
}
}
~EnvScope() {
if (previous) {
setenv(key, previous->c_str(), 1);
} else {
unsetenv(key);
}
}
const char* key;
std::optional<std::string> previous;
};
} // namespace
TEST_CASE("last_failure_dir honors XDG_CACHE_HOME when set",
"[resolver][last_failure_dir]") {
EnvScope xdg{"XDG_CACHE_HOME", "/tmp/xdg-test"};
auto p = last_failure_dir("sqlite");
REQUIRE(p.string() == "/tmp/xdg-test/cargoxx/last-failure/sqlite");
}
TEST_CASE("last_failure_dir falls back to $HOME/.cache when XDG is unset",
"[resolver][last_failure_dir]") {
EnvScope xdg{"XDG_CACHE_HOME", std::nullopt};
EnvScope home{"HOME", "/tmp/home-test"};
auto p = last_failure_dir("fmt");
REQUIRE(p.string() == "/tmp/home-test/.cache/cargoxx/last-failure/fmt");
}
TEST_CASE("last_failure_dir uses cwd-based fallback when neither var is set",
"[resolver][last_failure_dir]") {
EnvScope xdg{"XDG_CACHE_HOME", std::nullopt};
EnvScope home{"HOME", std::nullopt};
auto p = last_failure_dir("obscure");
REQUIRE(p.filename() == "obscure");
REQUIRE(p.parent_path().filename() == ".cargoxx-last-failure");
}

View File

@@ -131,16 +131,31 @@ TEST_CASE("discover lists src/bin/*.cpp as additional binaries", "[layout]") {
REQUIRE(r->binaries[2].name == "pkg"); REQUIRE(r->binaries[2].name == "pkg");
} }
TEST_CASE("discover does not recurse into src/bin/", "[layout]") { TEST_CASE("discover ignores src/bin/<sub>/ subdirs without main.cpp", "[layout]") {
TempProject p; TempProject p;
p.touch("src/main.cpp"); p.touch("src/main.cpp");
p.touch("src/bin/foo.cpp"); p.touch("src/bin/foo.cpp");
p.touch("src/bin/sub/nested.cpp"); // should be ignored p.touch("src/bin/sub/nested.cpp"); // no main.cpp at sub/ root, ignored
auto r = discover(p.root(), "pkg"); auto r = discover(p.root(), "pkg");
REQUIRE(r.has_value()); REQUIRE(r.has_value());
REQUIRE(r->binaries.size() == 2); REQUIRE(r->binaries.size() == 2);
} }
TEST_CASE("discover lists src/bin/<sub>/main.cpp as a binary named <sub>",
"[layout]") {
TempProject p;
p.touch("src/main.cpp");
p.touch("src/bin/extra/main.cpp");
p.touch("src/bin/extra/helpers.cpp"); // not collected in v1
auto r = discover(p.root(), "pkg");
REQUIRE(r.has_value());
REQUIRE(r->binaries.size() == 2);
REQUIRE(r->binaries[0].name == "extra");
REQUIRE(r->binaries[0].entry.filename() == "main.cpp");
REQUIRE(r->binaries[0].entry.parent_path().filename() == "extra");
REQUIRE(r->binaries[1].name == "pkg");
}
TEST_CASE("discover lists tests/*.cpp", "[layout]") { TEST_CASE("discover lists tests/*.cpp", "[layout]") {
TempProject p; TempProject p;
p.touch("src/main.cpp"); p.touch("src/main.cpp");

36
tests/levenshtein.cpp Normal file
View File

@@ -0,0 +1,36 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.util;
import std;
using cargoxx::util::levenshtein;
TEST_CASE("levenshtein of equal strings is zero", "[util][levenshtein]") {
REQUIRE(levenshtein("", "") == 0);
REQUIRE(levenshtein("sqlite", "sqlite") == 0);
}
TEST_CASE("levenshtein counts a single suffix character", "[util][levenshtein]") {
REQUIRE(levenshtein("sqlite", "sqlite3") == 1);
REQUIRE(levenshtein("fmt", "fmtlib") == 3);
}
TEST_CASE("levenshtein is symmetric", "[util][levenshtein]") {
REQUIRE(levenshtein("sqlite", "sqlite3") == levenshtein("sqlite3", "sqlite"));
REQUIRE(levenshtein("abseil-cpp", "absl") == levenshtein("absl", "abseil-cpp"));
}
TEST_CASE("levenshtein counts a single substitution", "[util][levenshtein]") {
REQUIRE(levenshtein("kitten", "sitten") == 1);
REQUIRE(levenshtein("kitten", "kittes") == 1);
}
TEST_CASE("levenshtein matches the classic kitten/sitting example",
"[util][levenshtein]") {
REQUIRE(levenshtein("kitten", "sitting") == 3);
}
TEST_CASE("levenshtein handles empty inputs", "[util][levenshtein]") {
REQUIRE(levenshtein("", "abc") == 3);
REQUIRE(levenshtein("abc", "") == 3);
}

View File

@@ -54,14 +54,14 @@ auto round_trip(const Lockfile& l) -> Lockfile {
} // namespace } // namespace
TEST_CASE("write round-trips a minimal lockfile", "[lockfile]") { TEST_CASE("write round-trips a minimal lockfile", "[lockfile]") {
Lockfile l{1, {root_pkg("my-project", "0.1.0")}}; Lockfile l{.version = 1, .packages = {root_pkg("my-project", "0.1.0")}};
REQUIRE(round_trip(l) == l); REQUIRE(round_trip(l) == l);
} }
TEST_CASE("write round-trips a lockfile with deps", "[lockfile]") { TEST_CASE("write round-trips a lockfile with deps", "[lockfile]") {
Lockfile l{ Lockfile l{
1, .version = 1,
{ .packages = {
root_pkg("my-project", "0.1.0", {"fmt 10.2.1", "spdlog 1.13.0"}), root_pkg("my-project", "0.1.0", {"fmt 10.2.1", "spdlog 1.13.0"}),
dep_pkg("fmt", "10.2.1", "fmt_10", "8a3f...c2d1"), dep_pkg("fmt", "10.2.1", "fmt_10", "8a3f...c2d1"),
dep_pkg("spdlog", "1.13.0", "spdlog", "8a3f...c2d1"), dep_pkg("spdlog", "1.13.0", "spdlog", "8a3f...c2d1"),
@@ -70,10 +70,109 @@ TEST_CASE("write round-trips a lockfile with deps", "[lockfile]") {
REQUIRE(round_trip(l) == l); REQUIRE(round_trip(l) == l);
} }
TEST_CASE("write round-trips lockfile recipe fields", "[lockfile]") {
Lockfile l{
.version = 1,
.packages = {
LockfilePackage{
.name = "fmt",
.version = "10.2.1",
.dependencies = {},
.nixpkgs_attr = "fmt_10",
.nixpkgs_rev = std::nullopt,
.linkdb_source = "conan",
.find_package = "fmt CONFIG REQUIRED",
.targets = {"fmt::fmt"},
.pkg_config_module = std::nullopt,
.brute_force_libs = {},
.brute_force_includes = {},
},
LockfilePackage{
.name = "sqlite",
.version = "*",
.dependencies = {},
.nixpkgs_attr = "sqlite",
.nixpkgs_rev = std::nullopt,
.linkdb_source = "pkg-config",
.find_package = "PkgConfig REQUIRED",
.targets = {"PkgConfig::SQLITE3"},
.pkg_config_module = "sqlite3",
.brute_force_libs = {},
.brute_force_includes = {},
},
LockfilePackage{
.name = "obscure",
.version = "*",
.dependencies = {},
.nixpkgs_attr = "obscure",
.nixpkgs_rev = std::nullopt,
.linkdb_source = "brute-force",
.find_package = "",
.targets = {"obscure::obscure"},
.pkg_config_module = std::nullopt,
.brute_force_libs = {"/nix/store/abc/lib/libobscure.a"},
.brute_force_includes = {"/nix/store/abc/include"},
},
},
};
REQUIRE(round_trip(l) == l);
}
TEST_CASE("write round-trips cargoxx-path source fields", "[lockfile]") {
Lockfile l{
.version = 1,
.packages = {
LockfilePackage{
.name = "mylib",
.version = "*",
.dependencies = {},
.nixpkgs_attr = std::nullopt,
.nixpkgs_rev = std::nullopt,
.linkdb_source = "cargoxx-path",
.find_package = "mylib CONFIG REQUIRED",
.targets = {"mylib::mylib"},
.pkg_config_module = std::nullopt,
.brute_force_libs = {},
.brute_force_includes = {},
.source_kind = "cargoxx-path",
.source_path = "../mylib",
},
},
};
REQUIRE(round_trip(l) == l);
}
TEST_CASE("write round-trips cargoxx-git source fields", "[lockfile]") {
Lockfile l{
.version = 1,
.packages = {
LockfilePackage{
.name = "mylib",
.version = "*",
.dependencies = {},
.nixpkgs_attr = std::nullopt,
.nixpkgs_rev = std::nullopt,
.linkdb_source = "cargoxx-git",
.find_package = "mylib CONFIG REQUIRED",
.targets = {"mylib::mylib"},
.pkg_config_module = std::nullopt,
.brute_force_libs = {},
.brute_force_includes = {},
.source_kind = "cargoxx-git",
.source_path = std::nullopt,
.source_git_url = "https://gitea.example/me/mylib",
.source_git_commit = "0123456789012345678901234567890123456789",
.source_git_sha256 = "sha256-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123=",
},
},
};
REQUIRE(round_trip(l) == l);
}
TEST_CASE("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") { TEST_CASE("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") {
Lockfile l{ Lockfile l{
1, .version = 1,
{ .packages = {
root_pkg("p", "0.1.0", {"fmt 10.2.1"}), root_pkg("p", "0.1.0", {"fmt 10.2.1"}),
dep_pkg("fmt", "10.2.1", "fmt_10", "abc123"), dep_pkg("fmt", "10.2.1", "fmt_10", "abc123"),
}, },
@@ -81,8 +180,19 @@ TEST_CASE("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") {
REQUIRE(l.nixpkgs_rev() == "abc123"); REQUIRE(l.nixpkgs_rev() == "abc123");
} }
TEST_CASE("write round-trips top-level nixpkgs_rev + flake_utils_rev pins",
"[lockfile]") {
Lockfile l{
.version = 1,
.nixpkgs_rev_pin = "549bd84d6279f9852cae6225e372cc67fb91a4c1",
.flake_utils_rev_pin = "11707dc2f618dd54ca8739b309ec4fc024de578b",
.packages = {root_pkg("p", "0.1.0")},
};
REQUIRE(round_trip(l) == l);
}
TEST_CASE("Lockfile::nixpkgs_rev is nullopt when no deps", "[lockfile]") { TEST_CASE("Lockfile::nixpkgs_rev is nullopt when no deps", "[lockfile]") {
Lockfile l{1, {root_pkg("p", "0.1.0")}}; Lockfile l{.version = 1, .packages = {root_pkg("p", "0.1.0")}};
REQUIRE_FALSE(l.nixpkgs_rev().has_value()); REQUIRE_FALSE(l.nixpkgs_rev().has_value());
} }

View File

@@ -181,16 +181,21 @@ optimize = "max"
REQUIRE(r.error().code == ErrorCode::ManifestUnknownField); REQUIRE(r.error().code == ErrorCode::ManifestUnknownField);
} }
TEST_CASE("parse accepts reserved [package] fields", "[manifest][parse]") { TEST_CASE("parse stores description / repository / homepage on Package",
"[manifest][parse]") {
auto p = write_manifest(R"( auto p = write_manifest(R"(
[package] [package]
name = "foo" name = "foo"
version = "0.1.0" version = "0.1.0"
description = "demo" description = "demo"
repository = "https://example.com/foo" repository = "https://example.com/foo"
homepage = "https://example.com"
)"); )");
auto r = parse(p); auto r = parse(p);
REQUIRE(r.has_value()); REQUIRE(r.has_value());
REQUIRE(r->package.description == "demo");
REQUIRE(r->package.repository == "https://example.com/foo");
REQUIRE(r->package.homepage == "https://example.com");
} }
TEST_CASE("parse accepts reserved top-level tables", "[manifest][parse]") { TEST_CASE("parse accepts reserved top-level tables", "[manifest][parse]") {
@@ -297,3 +302,79 @@ version = "0.1.0"
REQUIRE_FALSE(r.has_value()); REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField); REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
} }
TEST_CASE("parse recognizes { path = \"...\" } as a cargoxx path dep",
"[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "consumer"
version = "0.1.0"
edition = "cpp23"
[dependencies]
mylib = { path = "../mylib" }
)");
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->dependencies.size() == 1);
const auto& dep = r->dependencies[0];
REQUIRE(dep.name == "mylib");
REQUIRE(dep.source == cargoxx::manifest::DepSource::CargoxxPath);
REQUIRE(dep.path.has_value());
REQUIRE(*dep.path == "../mylib");
// Version defaults to "*" when only `path` is given.
REQUIRE(dep.version_spec == "*");
}
TEST_CASE("parse rejects dep table without version or path",
"[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "consumer"
version = "0.1.0"
edition = "cpp23"
[dependencies]
mylib = { components = ["a"] }
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
}
TEST_CASE("parse recognizes { git = \"...\", rev = \"...\" } as a cargoxx git dep",
"[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "consumer"
version = "0.1.0"
edition = "cpp23"
[dependencies]
mylib = { git = "https://gitea.example/me/mylib", rev = "0123456789012345678901234567890123456789" }
)");
auto r = parse(p);
REQUIRE(r.has_value());
REQUIRE(r->dependencies.size() == 1);
const auto& dep = r->dependencies[0];
REQUIRE(dep.name == "mylib");
REQUIRE(dep.source == cargoxx::manifest::DepSource::CargoxxGit);
REQUIRE(dep.git_url == "https://gitea.example/me/mylib");
REQUIRE(dep.git_rev == "0123456789012345678901234567890123456789");
REQUIRE(dep.version_spec == "*");
}
TEST_CASE("parse rejects git dep without rev", "[manifest][parse]") {
auto p = write_manifest(R"(
[package]
name = "consumer"
version = "0.1.0"
edition = "cpp23"
[dependencies]
mylib = { git = "https://gitea.example/me/mylib" }
)");
auto r = parse(p);
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ManifestInvalidField);
}

View File

@@ -138,3 +138,44 @@ TEST_CASE("write fails when the target directory does not exist",
auto r = write(m, "/nonexistent/dir/Cargoxx.toml"); auto r = write(m, "/nonexistent/dir/Cargoxx.toml");
REQUIRE_FALSE(r.has_value()); REQUIRE_FALSE(r.has_value());
} }
TEST_CASE("write round-trips a path-form dependency", "[manifest][write]") {
Manifest m{
pkg("consumer", "0.1.0"),
{Dependency{
.name = "mylib",
.version_spec = "*",
.components = {},
.source = cargoxx::manifest::DepSource::CargoxxPath,
.path = "../mylib",
}},
{},
};
REQUIRE(round_trip(m) == m);
}
TEST_CASE("write round-trips a git-form dependency", "[manifest][write]") {
Manifest m{
pkg("consumer", "0.1.0"),
{Dependency{
.name = "mylib",
.version_spec = "*",
.components = {},
.source = cargoxx::manifest::DepSource::CargoxxGit,
.git_url = "https://gitea.example/me/mylib",
.git_rev = "0123456789012345678901234567890123456789",
}},
{},
};
REQUIRE(round_trip(m) == m);
}
TEST_CASE("write round-trips description/repository/homepage",
"[manifest][write]") {
auto p = pkg("foo", "0.1.0");
p.description = "demo library";
p.repository = "https://gitea.example/me/foo";
p.homepage = "https://example.com/foo";
Manifest m{p, {}, {}};
REQUIRE(round_trip(m) == m);
}

89
tests/pc_scan_parse.cpp Normal file
View File

@@ -0,0 +1,89 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.resolver;
import cargoxx.util;
import std;
using cargoxx::resolver::pc_scan;
using cargoxx::util::ErrorCode;
namespace {
auto fresh_store() -> std::filesystem::path {
auto d = std::filesystem::temp_directory_path() /
std::format("cargoxx-pc-scan-{}", std::random_device{}());
std::filesystem::create_directories(d / "lib" / "pkgconfig");
return d;
}
void touch_pc(const std::filesystem::path& store, std::string_view name,
std::string_view content =
"Name: x\nDescription: x\nLibs: -lx\n") {
std::ofstream{store / "lib" / "pkgconfig" / std::string{name}} << content;
}
} // namespace
TEST_CASE("pc_scan picks the exact-name .pc when present",
"[resolver][pc_scan]") {
auto store = fresh_store();
touch_pc(store, "sqlite3.pc");
auto r = pc_scan(store, "sqlite3");
REQUIRE(r.has_value());
REQUIRE(r->pc_module == "sqlite3");
REQUIRE(r->pc_file.filename() == "sqlite3.pc");
}
TEST_CASE("pc_scan picks sqlite3.pc for nixpkgs name 'sqlite'",
"[resolver][pc_scan]") {
auto store = fresh_store();
touch_pc(store, "sqlite3.pc");
auto r = pc_scan(store, "sqlite");
REQUIRE(r.has_value());
REQUIRE(r->pc_module == "sqlite3");
}
TEST_CASE("pc_scan picks the best match among multiple .pc files",
"[resolver][pc_scan]") {
auto store = fresh_store();
touch_pc(store, "zlib.pc");
touch_pc(store, "sqlite3.pc");
touch_pc(store, "unrelated.pc");
auto r = pc_scan(store, "zlib");
REQUIRE(r.has_value());
REQUIRE(r->pc_module == "zlib");
}
TEST_CASE("pc_scan returns ResolutionUnknownPackage when nothing matches",
"[resolver][pc_scan]") {
auto store = fresh_store();
touch_pc(store, "totally-unrelated.pc");
auto r = pc_scan(store, "sqlite");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
}
TEST_CASE("pc_scan errors when lib/pkgconfig is missing",
"[resolver][pc_scan]") {
auto d = std::filesystem::temp_directory_path() /
std::format("cargoxx-pc-empty-{}", std::random_device{}());
std::filesystem::create_directories(d);
auto r = pc_scan(d, "sqlite");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
}
TEST_CASE("pc_scan skips .pc files that look like junk",
"[resolver][pc_scan]") {
auto store = fresh_store();
touch_pc(store, "sqlite3.pc", "this is not a real pc file\n");
auto r = pc_scan(store, "sqlite3");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
}

View File

@@ -41,7 +41,7 @@ auto make_request(const std::filesystem::path& parent) -> VerifyLinkRequest {
.version_spec = "*", .version_spec = "*",
.components = {}, .components = {},
.overlay_path = parent / "overlay.sqlite", .overlay_path = parent / "overlay.sqlite",
.scratch_root = parent / "scratch", .scratch_path = parent / "scratch" / "01-conan",
}; };
} }
@@ -101,7 +101,8 @@ TEST_CASE("verify_link rolls the provisional row back when the build fails",
REQUIRE(rec.error().code == cargoxx::util::ErrorCode::LinkdbUnknownPackage); REQUIRE(rec.error().code == cargoxx::util::ErrorCode::LinkdbUnknownPackage);
} }
TEST_CASE("verify_link cleans up its scratch project", "[resolver][verify_link]") { TEST_CASE("verify_link preserves its scratch project for inspection",
"[resolver][verify_link]") {
auto parent = fresh_dir(); auto parent = fresh_dir();
auto req = make_request(parent); auto req = make_request(parent);
std::filesystem::path captured; std::filesystem::path captured;
@@ -111,6 +112,7 @@ TEST_CASE("verify_link cleans up its scratch project", "[resolver][verify_link]"
return cargoxx::util::Result<void>{}; return cargoxx::util::Result<void>{};
}); });
REQUIRE_FALSE(captured.empty()); REQUIRE(captured == req.scratch_path);
REQUIRE_FALSE(std::filesystem::exists(captured)); REQUIRE(std::filesystem::exists(captured / "Cargoxx.toml"));
REQUIRE(std::filesystem::exists(captured / "src" / "main.cpp"));
} }