Compare commits
25 Commits
a757149f99
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bb39a64c1 | |||
| 9d33379f94 | |||
| f9932a3ad9 | |||
| 3138e78f47 | |||
| 09f151ad82 | |||
| e6c39914b3 | |||
| fdf97861a4 | |||
| 1f63984b60 | |||
| 85417f317c | |||
| 43a7d1f09d | |||
| 7c10ea2382 | |||
| f62cff49c6 | |||
| 815e5b1be2 | |||
| db1c9eb36d | |||
| f90bcfbff7 | |||
| 65a749f088 | |||
| 94e658fdf1 | |||
| 01b3c28d6c | |||
| 8bbfcf7657 | |||
| 8b396bcd0f | |||
| c46f3aa1f0 | |||
| 73aebf183e | |||
| 9b6014b82d | |||
| 6c4933f282 | |||
| 4f9b6f1827 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,7 +1,15 @@
|
||||
# Build outputs
|
||||
/build/
|
||||
/build/*
|
||||
!/build/CMakeLists.txt
|
||||
/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
|
||||
CMakeCache.txt
|
||||
|
||||
222
CHANGELOG.md
222
CHANGELOG.md
@@ -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,
|
||||
override-curated, version-range gating, components rejection,
|
||||
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`.
|
||||
|
||||
@@ -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
32
Cargoxx.lock
Normal 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 = '*'
|
||||
@@ -5,11 +5,12 @@ edition = "cpp23"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
sqlite3 = "*"
|
||||
sqlite = "*"
|
||||
reproc = "*"
|
||||
|
||||
[dev-dependencies]
|
||||
catch2 = "*"
|
||||
catch2_3 = "*"
|
||||
|
||||
[build]
|
||||
warnings_as_errors = false
|
||||
include_dirs = ["third_party"]
|
||||
|
||||
101
README.md
101
README.md
@@ -1,23 +1,104 @@
|
||||
# cargoxx
|
||||
|
||||
A Cargo-style frontend for modern C++ that uses Nix as the source of truth for
|
||||
dependencies and generates CMake for the build. Users author projects with C++23
|
||||
modules and never touch CMake or `flake.nix` by hand.
|
||||
A Cargo-style frontend for modern C++ that uses Nix as the dependency
|
||||
source of truth and generates CMake for the build. Users author projects
|
||||
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
|
||||
[`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
|
||||
|
||||
cargoxx builds inside a Nix-managed dev shell that pins the toolchain
|
||||
(Clang with C++23 modules + `import std;`, CMake ≥ 3.30, Ninja).
|
||||
cargoxx is self-hosted, so the bootstrap path requires an existing
|
||||
cargoxx binary (release artifact or one built from a prior commit) plus
|
||||
a Nix toolchain.
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build
|
||||
cargoxx build # regenerate build/CMakeLists.txt + root flake.nix
|
||||
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.
|
||||
|
||||
17
TECH_SPEC.md
17
TECH_SPEC.md
@@ -619,18 +619,17 @@ User-facing errors are formatted via `util::format(Error)` and printed to stderr
|
||||
|
||||
## 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).**
|
||||
`CMakeLists.txt` and `flake.nix` at the repo root are written by humans. `cargoxx` builds `cargoxx`.
|
||||
**Phase 2 (current, since M6) — fully self-hosted.**
|
||||
`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.**
|
||||
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.
|
||||
Bootstrap path:
|
||||
```
|
||||
pre-built cargoxx → cargoxx build → next cargoxx
|
||||
```
|
||||
|
||||
**Phase 2 — fully self-hosted.**
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
474
build/CMakeLists.txt
Normal file
474
build/CMakeLists.txt
Normal 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)
|
||||
448
docs/library-reuse-and-publish.md
Normal file
448
docs/library-reuse-and-publish.md
Normal 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
204
flake.lock
generated
@@ -1,5 +1,44 @@
|
||||
{
|
||||
"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": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
@@ -18,7 +57,119 @@
|
||||
"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": {
|
||||
"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": {
|
||||
"lastModified": 1777954456,
|
||||
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
|
||||
@@ -36,8 +187,9 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"bundlers": "bundlers",
|
||||
"flake-utils": "flake-utils_3",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
@@ -54,6 +206,54 @@
|
||||
"repo": "default",
|
||||
"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",
|
||||
|
||||
204
flake.nix
204
flake.nix
@@ -1,30 +1,208 @@
|
||||
{
|
||||
description = "cargoxx — Cargo-style frontend for modern C++";
|
||||
description = "cargoxx";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
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:
|
||||
let
|
||||
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 {
|
||||
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 {
|
||||
name = "cargoxx-dev";
|
||||
version = "0.1.0";
|
||||
nativeBuildInputs = [
|
||||
pkgs.cmake
|
||||
pkgs.ninja
|
||||
pkgs.git
|
||||
pkgs.pkg-config
|
||||
];
|
||||
buildInputs = [
|
||||
pkgs.sqlite
|
||||
pkgs.reproc
|
||||
pkgs.catch2_3
|
||||
];
|
||||
nativeBuildInputs = [ pkgs.ninja pkgs.cmake ];
|
||||
buildInputs = [ pkgs.reproc pkgs.sqlite pkgs.catch2_3 ];
|
||||
hardeningDisable = [ "all" ];
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
auto cmd_build(const std::filesystem::path& project_root, bool no_build, bool release,
|
||||
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>;
|
||||
|
||||
// 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)
|
||||
-> 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;
|
||||
|
||||
} // namespace cargoxx::cli
|
||||
|
||||
@@ -77,9 +77,12 @@ auto record_lockfile_rev(const fs::path& project_root, const std::string& name,
|
||||
return lockfile::write(lock, lock_path);
|
||||
}
|
||||
|
||||
// Drives the resolver chain (Conan → vcpkg → nix-cmake-scan), running a
|
||||
// real `cmd_build` against each candidate via verify_link. On success the
|
||||
// overlay carries a confirmed row for the package.
|
||||
// Drives the resolver chain (Conan → vcpkg → nix-cmake-scan → pc-scan),
|
||||
// running a real `cmd_build` against each candidate via verify_link.
|
||||
// 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,
|
||||
const std::vector<std::string>& components,
|
||||
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,
|
||||
/*target=*/std::nullopt, overlay_path);
|
||||
};
|
||||
const auto scratch_root =
|
||||
std::filesystem::temp_directory_path() /
|
||||
std::format("cargoxx-discover-{}", std::random_device{}());
|
||||
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, overlay_path,
|
||||
scratch_root, build_fn);
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(scratch_root, ec);
|
||||
|
||||
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 {};
|
||||
@@ -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());
|
||||
|
||||
// Drop any auto-discovered overlay rows for this package before
|
||||
|
||||
@@ -8,6 +8,7 @@ import cargoxx.linkdb;
|
||||
import cargoxx.lockfile;
|
||||
import cargoxx.codegen;
|
||||
import cargoxx.exec;
|
||||
import cargoxx.resolver;
|
||||
|
||||
namespace cargoxx::cli {
|
||||
|
||||
@@ -34,6 +35,34 @@ auto write_text(const fs::path& path, std::string_view content) -> util::Result<
|
||||
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**
|
||||
// `nixpkgs_rev` for any (name, version) entry that already exists in
|
||||
// `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,
|
||||
const std::vector<linkdb::Recipe>& 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)
|
||||
-> std::optional<lockfile::LockfilePackage> {
|
||||
for (const auto& p : prior.packages) {
|
||||
@@ -58,6 +89,13 @@ auto merge_lockfile(const manifest::Manifest& m,
|
||||
|
||||
lockfile::Lockfile lock;
|
||||
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{
|
||||
.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) {
|
||||
std::optional<std::string> rev;
|
||||
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) {
|
||||
rev = p->nixpkgs_rev;
|
||||
if (p->nixpkgs_attr && !p->nixpkgs_attr->empty()) {
|
||||
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{
|
||||
.name = dep.name,
|
||||
.version = dep.version_spec,
|
||||
.dependencies = {},
|
||||
.nixpkgs_attr = std::move(attr),
|
||||
.nixpkgs_attr = std::move(nix_attr_opt),
|
||||
.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,
|
||||
.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) {
|
||||
@@ -108,7 +188,9 @@ namespace {
|
||||
|
||||
auto run_nix_cmake(const fs::path& project_root, const std::vector<std::string>& cmake_args,
|
||||
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());
|
||||
|
||||
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,
|
||||
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 m = manifest::parse(manifest_path);
|
||||
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());
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
-> util::Result<std::vector<linkdb::Recipe>> {
|
||||
std::vector<linkdb::Recipe> out;
|
||||
out.reserve(deps.size());
|
||||
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);
|
||||
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) {
|
||||
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) {
|
||||
return std::unexpected(dev_recipes.error());
|
||||
}
|
||||
auto lock = merge_lockfile(*m, *recipes, *dev_recipes, prior, git_sha256s);
|
||||
|
||||
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);
|
||||
std::optional<codegen::VendorIndex> vendor_index;
|
||||
if (offline) {
|
||||
auto vendor_path = vendor.value_or(project_root / "vendor.toml");
|
||||
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{
|
||||
.manifest = *m,
|
||||
@@ -188,6 +459,7 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release,
|
||||
.recipes = *recipes,
|
||||
.dev_recipes = *dev_recipes,
|
||||
.project_root = project_root,
|
||||
.vendor = vendor_index,
|
||||
};
|
||||
auto flake_text = codegen::flake_nix(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"));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
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",
|
||||
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};
|
||||
if (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());
|
||||
}
|
||||
|
||||
|
||||
31
src/cli/cmd_linkdb_add.cpp
Normal file
31
src/cli/cmd_linkdb_add.cpp
Normal 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
459
src/cli/cmd_publish.cpp
Normal 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
|
||||
@@ -81,7 +81,7 @@ auto cmd_run(const fs::path& project_root, bool release,
|
||||
}
|
||||
|
||||
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,
|
||||
exec::ExecOptions{
|
||||
|
||||
@@ -19,8 +19,9 @@ auto cmd_test(const fs::path& project_root, bool release,
|
||||
const auto build_dir = std::format("build/{}", profile);
|
||||
|
||||
auto r = exec::run("nix",
|
||||
{"develop", "--command", "ctest", "--test-dir", build_dir,
|
||||
"--output-on-failure"},
|
||||
{"--extra-experimental-features", "nix-command flakes",
|
||||
"develop", "path:./build", "--command", "ctest",
|
||||
"--test-dir", build_dir, "--output-on-failure"},
|
||||
exec::ExecOptions{
|
||||
.cwd = project_root,
|
||||
.env_overrides = {},
|
||||
|
||||
128
src/cli/cmd_vendor.cpp
Normal file
128
src/cli/cmd_vendor.cpp
Normal 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
|
||||
@@ -23,13 +23,32 @@ auto run(int argc, char** argv) -> int {
|
||||
"build", "Generate flake.nix and build/CMakeLists.txt; build with nix+cmake");
|
||||
bool build_no_build = false;
|
||||
bool build_release = false;
|
||||
bool build_offline = false;
|
||||
std::string build_target;
|
||||
std::string build_vendor;
|
||||
build_cmd->add_flag("--no-build", build_no_build,
|
||||
"Generate files only; do not invoke nix/cmake");
|
||||
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 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");
|
||||
bool run_release = false;
|
||||
std::string run_bin;
|
||||
@@ -58,6 +77,28 @@ auto run(int argc, char** argv) -> int {
|
||||
std::string remove_name;
|
||||
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 {
|
||||
app.parse(argc, argv);
|
||||
} catch (const CLI::ParseError& e) {
|
||||
@@ -87,19 +128,45 @@ auto run(int argc, char** argv) -> int {
|
||||
if (!build_target.empty()) {
|
||||
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) {
|
||||
std::cerr << util::format(r.error());
|
||||
return 1;
|
||||
}
|
||||
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 {
|
||||
std::cout << " Built\n";
|
||||
}
|
||||
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) {
|
||||
std::optional<std::string> bin;
|
||||
if (!run_bin.empty()) {
|
||||
@@ -175,6 +242,31 @@ auto run(int argc, char** argv) -> int {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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_CXX_MODULE_STD ON)\n"
|
||||
"\n"
|
||||
"project({} LANGUAGES CXX)\n"
|
||||
"project({} VERSION {} LANGUAGES CXX)\n"
|
||||
"\n"
|
||||
"include(GNUInstallDirs)\n"
|
||||
"include(CMakePackageConfigHelpers)\n"
|
||||
"\n"
|
||||
"# Generated by cargoxx — do not edit.\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_EXPORT_COMPILE_COMMANDS ON)\n"
|
||||
"\n"
|
||||
"add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)\n",
|
||||
m.package.name, edition_to_int(m.package.edition));
|
||||
"add_compile_options(-Wall -Wextra -Wpedantic -Wconversion "
|
||||
"-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,
|
||||
@@ -86,11 +90,60 @@ auto emit_find_packages(const std::vector<linkdb::Recipe>& recipes,
|
||||
return {};
|
||||
}
|
||||
std::string out = "\n# ----- dependencies -----\n";
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
for (const auto& r : recipes) {
|
||||
out += std::format("find_package({})\n", r.find_package);
|
||||
emit_one(r);
|
||||
}
|
||||
for (const auto& r : dev_recipes) {
|
||||
out += std::format("find_package({})\n", r.find_package);
|
||||
emit_one(r);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
const std::vector<linkdb::Recipe>& recipes,
|
||||
const std::vector<std::string>& include_dirs,
|
||||
manifest::Edition edition,
|
||||
const fs::path& project_root) -> std::string {
|
||||
std::string out = "\n# ----- library target -----\n";
|
||||
out += std::format("add_library({} STATIC)\n", package_name);
|
||||
out += std::format("target_sources({}\n", package_name);
|
||||
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) {
|
||||
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";
|
||||
// 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()) {
|
||||
out += std::format("target_include_directories({} SYSTEM PRIVATE", package_name);
|
||||
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,
|
||||
collect_dep_targets(recipes));
|
||||
|
||||
out += emit_library_install_rules(package_name);
|
||||
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";
|
||||
out += std::format("add_executable({}_bin {})\n", package_name,
|
||||
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);
|
||||
out += link_block(std::format("{}_bin", package_name), "PRIVATE", has_lib, package_name,
|
||||
collect_dep_targets(recipes));
|
||||
out += std::format("install(TARGETS {}_bin RUNTIME DESTINATION ${{CMAKE_INSTALL_BINDIR}})\n",
|
||||
package_name);
|
||||
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);
|
||||
out += std::format("add_executable({} {})\n", bin.name,
|
||||
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,
|
||||
collect_dep_targets(recipes));
|
||||
out += std::format("install(TARGETS {} RUNTIME DESTINATION ${{CMAKE_INSTALL_BINDIR}})\n",
|
||||
bin.name);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -285,7 +410,8 @@ auto cmake_lists(const GenerateInputs& in) -> std::string {
|
||||
|
||||
if (in.layout.library) {
|
||||
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);
|
||||
|
||||
@@ -9,6 +9,16 @@ import cargoxx.lockfile;
|
||||
|
||||
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
|
||||
// the underlying objects. Not copyable.
|
||||
struct GenerateInputs {
|
||||
@@ -18,9 +28,14 @@ struct GenerateInputs {
|
||||
std::vector<linkdb::Recipe> recipes; // one per manifest dep, same order
|
||||
std::vector<linkdb::Recipe> dev_recipes; // one per dev_dependency, same order
|
||||
std::filesystem::path project_root;
|
||||
std::optional<VendorIndex> vendor;
|
||||
};
|
||||
|
||||
auto flake_nix(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
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace cargoxx::codegen {
|
||||
|
||||
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
|
||||
// on the shared `nixpkgs` input (which always tracks nixos-unstable).
|
||||
struct DepBinding {
|
||||
@@ -20,6 +23,15 @@ struct DepBinding {
|
||||
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
|
||||
// to use as a Nix identifier (let bindings, lambda destructure params)
|
||||
// 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));
|
||||
}
|
||||
|
||||
struct LockfileRef {
|
||||
std::optional<std::string> rev;
|
||||
std::optional<std::string> attr;
|
||||
struct LockfileEntry {
|
||||
std::optional<std::string> nixpkgs_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,
|
||||
const std::string& version) -> LockfileRef {
|
||||
auto find_lockfile_entry(const lockfile::Lockfile& lock, const std::string& name,
|
||||
const std::string& version) -> LockfileEntry {
|
||||
for (const auto& p : lock.packages) {
|
||||
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> {
|
||||
std::vector<DepBinding> out;
|
||||
out.reserve(in.manifest.dependencies.size());
|
||||
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 ref = find_lockfile_ref(in.lock, dep.name, dep.version_spec);
|
||||
// For pinned deps the lockfile's nixpkgs_attr is authoritative
|
||||
// (it came from devbox's attr_paths for this specific rev). The
|
||||
// curated recipe's attr only applies to nixos-unstable, so it's
|
||||
// wrong when the dep pulls from a different rev.
|
||||
std::string attr = (ref.attr && !ref.attr->empty()) ? *ref.attr
|
||||
: rec.nixpkgs_attr;
|
||||
DepBinding b{
|
||||
struct Bindings {
|
||||
std::vector<DepBinding> nixpkgs;
|
||||
std::vector<CargoxxPkgsBinding> cargoxx_pkgs;
|
||||
};
|
||||
|
||||
auto build_bindings(const GenerateInputs& in) -> Bindings {
|
||||
Bindings out;
|
||||
auto push = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) {
|
||||
// cargoxx-source deps (path/git) don't live in nixpkgs — they're
|
||||
// produced by a recursive buildCppPackage invocation when the
|
||||
// consumer is built via `nix build`. Emitting them here would
|
||||
// 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;
|
||||
out.nixpkgs.push_back(DepBinding{
|
||||
.name = dep.name,
|
||||
.version = dep.version_spec,
|
||||
.nixpkgs_attr = std::move(attr),
|
||||
.sanitized = sanitize_input_attr(dep.name, dep.version_spec),
|
||||
.rev = std::move(ref.rev),
|
||||
};
|
||||
out.push_back(std::move(b));
|
||||
.rev = entry.nixpkgs_rev,
|
||||
});
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -100,30 +143,70 @@ auto pinned_inputs_dedup(const std::vector<DepBinding>& bindings)
|
||||
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 {
|
||||
// Always emit the shared toolchain `nixpkgs` and `flake-utils`
|
||||
// inputs. Per-pinned-dep inputs land between them so the output
|
||||
// diff stays stable across reruns.
|
||||
auto nixpkgs_url = [&]() -> std::string {
|
||||
if (vendor && !vendor->nixpkgs_store_path.empty()) {
|
||||
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 =
|
||||
" inputs = {\n"
|
||||
" nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n";
|
||||
for (const auto* b : pinned) {
|
||||
out += std::format(" {}.url = \"github:NixOS/nixpkgs/{}\";\n",
|
||||
b->sanitized, *b->rev);
|
||||
+ std::format(" nixpkgs.url = \"{}\";\n", nixpkgs_url);
|
||||
if (!vendor) {
|
||||
for (const auto* b : pinned) {
|
||||
out += std::format(" {}.url = \"github:NixOS/nixpkgs/{}\";\n",
|
||||
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;
|
||||
}
|
||||
|
||||
auto emit_outputs_params(const std::vector<const DepBinding*>& pinned)
|
||||
-> std::string {
|
||||
auto emit_outputs_params(const std::vector<const DepBinding*>& pinned,
|
||||
bool any_cargoxx_pkgs) -> std::string {
|
||||
std::string out = "{ self, nixpkgs";
|
||||
for (const auto* b : pinned) {
|
||||
out += ", ";
|
||||
out += b->sanitized;
|
||||
}
|
||||
if (any_cargoxx_pkgs) {
|
||||
out += ", cargoxx-pkgs";
|
||||
}
|
||||
out += ", flake-utils }";
|
||||
return out;
|
||||
}
|
||||
@@ -144,15 +227,23 @@ auto base_expr(const DepBinding& b) -> std::string {
|
||||
: 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::string out;
|
||||
for (const auto& b : bindings) {
|
||||
for (const auto& b : nixpkgs_bs) {
|
||||
auto expr = base_expr(b);
|
||||
if (seen.insert(expr).second) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -160,17 +251,30 @@ auto emit_build_inputs(const std::vector<DepBinding>& bindings) -> std::string {
|
||||
|
||||
auto flake_nix(const GenerateInputs& in) -> std::string {
|
||||
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;
|
||||
out += "{\n";
|
||||
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 += " outputs = ";
|
||||
out += emit_outputs_params(pinned);
|
||||
out += emit_outputs_params(pinned, cxx_rev.has_value());
|
||||
out += ":\n"
|
||||
" flake-utils.lib.eachDefaultSystem (system:\n"
|
||||
" let\n"
|
||||
@@ -182,10 +286,13 @@ auto flake_nix(const GenerateInputs& in) -> std::string {
|
||||
" version = \"1.0\";\n"
|
||||
" nativeBuildInputs = [\n"
|
||||
" pkgs.ninja\n"
|
||||
" pkgs.cmake\n"
|
||||
" ];\n"
|
||||
" pkgs.cmake\n";
|
||||
if (any_pkg_config) {
|
||||
out += " pkgs.pkg-config\n";
|
||||
}
|
||||
out += " ];\n"
|
||||
" buildInputs = [\n";
|
||||
out += emit_build_inputs(bindings);
|
||||
out += emit_build_inputs(bindings.nixpkgs, bindings.cargoxx_pkgs);
|
||||
out += " ];\n"
|
||||
" hardeningDisable = [\n"
|
||||
" \"all\"\n"
|
||||
|
||||
69
src/codegen/vendor.cpp
Normal file
69
src/codegen/vendor.cpp
Normal 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
|
||||
@@ -104,6 +104,31 @@ auto discover(const fs::path& project_root, const std::string& package_name)
|
||||
.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);
|
||||
|
||||
for (const auto& f : top_level_cpp(tests_dir)) {
|
||||
|
||||
@@ -64,6 +64,9 @@ auto Database::resolve(const std::string& package, const std::string& version,
|
||||
.find_package = row.find_package,
|
||||
.targets = row.targets,
|
||||
.source = row.source,
|
||||
.pkg_config_module = row.pkg_config_module,
|
||||
.brute_force_libs = row.brute_force_libs,
|
||||
.brute_force_includes = row.brute_force_includes,
|
||||
};
|
||||
}
|
||||
return std::unexpected(util::Error{
|
||||
|
||||
@@ -15,6 +15,24 @@ struct Recipe {
|
||||
std::vector<std::string> targets; // post-substitution
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -29,6 +47,9 @@ struct OverlayRow {
|
||||
std::vector<std::string> targets;
|
||||
std::string source;
|
||||
std::int64_t verified_at = 0;
|
||||
std::optional<std::string> pkg_config_module;
|
||||
std::vector<std::string> brute_force_libs;
|
||||
std::vector<std::string> brute_force_includes;
|
||||
};
|
||||
|
||||
// RAII wrapper for an open sqlite3 connection used by the overlay database.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -99,8 +135,8 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
|
||||
constexpr const char* SQL =
|
||||
"INSERT OR REPLACE INTO recipes "
|
||||
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
|
||||
" verified_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?)";
|
||||
" verified_at, pkg_config_module, brute_force_libs, brute_force_includes) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?, ?, ?, ?)";
|
||||
|
||||
sqlite3* db = state.handle();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
@@ -109,6 +145,8 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
|
||||
}
|
||||
|
||||
auto targets_str = nlohmann::json(r.targets).dump();
|
||||
auto libs_str = nlohmann::json(r.brute_force_libs).dump();
|
||||
auto incs_str = nlohmann::json(r.brute_force_includes).dump();
|
||||
auto now = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
@@ -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, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
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);
|
||||
sqlite3_finalize(stmt);
|
||||
@@ -137,8 +182,8 @@ auto overlay_insert(OverlayState& state, const std::string& package,
|
||||
constexpr const char* SQL =
|
||||
"INSERT OR REPLACE INTO recipes "
|
||||
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
|
||||
" verified_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL, ?, ?)";
|
||||
" verified_at, pkg_config_module, brute_force_libs, brute_force_includes) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)";
|
||||
|
||||
sqlite3* db = state.handle();
|
||||
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 libs_str = nlohmann::json(r.brute_force_libs).dump();
|
||||
auto incs_str = nlohmann::json(r.brute_force_includes).dump();
|
||||
|
||||
sqlite3_bind_text(stmt, 1, package.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, version_range.c_str(), -1, SQLITE_TRANSIENT);
|
||||
@@ -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, 6, source.c_str(), -1, SQLITE_TRANSIENT);
|
||||
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);
|
||||
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)
|
||||
-> util::Result<std::vector<OverlayRow>> {
|
||||
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 = ?";
|
||||
|
||||
sqlite3* db = state.handle();
|
||||
@@ -276,6 +331,26 @@ auto overlay_query(OverlayState& state, const std::string& package)
|
||||
}
|
||||
row.source = column_text(stmt, 4);
|
||||
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));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
@@ -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>()) {
|
||||
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>()) {
|
||||
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;
|
||||
}
|
||||
@@ -104,6 +152,12 @@ auto parse(const std::filesystem::path& path) -> util::Result<Lockfile> {
|
||||
if (auto v = root["version"].value<int>()) {
|
||||
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()) {
|
||||
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> {
|
||||
toml::table root;
|
||||
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;
|
||||
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) {
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
root.insert_or_assign("package", std::move(packages));
|
||||
|
||||
@@ -9,22 +9,42 @@ export namespace cargoxx::lockfile {
|
||||
struct LockfilePackage {
|
||||
std::string name;
|
||||
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_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> 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;
|
||||
};
|
||||
|
||||
struct Lockfile {
|
||||
int version = 1;
|
||||
std::optional<std::string> nixpkgs_rev_pin;
|
||||
std::optional<std::string> flake_utils_rev_pin;
|
||||
std::vector<LockfilePackage> packages;
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,10 +5,20 @@ import cargoxx.util;
|
||||
|
||||
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 {
|
||||
std::string name;
|
||||
std::string version_spec;
|
||||
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;
|
||||
};
|
||||
@@ -29,6 +39,9 @@ struct Package {
|
||||
Edition edition = Edition::Cpp23;
|
||||
std::vector<std::string> authors;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -73,6 +73,7 @@ auto extract_string_array(const toml::array& arr, std::string_view field,
|
||||
|
||||
constexpr std::array PACKAGE_KNOWN_KEYS = {
|
||||
"name", "version", "edition", "authors", "license", "description", "repository",
|
||||
"homepage",
|
||||
};
|
||||
|
||||
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>()) {
|
||||
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;
|
||||
}
|
||||
@@ -153,12 +163,49 @@ auto parse_dependency(std::string name, const toml::node& value,
|
||||
}
|
||||
|
||||
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>()) {
|
||||
dep.version_spec = *v;
|
||||
} else {
|
||||
return std::unexpected(err(
|
||||
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)));
|
||||
}
|
||||
if (const auto* comps = (*tbl)["components"].as_array()) {
|
||||
|
||||
@@ -44,12 +44,32 @@ auto build_table(const Manifest& m) -> toml::table {
|
||||
if (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));
|
||||
|
||||
auto deps_to_table = [](const std::vector<Dependency>& deps) {
|
||||
toml::table out;
|
||||
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);
|
||||
} else {
|
||||
toml::table dep_tbl;
|
||||
|
||||
81
src/resolver/brute_scan.cpp
Normal file
81
src/resolver/brute_scan.cpp
Normal 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
|
||||
233
src/resolver/cargoxx_pkgs_probe.cpp
Normal file
233
src/resolver/cargoxx_pkgs_probe.cpp
Normal 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
|
||||
@@ -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 {
|
||||
std::string source;
|
||||
linkdb::Recipe recipe;
|
||||
@@ -56,7 +111,7 @@ struct Candidate {
|
||||
auto try_verify(const Candidate& cand, const std::string& name,
|
||||
const std::string& version_spec,
|
||||
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> {
|
||||
VerifyLinkRequest req{
|
||||
.candidate = cand.recipe,
|
||||
@@ -65,7 +120,7 @@ auto try_verify(const Candidate& cand, const std::string& name,
|
||||
.version_spec = version_spec,
|
||||
.components = components,
|
||||
.overlay_path = overlay_path,
|
||||
.scratch_root = scratch_root,
|
||||
.scratch_path = scratch_path,
|
||||
};
|
||||
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;
|
||||
|
||||
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")});
|
||||
}
|
||||
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")});
|
||||
}
|
||||
// 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;
|
||||
};
|
||||
std::optional<NixCmakeCandidate> scan_hit;
|
||||
std::optional<PcCandidate> pc_hit;
|
||||
std::string realized_dev_path;
|
||||
if (!info->dev_path.empty()) {
|
||||
scan_hit = realize_and_scan("dev");
|
||||
if (auto realized = realize_path(std::format("{}.dev", name)); realized) {
|
||||
realized_dev_path = *realized;
|
||||
}
|
||||
}
|
||||
if (!scan_hit) {
|
||||
scan_hit = realize_and_scan("");
|
||||
}
|
||||
if (realized_dev_path.empty()) {
|
||||
if (auto realized = realize_path(name); realized) {
|
||||
realized_dev_path = *realized;
|
||||
}
|
||||
}
|
||||
if (scan_hit) {
|
||||
candidates.push_back(
|
||||
{"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()) {
|
||||
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::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,
|
||||
scratch_root, build_fn);
|
||||
scratch_path, build_fn);
|
||||
if (verified) {
|
||||
return Discovered{
|
||||
.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);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
213
src/resolver/findmodule_scan.cpp
Normal file
213
src/resolver/findmodule_scan.cpp
Normal 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
|
||||
193
src/resolver/fuzzy_listing.cpp
Normal file
193
src/resolver/fuzzy_listing.cpp
Normal 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
|
||||
@@ -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.
|
||||
auto looks_like_missing_attribute(std::string_view stderr_text) -> bool {
|
||||
return stderr_text.find("does not provide attribute") != std::string_view::npos ||
|
||||
stderr_text.find("attribute '") != std::string_view::npos &&
|
||||
stderr_text.find("missing") != std::string_view::npos;
|
||||
(stderr_text.find("attribute '") != std::string_view::npos &&
|
||||
stderr_text.find("missing") != std::string_view::npos);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -162,4 +162,124 @@ auto realize_path(const std::string& flake_attr) -> util::Result<std::string> {
|
||||
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
|
||||
|
||||
161
src/resolver/pc_scan.cpp
Normal file
161
src/resolver/pc_scan.cpp
Normal 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
|
||||
@@ -45,6 +45,67 @@ auto nixpkgs_probe(const std::string& attr) -> util::Result<NixpkgsInfo>;
|
||||
// for build / network errors.
|
||||
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
|
||||
// expression derived from its filename stem.
|
||||
struct NixCmakeCandidate {
|
||||
@@ -77,6 +138,52 @@ auto nix_cmake_scan(const std::filesystem::path& store_path,
|
||||
const std::string& package_name)
|
||||
-> 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.
|
||||
struct ConanRecipe {
|
||||
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.
|
||||
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.
|
||||
struct VcpkgRecipe {
|
||||
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.
|
||||
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
|
||||
// build) on a project rooted at the given path. Injected so the resolver
|
||||
// stays decoupled from `cargoxx.cli`.
|
||||
@@ -123,12 +241,17 @@ using BuildFn =
|
||||
|
||||
struct VerifyLinkRequest {
|
||||
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 version_spec; // user-supplied spec (e.g. "*", "1.2")
|
||||
std::vector<std::string> components;
|
||||
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
|
||||
@@ -147,20 +270,34 @@ struct Discovered {
|
||||
std::string source;
|
||||
};
|
||||
|
||||
// Walks the full auto-resolution chain for a package not present in the
|
||||
// curated linkdb or the user's overlay:
|
||||
// 1. nixpkgs_probe(name) — confirms the attribute exists, captures
|
||||
// version + out_path
|
||||
// 2. for each of conan_probe, vcpkg_probe, nix_cmake_scan(out_path,…):
|
||||
// build a candidate linkdb::Recipe, run verify_link on it, return
|
||||
// on first success
|
||||
// 3. all candidates failed → ResolutionUnsatisfiable
|
||||
// Walks the full auto-resolution chain for a package not present in
|
||||
// the user's overlay. Each candidate produced by a probe gets its own
|
||||
// verify_link attempt at
|
||||
// <scratch_root>/<NN>-<probe>/
|
||||
// e.g. `01-conan/`, `02-vcpkg/`, `03-nix-probe/`, `04-pkg-config/`.
|
||||
// Subdirs are NOT cleaned up — they're meant for the user to inspect
|
||||
// after a failed `cargoxx add`. The caller wipes `<scratch_root>`
|
||||
// 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,
|
||||
const std::vector<std::string>& components,
|
||||
const std::filesystem::path& overlay_path,
|
||||
const std::filesystem::path& scratch_root, const BuildFn& build_fn)
|
||||
-> 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
|
||||
// uses; the response carries far more metadata (license, summary, per-
|
||||
// system store hashes) that we deliberately ignore.
|
||||
|
||||
@@ -31,24 +31,6 @@ auto write_text(const fs::path& path, std::string_view content) -> util::Result<
|
||||
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
|
||||
|
||||
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);
|
||||
if (!db) {
|
||||
@@ -74,26 +53,12 @@ auto verify_link(const VerifyLinkRequest& req, const BuildFn& build_fn)
|
||||
!r) {
|
||||
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);
|
||||
if (ec) {
|
||||
// Roll back the provisional row before bailing.
|
||||
auto db = linkdb::Database::open(req.overlay_path);
|
||||
if (db) {
|
||||
(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());
|
||||
}
|
||||
|
||||
// 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) {
|
||||
auto db = linkdb::Database::open(req.overlay_path);
|
||||
if (db) {
|
||||
|
||||
30
src/util/levenshtein.cpp
Normal file
30
src/util/levenshtein.cpp
Normal 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
|
||||
@@ -47,6 +47,11 @@ using Result = std::expected<T, Error>;
|
||||
|
||||
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`.
|
||||
//
|
||||
// Supported range syntax:
|
||||
|
||||
@@ -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)
|
||||
89
tests/brute_scan_parse.cpp
Normal file
89
tests/brute_scan_parse.cpp
Normal 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));
|
||||
}
|
||||
@@ -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));
|
||||
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 / "Cargoxx.lock"));
|
||||
|
||||
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)") !=
|
||||
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("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]") {
|
||||
@@ -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("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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
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",
|
||||
@@ -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());
|
||||
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");
|
||||
|
||||
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 / "flake.nix") == first_flake);
|
||||
REQUIRE(read_file(root / "build" / "flake.nix") == first_flake);
|
||||
REQUIRE(read_file(root / "Cargoxx.lock") == first_lock);
|
||||
}
|
||||
|
||||
65
tests/cmd_linkdb_add.cpp
Normal file
65
tests/cmd_linkdb_add.cpp
Normal 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");
|
||||
}
|
||||
93
tests/cmd_publish_validation.cpp
Normal file
93
tests/cmd_publish_validation.cpp
Normal 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);
|
||||
}
|
||||
@@ -72,10 +72,15 @@ TEST_CASE("cmake_lists for a binary-only project", "[codegen][cmake]") {
|
||||
GenerateInputs in{m, layout, lock, {}, {}, ROOT};
|
||||
|
||||
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("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);
|
||||
REQUIRE(out.find("add_library") == 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);
|
||||
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("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]") {
|
||||
@@ -138,6 +152,13 @@ TEST_CASE("cmake_lists emits extra binaries from src/bin/", "[codegen][cmake]")
|
||||
auto out = cmake_lists(in);
|
||||
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("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]") {
|
||||
@@ -290,7 +311,8 @@ TEST_CASE("cmake_lists emits baseline warnings", "[codegen][cmake]") {
|
||||
GenerateInputs in{m, layout, lock, {}, {}, ROOT};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -342,3 +364,70 @@ TEST_CASE("cmake_lists threads dev_recipes through find_package and tests",
|
||||
auto block = out.substr(link, end - link);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -73,11 +73,42 @@ auto dep_pkg(std::string name, std::string version,
|
||||
|
||||
} // 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",
|
||||
"[codegen][flake]") {
|
||||
Manifest m{pkg("hello"), {}, {}};
|
||||
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"};
|
||||
|
||||
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]") {
|
||||
Manifest m{pkg("app"), {dep("fmt", "10.2.1")}, {}};
|
||||
DiscoveredLayout layout{};
|
||||
Lockfile lock{1, {
|
||||
Lockfile lock{.version = 1, .packages = {
|
||||
root_pkg("app", "0.1.0"),
|
||||
dep_pkg("fmt", "10.2.1", "abc123def456"),
|
||||
}};
|
||||
@@ -115,7 +146,7 @@ TEST_CASE("flake_nix uses shared `pkgs` for unpinned deps",
|
||||
"[codegen][flake]") {
|
||||
Manifest m{pkg("app"), {dep("fmt", "*")}, {}};
|
||||
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")};
|
||||
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]") {
|
||||
Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("zlib", "*")}, {}};
|
||||
DiscoveredLayout layout{};
|
||||
Lockfile lock{1, {
|
||||
Lockfile lock{.version = 1, .packages = {
|
||||
root_pkg("app", "0.1.0"),
|
||||
dep_pkg("fmt", "10.2.1", "abc"),
|
||||
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]") {
|
||||
Manifest m{pkg("hello"), {}, {}};
|
||||
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"};
|
||||
|
||||
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")},
|
||||
{}};
|
||||
DiscoveredLayout layout{};
|
||||
Lockfile lock{1, {
|
||||
Lockfile lock{.version = 1, .packages = {
|
||||
root_pkg("app", "0.1.0"),
|
||||
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]") {
|
||||
Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("spdlog", "*")}, {}};
|
||||
DiscoveredLayout layout{};
|
||||
Lockfile lock{1, {
|
||||
Lockfile lock{.version = 1, .packages = {
|
||||
root_pkg("app", "0.1.0"),
|
||||
dep_pkg("fmt", "10.2.1", "abc"),
|
||||
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]") {
|
||||
Manifest m{pkg("hello"), {}, {}};
|
||||
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"};
|
||||
|
||||
auto out = flake_nix(in);
|
||||
@@ -200,7 +231,7 @@ TEST_CASE("flake_nix sanitizes hyphens and dots in dep names",
|
||||
"[codegen][flake]") {
|
||||
Manifest m{pkg("app"), {dep("range-v3", "0.12.0")}, {}};
|
||||
DiscoveredLayout layout{};
|
||||
Lockfile lock{1, {
|
||||
Lockfile lock{.version = 1, .packages = {
|
||||
root_pkg("app", "0.1.0"),
|
||||
dep_pkg("range-v3", "0.12.0", "rev123"),
|
||||
}};
|
||||
|
||||
7
tests/e2e/buildCppPackage/Cargoxx.toml
Normal file
7
tests/e2e/buildCppPackage/Cargoxx.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "e2e_demo"
|
||||
version = "0.1.0"
|
||||
edition = "cpp23"
|
||||
|
||||
[dependencies]
|
||||
nlohmann_json = "*"
|
||||
10
tests/e2e/buildCppPackage/flake.nix
Normal file
10
tests/e2e/buildCppPackage/flake.nix
Normal 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 = ./.; };
|
||||
};
|
||||
}
|
||||
38
tests/e2e/buildCppPackage/run.sh
Executable file
38
tests/e2e/buildCppPackage/run.sh
Executable 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"
|
||||
8
tests/e2e/buildCppPackage/src/bin/extra.cpp
Normal file
8
tests/e2e/buildCppPackage/src/bin/extra.cpp
Normal 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;
|
||||
}
|
||||
9
tests/e2e/buildCppPackage/src/main.cpp
Normal file
9
tests/e2e/buildCppPackage/src/main.cpp
Normal 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;
|
||||
}
|
||||
7
tests/e2e/pathDep/Cargoxx.toml
Normal file
7
tests/e2e/pathDep/Cargoxx.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "consumer"
|
||||
version = "0.1.0"
|
||||
edition = "cpp23"
|
||||
|
||||
[dependencies]
|
||||
greeter = { path = "./greeter" }
|
||||
10
tests/e2e/pathDep/flake.nix
Normal file
10
tests/e2e/pathDep/flake.nix
Normal 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 = ./.; };
|
||||
};
|
||||
}
|
||||
4
tests/e2e/pathDep/greeter/Cargoxx.toml
Normal file
4
tests/e2e/pathDep/greeter/Cargoxx.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[package]
|
||||
name = "greeter"
|
||||
version = "0.1.0"
|
||||
edition = "cpp23"
|
||||
8
tests/e2e/pathDep/greeter/src/lib.cppm
Normal file
8
tests/e2e/pathDep/greeter/src/lib.cppm
Normal 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
48
tests/e2e/pathDep/run.sh
Executable 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"
|
||||
7
tests/e2e/pathDep/src/main.cpp
Normal file
7
tests/e2e/pathDep/src/main.cpp
Normal file
@@ -0,0 +1,7 @@
|
||||
import std;
|
||||
import greeter;
|
||||
|
||||
int main() {
|
||||
std::println("{}", greeter::hello("world"));
|
||||
return 0;
|
||||
}
|
||||
67
tests/findmodule_scan_live.cpp
Normal file
67
tests/findmodule_scan_live.cpp
Normal 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);
|
||||
}
|
||||
59
tests/last_failure_dir.cpp
Normal file
59
tests/last_failure_dir.cpp
Normal 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");
|
||||
}
|
||||
@@ -131,16 +131,31 @@ TEST_CASE("discover lists src/bin/*.cpp as additional binaries", "[layout]") {
|
||||
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;
|
||||
p.touch("src/main.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");
|
||||
REQUIRE(r.has_value());
|
||||
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]") {
|
||||
TempProject p;
|
||||
p.touch("src/main.cpp");
|
||||
|
||||
36
tests/levenshtein.cpp
Normal file
36
tests/levenshtein.cpp
Normal 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);
|
||||
}
|
||||
@@ -54,14 +54,14 @@ auto round_trip(const Lockfile& l) -> Lockfile {
|
||||
} // namespace
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
TEST_CASE("write round-trips a lockfile with deps", "[lockfile]") {
|
||||
Lockfile l{
|
||||
1,
|
||||
{
|
||||
.version = 1,
|
||||
.packages = {
|
||||
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("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);
|
||||
}
|
||||
|
||||
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]") {
|
||||
Lockfile l{
|
||||
1,
|
||||
{
|
||||
.version = 1,
|
||||
.packages = {
|
||||
root_pkg("p", "0.1.0", {"fmt 10.2.1"}),
|
||||
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");
|
||||
}
|
||||
|
||||
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]") {
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -181,16 +181,21 @@ optimize = "max"
|
||||
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"(
|
||||
[package]
|
||||
name = "foo"
|
||||
version = "0.1.0"
|
||||
description = "demo"
|
||||
repository = "https://example.com/foo"
|
||||
homepage = "https://example.com"
|
||||
)");
|
||||
auto r = parse(p);
|
||||
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]") {
|
||||
@@ -297,3 +302,79 @@ version = "0.1.0"
|
||||
REQUIRE_FALSE(r.has_value());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -138,3 +138,44 @@ TEST_CASE("write fails when the target directory does not exist",
|
||||
auto r = write(m, "/nonexistent/dir/Cargoxx.toml");
|
||||
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
89
tests/pc_scan_parse.cpp
Normal 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);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ auto make_request(const std::filesystem::path& parent) -> VerifyLinkRequest {
|
||||
.version_spec = "*",
|
||||
.components = {},
|
||||
.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);
|
||||
}
|
||||
|
||||
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 req = make_request(parent);
|
||||
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>{};
|
||||
});
|
||||
|
||||
REQUIRE_FALSE(captured.empty());
|
||||
REQUIRE_FALSE(std::filesystem::exists(captured));
|
||||
REQUIRE(captured == req.scratch_path);
|
||||
REQUIRE(std::filesystem::exists(captured / "Cargoxx.toml"));
|
||||
REQUIRE(std::filesystem::exists(captured / "src" / "main.cpp"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user