From e6c39914b3398379b1f749614580fbc3a83d75bb Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Sun, 17 May 2026 18:13:15 +0000 Subject: [PATCH] [M8] reusable libraries: install layout + cargoxx-path deps --- .gitignore | 7 + CHANGELOG.md | 63 +++ docs/library-reuse-and-publish.md | 448 ++++++++++++++++++++ flake.nix | 29 +- src/cli/cmd_build.cpp | 47 ++ src/cli/cmd_run.cpp | 2 +- src/codegen/cmake.cpp | 84 +++- src/layout/layout.cpp | 25 ++ src/lockfile/lockfile.cpp | 12 + src/lockfile/lockfile.cppm | 4 + src/manifest/manifest.cppm | 7 + src/manifest/parser.cpp | 17 +- src/manifest/writer.cpp | 7 +- tests/cmd_build.cpp | 2 +- tests/codegen_cmake.cpp | 25 +- tests/e2e/buildCppPackage/src/bin/extra.cpp | 8 + tests/e2e/pathDep/Cargoxx.toml | 7 + tests/e2e/pathDep/flake.nix | 10 + tests/e2e/pathDep/greeter/Cargoxx.toml | 4 + tests/e2e/pathDep/greeter/src/lib.cppm | 8 + tests/e2e/pathDep/run.sh | 48 +++ tests/e2e/pathDep/src/main.cpp | 7 + tests/layout_discovery.cpp | 19 +- tests/lockfile_round_trip.cpp | 24 ++ tests/manifest_parse.cpp | 39 ++ 25 files changed, 932 insertions(+), 21 deletions(-) create mode 100644 docs/library-reuse-and-publish.md create mode 100644 tests/e2e/buildCppPackage/src/bin/extra.cpp create mode 100644 tests/e2e/pathDep/Cargoxx.toml create mode 100644 tests/e2e/pathDep/flake.nix create mode 100644 tests/e2e/pathDep/greeter/Cargoxx.toml create mode 100644 tests/e2e/pathDep/greeter/src/lib.cppm create mode 100755 tests/e2e/pathDep/run.sh create mode 100644 tests/e2e/pathDep/src/main.cpp diff --git a/.gitignore b/.gitignore index fc2f0ca..9b8a9eb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,13 @@ !/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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ad18e..66c2022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -435,3 +435,66 @@ All notable changes to cargoxx will be documented in this file. 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 ` follow suit, and + layout discovery picks up Cargo's `src/bin//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.a`, + `lib/cmake//{Config.cmake,ConfigVersion.cmake, + Targets.cmake}`, `lib/pkgconfig/.pc`, and modules + under `include//` (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_)` 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 ...)` is now part of the + generated header so `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 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 `/Cargoxx.toml` to + verify the dep's name matches, then synthesizes a Recipe + (`find_package = " CONFIG REQUIRED"`, + `targets = ["::"]`, `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( 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. diff --git a/docs/library-reuse-and-publish.md b/docs/library-reuse-and-publish.md new file mode 100644 index 0000000..f11445f --- /dev/null +++ b/docs/library-reuse-and-publish.md @@ -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..` 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( 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 = " CONFIG REQUIRED", + │ targets = ["::"] } + ├─ 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( CONFIG REQUIRED)` plus +`::` target — matches what the producer's installed +`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/ +│ └── / +│ ├── 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/.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/"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + inputs.cargoxx.url = "git+https://gitea/.../cargoxx?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//versions/.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/-; new package → also create + recipes//{maintainers.txt,meta.toml}. +7. Write recipes//versions/.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//**`; recipe TOML + validates. +2. **Source fixity**: re-fetch `[source].url` at `[source].commit`, + recompute sha256, compare. +3. **Build**: `nix build .#packages.x86_64-linux.`. +4. **Cache push**: + ``` + nix copy --to "file:///srv/cargoxx-cache?secret-key=$KEY" \ + .#packages.x86_64-linux. + ``` + `/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[@] + 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 @). + 3. Fetch recipes/foo/versions/.toml. + 4. Write Cargoxx.toml: foo = { version = "", registry = "cargoxx" }. + 5. Write Cargoxx.lock: source_kind = "registry", + registry_attr = "foo_". + 6. Also persist lock.registry_rev = . +``` + +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./... https://cache.nixos.org +trusted-public-keys = cache.cargoxx.:= 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..` 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. + `); 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. diff --git a/flake.nix b/flake.nix index 09ec7a4..1986885 100644 --- a/flake.nix +++ b/flake.nix @@ -60,6 +60,7 @@ 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; @@ -68,11 +69,17 @@ (builtins.getFlake "github:NixOS/nixpkgs/${rev}") .legacyPackages.${system}; + # cargoxx-path deps recurse into buildCppPackage on the sibling + # source tree; the result joins buildInputs so the consumer's + # find_package( CONFIG REQUIRED) resolves via CMAKE_PREFIX_PATH. evalDep = p: - let rev = if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "") - then p.nixpkgs_rev - else lock.nixpkgs_rev; - in (pkgsAt rev).${p.nixpkgs_attr}; + if (p.source_kind or "") == "cargoxx-path" then + buildCppPackage { src = src + ("/" + p.source_path); } + 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; @@ -87,12 +94,18 @@ mkDepTomlEntry = p: let derivation = evalDep p; - rev = if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "") + # 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. + isPath = (p.source_kind or "") == "cargoxx-path"; + attr = if isPath then "" else p.nixpkgs_attr; + rev = if isPath then "" + else if (p ? nixpkgs_rev) && (p.nixpkgs_rev != "") then p.nixpkgs_rev else lock.nixpkgs_rev; in '' [[dep]] name = "${p.name}" - nixpkgs_attr = "${p.nixpkgs_attr}" + nixpkgs_attr = "${attr}" nixpkgs_rev = "${rev}" store_path = "${derivation}" ''; @@ -121,9 +134,7 @@ cargoxx build --release --offline --vendor ${vendorToml} ''; installPhase = '' - mkdir -p $out/bin - cp build/release/${pname} $out/bin/ 2>/dev/null || \ - cp build/release/${pname}_bin $out/bin/${pname} + cmake --install build/release --prefix $out ''; hardeningDisable = [ "all" ]; }; diff --git a/src/cli/cmd_build.cpp b/src/cli/cmd_build.cpp index 85aad4c..8517b2a 100644 --- a/src/cli/cmd_build.cpp +++ b/src/cli/cmd_build.cpp @@ -120,6 +120,12 @@ auto merge_lockfile(const manifest::Manifest& m, attr = *p->nixpkgs_attr; } } + std::optional source_kind; + std::optional source_path; + if (dep.source == manifest::DepSource::CargoxxPath) { + source_kind = "cargoxx-path"; + source_path = dep.path; + } lock.packages.push_back(lockfile::LockfilePackage{ .name = dep.name, .version = dep.version_spec, @@ -132,6 +138,8 @@ auto merge_lockfile(const manifest::Manifest& m, .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), }); }; for (std::size_t i = 0; i < m.dependencies.size(); ++i) { @@ -249,11 +257,50 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release, 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 { + 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 = {}, + }; + }; + auto resolve_list = [&](const std::vector& deps) -> util::Result> { std::vector 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 (auto cached = recipe_from_lock(dep.name, dep.version_spec); cached) { out.push_back(std::move(*cached)); continue; diff --git a/src/cli/cmd_run.cpp b/src/cli/cmd_run.cpp index 179c2ff..28a0519 100644 --- a/src/cli/cmd_run.cpp +++ b/src/cli/cmd_run.cpp @@ -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{ diff --git a/src/codegen/cmake.cpp b/src/codegen/cmake.cpp index 563f08a..bcfce18 100644 --- a/src/codegen/cmake.cpp +++ b/src/codegen/cmake.cpp @@ -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" @@ -77,7 +80,7 @@ auto emit_header(const manifest::Manifest& m) -> std::string { "\n" "add_compile_options(-Wall -Wextra -Wpedantic -Wconversion " "-Wno-missing-field-initializers)\n", - m.package.name, edition_to_int(m.package.edition)); + m.package.name, m.package.version, edition_to_int(m.package.edition)); } auto emit_find_packages(const std::vector& recipes, @@ -153,9 +156,65 @@ auto any_recipe_is_catch2(const std::vector& 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& recipes, const std::vector& 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); @@ -172,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) { @@ -181,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; } @@ -190,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; } @@ -203,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; } @@ -335,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); diff --git a/src/layout/layout.cpp b/src/layout/layout.cpp index a31e019..cf3f8bd 100644 --- a/src/layout/layout.cpp +++ b/src/layout/layout.cpp @@ -104,6 +104,31 @@ auto discover(const fs::path& project_root, const std::string& package_name) .module_units = {}, }); } + // Cargo's src/bin//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 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)) { diff --git a/src/lockfile/lockfile.cpp b/src/lockfile/lockfile.cpp index 6bfce0d..2a4456c 100644 --- a/src/lockfile/lockfile.cpp +++ b/src/lockfile/lockfile.cpp @@ -106,6 +106,12 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path) } pkg.brute_force_includes = std::move(*r); } + if (auto v = tbl["source_kind"].value()) { + pkg.source_kind = *v; + } + if (auto v = tbl["source_path"].value()) { + pkg.source_path = *v; + } return pkg; } @@ -215,6 +221,12 @@ auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Res } 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); + } packages.push_back(std::move(tbl)); } root.insert_or_assign("package", std::move(packages)); diff --git a/src/lockfile/lockfile.cppm b/src/lockfile/lockfile.cppm index e2b1f49..1b78f27 100644 --- a/src/lockfile/lockfile.cppm +++ b/src/lockfile/lockfile.cppm @@ -18,6 +18,10 @@ struct LockfilePackage { std::optional pkg_config_module; std::vector brute_force_libs; std::vector brute_force_includes; + // For cargoxx-source deps (not nixpkgs/linkdb-resolved). v1 supports + // "cargoxx-path"; "cargoxx-git" / "cargoxx-registry" land in 1c/1d. + std::optional source_kind; + std::optional source_path; bool operator==(const LockfilePackage&) const = default; }; diff --git a/src/manifest/manifest.cppm b/src/manifest/manifest.cppm index 9a44885..1f9f26e 100644 --- a/src/manifest/manifest.cppm +++ b/src/manifest/manifest.cppm @@ -5,10 +5,17 @@ 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 +}; + struct Dependency { std::string name; std::string version_spec; std::vector components; + DepSource source = DepSource::Auto; + std::optional path; // when source == CargoxxPath bool operator==(const Dependency&) const = default; }; diff --git a/src/manifest/parser.cpp b/src/manifest/parser.cpp index 83d5ee4..54c567e 100644 --- a/src/manifest/parser.cpp +++ b/src/manifest/parser.cpp @@ -153,12 +153,27 @@ auto parse_dependency(std::string name, const toml::node& value, } if (const auto* tbl = value.as_table()) { + // Path form takes precedence: { path = "../foo" } → cargoxx-source dep. + // Version is optional in the path form (defaulting to "*"); the dep's + // own Cargoxx.toml supplies the real version at resolve time. + if (auto path_str = (*tbl)["path"].value()) { + dep.source = DepSource::CargoxxPath; + dep.path = *path_str; + if (auto v = (*tbl)["version"].value()) { + dep.version_spec = *v; + } else { + dep.version_spec = "*"; + } + return dep; + } + if (auto v = (*tbl)["version"].value()) { 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 a 'version' or 'path' string", + dep.name), path, source_pos(value))); } if (const auto* comps = (*tbl)["components"].as_array()) { diff --git a/src/manifest/writer.cpp b/src/manifest/writer.cpp index 8fef503..e478df7 100644 --- a/src/manifest/writer.cpp +++ b/src/manifest/writer.cpp @@ -49,7 +49,12 @@ auto build_table(const Manifest& m) -> toml::table { auto deps_to_table = [](const std::vector& 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.components.empty()) { out.insert_or_assign(dep.name, dep.version_spec); } else { toml::table dep_tbl; diff --git a/tests/cmd_build.cpp b/tests/cmd_build.cpp index c002171..9d96e75 100644 --- a/tests/cmd_build.cpp +++ b/tests/cmd_build.cpp @@ -69,7 +69,7 @@ TEST_CASE("cmd_build generates files for a no-deps binary project", 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); diff --git a/tests/codegen_cmake.cpp b/tests/codegen_cmake.cpp index d44be9a..4bb05e3 100644 --- a/tests/codegen_cmake.cpp +++ b/tests/codegen_cmake.cpp @@ -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); @@ -98,6 +103,15 @@ TEST_CASE("cmake_lists for a library-only project", "[codegen][cmake]") { 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]") { diff --git a/tests/e2e/buildCppPackage/src/bin/extra.cpp b/tests/e2e/buildCppPackage/src/bin/extra.cpp new file mode 100644 index 0000000..81b739d --- /dev/null +++ b/tests/e2e/buildCppPackage/src/bin/extra.cpp @@ -0,0 +1,8 @@ +#include +import std; +int main() { + nlohmann::json j; + j["from"] = "extra"; + std::println("{}", j["from"].get()); + return 0; +} diff --git a/tests/e2e/pathDep/Cargoxx.toml b/tests/e2e/pathDep/Cargoxx.toml new file mode 100644 index 0000000..d1a3cea --- /dev/null +++ b/tests/e2e/pathDep/Cargoxx.toml @@ -0,0 +1,7 @@ +[package] +name = "consumer" +version = "0.1.0" +edition = "cpp23" + +[dependencies] +greeter = { path = "./greeter" } diff --git a/tests/e2e/pathDep/flake.nix b/tests/e2e/pathDep/flake.nix new file mode 100644 index 0000000..03d17c4 --- /dev/null +++ b/tests/e2e/pathDep/flake.nix @@ -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 = ./.; }; + }; +} diff --git a/tests/e2e/pathDep/greeter/Cargoxx.toml b/tests/e2e/pathDep/greeter/Cargoxx.toml new file mode 100644 index 0000000..bc58f3d --- /dev/null +++ b/tests/e2e/pathDep/greeter/Cargoxx.toml @@ -0,0 +1,4 @@ +[package] +name = "greeter" +version = "0.1.0" +edition = "cpp23" diff --git a/tests/e2e/pathDep/greeter/src/lib.cppm b/tests/e2e/pathDep/greeter/src/lib.cppm new file mode 100644 index 0000000..23ae5c8 --- /dev/null +++ b/tests/e2e/pathDep/greeter/src/lib.cppm @@ -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 diff --git a/tests/e2e/pathDep/run.sh b/tests/e2e/pathDep/run.sh new file mode 100755 index 0000000..f6c499f --- /dev/null +++ b/tests/e2e/pathDep/run.sh @@ -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" diff --git a/tests/e2e/pathDep/src/main.cpp b/tests/e2e/pathDep/src/main.cpp new file mode 100644 index 0000000..0cd2919 --- /dev/null +++ b/tests/e2e/pathDep/src/main.cpp @@ -0,0 +1,7 @@ +import std; +import greeter; + +int main() { + std::println("{}", greeter::hello("world")); + return 0; +} diff --git a/tests/layout_discovery.cpp b/tests/layout_discovery.cpp index e3dc4c2..52aaa8f 100644 --- a/tests/layout_discovery.cpp +++ b/tests/layout_discovery.cpp @@ -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// 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//main.cpp as a binary named ", + "[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"); diff --git a/tests/lockfile_round_trip.cpp b/tests/lockfile_round_trip.cpp index 1555483..fa3cf3f 100644 --- a/tests/lockfile_round_trip.cpp +++ b/tests/lockfile_round_trip.cpp @@ -118,6 +118,30 @@ TEST_CASE("write round-trips lockfile recipe fields", "[lockfile]") { 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("Lockfile::nixpkgs_rev returns the shared rev", "[lockfile]") { Lockfile l{ .version = 1, diff --git a/tests/manifest_parse.cpp b/tests/manifest_parse.cpp index 8a13286..80bf0f3 100644 --- a/tests/manifest_parse.cpp +++ b/tests/manifest_parse.cpp @@ -297,3 +297,42 @@ 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); +}