From 7bb39a64c1b2167b04742a5066f9bee55b8d1c7f Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Mon, 18 May 2026 23:13:14 +0000 Subject: [PATCH] =?UTF-8?q?[M8]=20cargoxx-pkgs=20as=20a=20flake:=20cargoxx?= =?UTF-8?q?=20add=20=E2=86=92=20string-form=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapper: fix cache.nixos.org-1 key; drop AppImage; pin publish to mozart/cargoxx-pkgs. --- build/CMakeLists.txt | 10 ++ flake.nix | 7 +- src/cli/cli.cppm | 11 +- src/cli/cmd_add.cpp | 75 +++++++++ src/cli/cmd_build.cpp | 18 ++- src/cli/cmd_publish.cpp | 14 +- src/cli/run.cpp | 12 +- src/codegen/flake.cpp | 119 +++++++++++--- src/lockfile/lockfile.cpp | 12 ++ src/lockfile/lockfile.cppm | 12 +- src/manifest/parser.cpp | 1 + src/resolver/cargoxx_pkgs_probe.cpp | 233 ++++++++++++++++++++++++++++ src/resolver/resolver.cppm | 36 +++++ tests/cmd_publish_validation.cpp | 93 +++++++++++ tests/manifest_parse.cpp | 7 +- tests/manifest_write.cpp | 41 +++++ 16 files changed, 642 insertions(+), 59 deletions(-) create mode 100644 src/resolver/cargoxx_pkgs_probe.cpp create mode 100644 tests/cmd_publish_validation.cpp diff --git a/build/CMakeLists.txt b/build/CMakeLists.txt index e442dfd..6103ebc 100644 --- a/build/CMakeLists.txt +++ b/build/CMakeLists.txt @@ -63,6 +63,7 @@ target_sources(cargoxx ../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 @@ -201,6 +202,15 @@ target_link_libraries(test_cmd_new PRIVATE 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 diff --git a/flake.nix b/flake.nix index 5f0dd02..f7cf63f 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ 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:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= + trusted-public-keys = cache.cargoxx.amadey.xyz:HQNcKDh9lufWm0M32a06AEiLf1Hr0WoRY3Bp5NnWZxs= cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ''; cargoxx-bin = pkgs.gcc15Stdenv.mkDerivation { @@ -53,9 +53,6 @@ ''; installPhase = '' mkdir -p $out/bin - # Codegen since Phase 1a routes every executable through - # RUNTIME_OUTPUT_DIRECTORY=${"$"}{CMAKE_BINARY_DIR}/bin, so - # the binary lives at build/release/bin/cargoxx. cp build/release/bin/cargoxx $out/bin/ wrapProgram $out/bin/cargoxx \ --prefix PATH : ${cargoxxRuntimePath} \ @@ -189,7 +186,6 @@ ''; in { packages.default = cargoxx-bin; - packages.appimage = bundlers-sys.toAppImage cargoxx-bin; packages.dockerImage = bundlers-sys.toDockerImage cargoxx-bin; packages.deb = bundlers-sys.toDEB cargoxx-bin; packages.rpm = bundlers-sys.toRPM cargoxx-bin; @@ -197,7 +193,6 @@ # Reusable packaging functions, all of the shape `drv -> drv`. # Mirror the `to*` naming used by github:NixOS/bundlers. - lib.toAppImage = bundlers-sys.toAppImage; lib.toDockerImage = bundlers-sys.toDockerImage; lib.toDEB = bundlers-sys.toDEB; lib.toRPM = bundlers-sys.toRPM; diff --git a/src/cli/cli.cppm b/src/cli/cli.cppm index 226de68..7151222 100644 --- a/src/cli/cli.cppm +++ b/src/cli/cli.cppm @@ -30,17 +30,14 @@ auto cmd_vendor(const std::filesystem::path& project_root, -> util::Result; // Publish the project's current HEAD as a new version recipe in the -// cargoxx-pkgs registry. Validates manifest + lockfile, computes the -// source sha256 via `nix flake prefetch`, writes +// cargoxx-pkgs repo (mozart/cargoxx-pkgs). Validates manifest + lockfile, +// computes the source sha256 via `nix flake prefetch`, writes // `recipes//versions/.toml` (and `maintainers.txt` for // new packages) into a `publish/-` 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. -// -// `registry_slug` is "/" (default: $CARGOXX_REGISTRY or -// "mozart/cargoxx-pkgs"). Authentication comes from `tea login`. -auto cmd_publish(const std::filesystem::path& project_root, bool dry_run, - std::optional registry_slug = std::nullopt) +// Authentication comes from `tea login`. +auto cmd_publish(const std::filesystem::path& project_root, bool dry_run) -> util::Result; // Builds the project, picks a binary target, and execs it with `args`. diff --git a/src/cli/cmd_add.cpp b/src/cli/cmd_add.cpp index 4c14c78..9f7fa15 100644 --- a/src/cli/cmd_add.cpp +++ b/src/cli/cmd_add.cpp @@ -141,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}.` + // 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 + // `_` (e.g. greeter_0_1_1) plus a bare `` + // 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 diff --git a/src/cli/cmd_build.cpp b/src/cli/cmd_build.cpp index 924bc0d..907011c 100644 --- a/src/cli/cmd_build.cpp +++ b/src/cli/cmd_build.cpp @@ -116,11 +116,25 @@ auto merge_lockfile(const manifest::Manifest& m, auto emit_dep = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) { std::optional rev; std::string attr = rec.nixpkgs_attr; + std::optional cxx_pkgs_attr; + std::optional 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 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 source_kind; std::optional source_path; @@ -142,8 +156,10 @@ auto merge_lockfile(const manifest::Manifest& m, .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, diff --git a/src/cli/cmd_publish.cpp b/src/cli/cmd_publish.cpp index 38f35c8..c2f1505 100644 --- a/src/cli/cmd_publish.cpp +++ b/src/cli/cmd_publish.cpp @@ -16,7 +16,8 @@ namespace fs = std::filesystem; namespace { -constexpr std::string_view DEFAULT_REGISTRY = "mozart/cargoxx-pkgs"; +// 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 { @@ -232,8 +233,8 @@ auto path_exists_remote(const std::string& registry, const std::string& path) } // namespace -auto cmd_publish(const fs::path& project_root, bool dry_run, - std::optional registry_slug_arg) -> util::Result { +auto cmd_publish(const fs::path& project_root, bool dry_run) + -> util::Result { // 1. Read manifest + lockfile auto m = manifest::parse(project_root / "Cargoxx.toml"); if (!m) { @@ -322,11 +323,8 @@ auto cmd_publish(const fs::path& project_root, bool dry_run, return {}; } - // 6. Registry slug + auth. - auto registry = registry_slug_arg.value_or([]() -> std::string { - auto* env = std::getenv("CARGOXX_REGISTRY"); - return env && *env ? env : std::string{DEFAULT_REGISTRY}; - }()); + // 6. Auth. + const std::string registry{CARGOXX_PKGS_REPO}; auto publisher = tea_whoami(); if (!publisher) { diff --git a/src/cli/run.cpp b/src/cli/run.cpp index 29094b6..f8752a5 100644 --- a/src/cli/run.cpp +++ b/src/cli/run.cpp @@ -44,14 +44,10 @@ auto run(int argc, char** argv) -> int { "Path to write vendor.toml (default ./vendor.toml)"); auto* publish_cmd = app.add_subcommand( - "publish", "Publish the current HEAD as a new recipe in the cargoxx registry"); + "publish", "Publish the current HEAD as a new recipe in cargoxx-pkgs"); bool publish_dry_run = false; - std::string publish_registry; publish_cmd->add_flag("--dry-run", publish_dry_run, "Print the recipe TOML; skip all network ops"); - publish_cmd->add_option("--registry", publish_registry, - "Registry repo slug / " - "(default $CARGOXX_REGISTRY or mozart/cargoxx-pkgs)"); auto* run_cmd = app.add_subcommand("run", "Build and run a binary target"); bool run_release = false; @@ -163,11 +159,7 @@ auto run(int argc, char** argv) -> int { } if (*publish_cmd) { - std::optional registry; - if (!publish_registry.empty()) { - registry = publish_registry; - } - auto r = cmd_publish(cwd, publish_dry_run, registry); + auto r = cmd_publish(cwd, publish_dry_run); if (!r) { std::cerr << util::format(r.error()); return 1; diff --git a/src/codegen/flake.cpp b/src/codegen/flake.cpp index 394e407..0fabc2a 100644 --- a/src/codegen/flake.cpp +++ b/src/codegen/flake.cpp @@ -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 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.) — Nix permits underscores in @@ -42,34 +54,66 @@ auto sanitize_input_attr(std::string_view name, std::string_view version) return std::format("nixpkgs_{}_{}", sanitize(name), sanitize(version)); } -struct LockfileRef { - std::optional rev; - std::optional attr; +struct LockfileEntry { + std::optional nixpkgs_attr; + std::optional nixpkgs_rev; + std::optional cargoxx_pkgs_attr; + std::optional 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 { - std::vector out; - out.reserve(in.manifest.dependencies.size() + in.manifest.dev_dependencies.size()); +struct Bindings { + std::vector nixpkgs; + std::vector cargoxx_pkgs; +}; + +auto build_bindings(const GenerateInputs& in) -> Bindings { + Bindings out; auto push = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) { - auto ref = find_lockfile_ref(in.lock, dep.name, dep.version_spec); - std::string attr = (ref.attr && !ref.attr->empty()) ? *ref.attr - : rec.nixpkgs_attr; - out.push_back(DepBinding{ + // 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), + .rev = entry.nixpkgs_rev, }); }; for (std::size_t i = 0; i < in.manifest.dependencies.size(); ++i) { @@ -99,7 +143,20 @@ auto pinned_inputs_dedup(const std::vector& bindings) return out; } +// 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& bs) + -> std::optional { + if (bs.empty()) { + return std::nullopt; + } + return bs.front().rev; +} + auto emit_inputs_block(const std::vector& pinned, + const std::optional& cargoxx_pkgs_rev, const lockfile::Lockfile& lock, const std::optional& vendor) -> std::string { @@ -131,18 +188,25 @@ auto emit_inputs_block(const std::vector& pinned, b->sanitized, *b->rev); } } + 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& pinned) - -> std::string { +auto emit_outputs_params(const std::vector& 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; } @@ -163,15 +227,23 @@ auto base_expr(const DepBinding& b) -> std::string { : std::format("pkgs.{}", b.nixpkgs_attr); } -auto emit_build_inputs(const std::vector& bindings) -> std::string { +auto emit_build_inputs(const std::vector& nixpkgs_bs, + const std::vector& cxx_bs) + -> std::string { std::set 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; } @@ -179,13 +251,14 @@ auto emit_build_inputs(const std::vector& 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, in.lock, in.vendor); + out += emit_inputs_block(pinned, cxx_rev, in.lock, in.vendor); const bool any_pkg_config = std::ranges::any_of(in.recipes, @@ -201,7 +274,7 @@ auto flake_nix(const GenerateInputs& in) -> std::string { 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" @@ -219,7 +292,7 @@ auto flake_nix(const GenerateInputs& in) -> std::string { } out += " ];\n" " buildInputs = [\n"; - out += emit_build_inputs(bindings); + out += emit_build_inputs(bindings.nixpkgs, bindings.cargoxx_pkgs); out += " ];\n" " hardeningDisable = [\n" " \"all\"\n" diff --git a/src/lockfile/lockfile.cpp b/src/lockfile/lockfile.cpp index 1f60407..3dee91b 100644 --- a/src/lockfile/lockfile.cpp +++ b/src/lockfile/lockfile.cpp @@ -76,6 +76,12 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path) if (auto v = tbl["nixpkgs_rev"].value()) { pkg.nixpkgs_rev = *v; } + if (auto v = tbl["cargoxx_pkgs_attr"].value()) { + pkg.cargoxx_pkgs_attr = *v; + } + if (auto v = tbl["cargoxx_pkgs_rev"].value()) { + pkg.cargoxx_pkgs_rev = *v; + } if (auto v = tbl["linkdb_source"].value()) { pkg.linkdb_source = *v; } @@ -200,6 +206,12 @@ 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); } diff --git a/src/lockfile/lockfile.cppm b/src/lockfile/lockfile.cppm index 2ed7c3a..3143ffb 100644 --- a/src/lockfile/lockfile.cppm +++ b/src/lockfile/lockfile.cppm @@ -12,6 +12,13 @@ struct LockfilePackage { std::vector dependencies; std::optional nixpkgs_attr; std::optional 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.` (e.g. "greeter_0_1_1"), and + // `cargoxx_pkgs_rev` pins the cargoxx-pkgs repo itself. + std::optional cargoxx_pkgs_attr; + std::optional cargoxx_pkgs_rev; std::optional linkdb_source; std::optional find_package; std::vector targets; @@ -19,9 +26,8 @@ struct LockfilePackage { std::vector brute_force_libs; std::vector 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 - // "cargoxx-registry" → (1d) + // "cargoxx-path" → source_path only + // "cargoxx-git" → source_git_url + source_git_commit + source_git_sha256 std::optional source_kind; std::optional source_path; std::optional source_git_url; diff --git a/src/manifest/parser.cpp b/src/manifest/parser.cpp index 7874fe6..9d11018 100644 --- a/src/manifest/parser.cpp +++ b/src/manifest/parser.cpp @@ -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 = { diff --git a/src/resolver/cargoxx_pkgs_probe.cpp b/src/resolver/cargoxx_pkgs_probe.cpp new file mode 100644 index 0000000..ee916e4 --- /dev/null +++ b/src/resolver/cargoxx_pkgs_probe.cpp @@ -0,0 +1,233 @@ +module; + +#include + +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 { + 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 ``. 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::vector 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& versions, + std::string_view spec) -> std::optional { + std::optional 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> { + auto names = extract_filenames(contents_json); + std::vector 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 { + 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()) { + 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(); !v || *v != "git") { + return std::unexpected(error( + util::ErrorCode::ResolutionNetworkError, + "cargoxx-pkgs recipe [source].type must be 'git'")); + } + auto url = (*src)["url"].value(); + auto commit = (*src)["commit"].value(); + auto sha = (*src)["sha256"].value(); + 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="` in the consumer's flake. +auto query_cargoxx_pkgs_head() -> util::Result { + 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":"", ...}`. + 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 { + 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 diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index dcde8fc..f0fc971 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -64,6 +64,42 @@ struct PrefetchedSource { std::string hash; // SRI form, e.g. "sha256-" }; +// 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="`. 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/` 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>; + +// Pure: parse a single recipe TOML (the file at +// `recipes//versions/.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; + +// Hits the cargoxx-pkgs Gitea repo for ``'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; + // 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. diff --git a/tests/cmd_publish_validation.cpp b/tests/cmd_publish_validation.cpp new file mode 100644 index 0000000..e78d2c0 --- /dev/null +++ b/tests/cmd_publish_validation.cpp @@ -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 + +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); +} diff --git a/tests/manifest_parse.cpp b/tests/manifest_parse.cpp index e92eea9..284cfa6 100644 --- a/tests/manifest_parse.cpp +++ b/tests/manifest_parse.cpp @@ -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]") { diff --git a/tests/manifest_write.cpp b/tests/manifest_write.cpp index 5b5ca08..d1279a8 100644 --- a/tests/manifest_write.cpp +++ b/tests/manifest_write.cpp @@ -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); +}