Compare commits

..

3 Commits

Author SHA1 Message Date
7bb39a64c1 [M8] cargoxx-pkgs as a flake: cargoxx add → string-form dep
Wrapper: fix cache.nixos.org-1 key; drop AppImage; pin publish to mozart/cargoxx-pkgs.
2026-05-18 23:13:14 +00:00
9d33379f94 [M8] wrapper: substituters + trusted-public-keys for cargoxx-pkgs cache 2026-05-18 20:27:50 +00:00
f9932a3ad9 [M8] cargoxx-bin installPhase: copy from build/release/bin/ (Phase 1a relocation) 2026-05-18 18:54:41 +00:00
16 changed files with 648 additions and 57 deletions

View File

@@ -63,6 +63,7 @@ target_sources(cargoxx
../src/manifest/parser.cpp ../src/manifest/parser.cpp
../src/manifest/writer.cpp ../src/manifest/writer.cpp
../src/resolver/brute_scan.cpp ../src/resolver/brute_scan.cpp
../src/resolver/cargoxx_pkgs_probe.cpp
../src/resolver/conan_probe.cpp ../src/resolver/conan_probe.cpp
../src/resolver/discover.cpp ../src/resolver/discover.cpp
../src/resolver/findmodule_scan.cpp ../src/resolver/findmodule_scan.cpp
@@ -201,6 +202,15 @@ target_link_libraries(test_cmd_new PRIVATE
Catch2::Catch2WithMain Catch2::Catch2WithMain
) )
catch_discover_tests(test_cmd_new) 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) add_executable(test_cmd_remove ../tests/cmd_remove.cpp)
target_link_libraries(test_cmd_remove PRIVATE target_link_libraries(test_cmd_remove PRIVATE
cargoxx cargoxx

View File

@@ -27,10 +27,15 @@
# Defaults applied to the bundled `nix` so it works on hosts # Defaults applied to the bundled `nix` so it works on hosts
# that don't already have nix set up (Arch/Debian/Fedora users # that don't already have nix set up (Arch/Debian/Fedora users
# who install our .pkg.tar.zst / .deb / .rpm). Multi-user mode # who install our .pkg.tar.zst / .deb / .rpm). Multi-user mode
# would expect a `nixbld` group and a running daemon # 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 = '' cargoxxNixConfig = ''
experimental-features = nix-command flakes experimental-features = nix-command flakes
build-users-group = 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 { cargoxx-bin = pkgs.gcc15Stdenv.mkDerivation {
@@ -48,7 +53,7 @@
''; '';
installPhase = '' installPhase = ''
mkdir -p $out/bin mkdir -p $out/bin
cp build/release/cargoxx $out/bin/ cp build/release/bin/cargoxx $out/bin/
wrapProgram $out/bin/cargoxx \ wrapProgram $out/bin/cargoxx \
--prefix PATH : ${cargoxxRuntimePath} \ --prefix PATH : ${cargoxxRuntimePath} \
--set-default NIX_CONFIG ${pkgs.lib.escapeShellArg cargoxxNixConfig} --set-default NIX_CONFIG ${pkgs.lib.escapeShellArg cargoxxNixConfig}
@@ -181,7 +186,6 @@
''; '';
in { in {
packages.default = cargoxx-bin; packages.default = cargoxx-bin;
packages.appimage = bundlers-sys.toAppImage cargoxx-bin;
packages.dockerImage = bundlers-sys.toDockerImage cargoxx-bin; packages.dockerImage = bundlers-sys.toDockerImage cargoxx-bin;
packages.deb = bundlers-sys.toDEB cargoxx-bin; packages.deb = bundlers-sys.toDEB cargoxx-bin;
packages.rpm = bundlers-sys.toRPM cargoxx-bin; packages.rpm = bundlers-sys.toRPM cargoxx-bin;
@@ -189,7 +193,6 @@
# Reusable packaging functions, all of the shape `drv -> drv`. # Reusable packaging functions, all of the shape `drv -> drv`.
# Mirror the `to*` naming used by github:NixOS/bundlers. # Mirror the `to*` naming used by github:NixOS/bundlers.
lib.toAppImage = bundlers-sys.toAppImage;
lib.toDockerImage = bundlers-sys.toDockerImage; lib.toDockerImage = bundlers-sys.toDockerImage;
lib.toDEB = bundlers-sys.toDEB; lib.toDEB = bundlers-sys.toDEB;
lib.toRPM = bundlers-sys.toRPM; lib.toRPM = bundlers-sys.toRPM;

View File

@@ -30,17 +30,14 @@ auto cmd_vendor(const std::filesystem::path& project_root,
-> util::Result<void>; -> util::Result<void>;
// Publish the project's current HEAD as a new version recipe in the // Publish the project's current HEAD as a new version recipe in the
// cargoxx-pkgs registry. Validates manifest + lockfile, computes the // cargoxx-pkgs repo (mozart/cargoxx-pkgs). Validates manifest + lockfile,
// source sha256 via `nix flake prefetch`, writes // computes the source sha256 via `nix flake prefetch`, writes
// `recipes/<name>/versions/<version>.toml` (and `maintainers.txt` for // `recipes/<name>/versions/<version>.toml` (and `maintainers.txt` for
// new packages) into a `publish/<name>-<version>` branch via the // new packages) into a `publish/<name>-<version>` branch via the
// Gitea contents API, opens a PR via `tea api`. With `dry_run=true`, // Gitea contents API, opens a PR via `tea api`. With `dry_run=true`,
// prints the recipe TOML and skips all network operations. // prints the recipe TOML and skips all network operations.
// // Authentication comes from `tea login`.
// `registry_slug` is "<owner>/<repo>" (default: $CARGOXX_REGISTRY or auto cmd_publish(const std::filesystem::path& project_root, bool dry_run)
// "mozart/cargoxx-pkgs"). Authentication comes from `tea login`.
auto cmd_publish(const std::filesystem::path& project_root, bool dry_run,
std::optional<std::string> registry_slug = std::nullopt)
-> util::Result<void>; -> util::Result<void>;
// Builds the project, picks a binary target, and execs it with `args`. // Builds the project, picks a binary target, and execs it with `args`.

View File

@@ -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}.<attr>`
// as a buildInput. Disabled in tests via CARGOXX_NO_AUTORESOLVE.
{
auto* env = std::getenv("CARGOXX_NO_AUTORESOLVE");
const bool autoresolve_disabled = env != nullptr && *env != 0;
if (!autoresolve_disabled) {
auto hit = resolver::try_cargoxx_pkgs(name, effective_version);
if (hit) {
// cargoxx-pkgs's flake exposes per-version attrs as
// `<n>_<safe>` (e.g. greeter_0_1_1) plus a bare `<n>`
// pointing at the latest. Pin the concrete attr so the
// lock is hermetic.
std::string safe;
safe.reserve(hit->version.size());
for (char c : hit->version) {
safe.push_back((c == '.' || c == '-' || c == '+') ? '_' : c);
}
auto attr = std::format("{}_{}", name, safe);
m->dependencies.push_back(manifest::Dependency{
.name = name,
.version_spec = hit->version,
.components = std::move(components),
});
if (auto r = manifest::write(*m, manifest_path); !r) {
return std::unexpected(r.error());
}
const auto lock_path = project_root / "Cargoxx.lock";
lockfile::Lockfile lock;
if (std::error_code ec; std::filesystem::exists(lock_path, ec)) {
if (auto parsed = lockfile::parse(lock_path); parsed) {
lock = std::move(*parsed);
}
}
if (lock.version == 0) {
lock.version = 1;
}
lockfile::LockfilePackage entry{
.name = name,
.version = hit->version,
.cargoxx_pkgs_attr = attr,
.cargoxx_pkgs_rev = hit->repo_rev,
.linkdb_source = "cargoxx-pkgs",
.find_package = std::format("{} CONFIG REQUIRED", name),
.targets = {std::format("{}::{}", name, name)},
};
bool replaced = false;
for (auto& p : lock.packages) {
if (p.name == name && p.version == hit->version) {
p = entry;
replaced = true;
break;
}
}
if (!replaced) {
lock.packages.push_back(std::move(entry));
}
if (auto r = lockfile::write(lock, lock_path); !r) {
return std::unexpected(r.error());
}
return {};
}
if (hit.error().code != util::ErrorCode::ResolutionUnknownPackage) {
return std::unexpected(hit.error());
}
// Fall through to the linkdb chain.
}
}
const auto effective_overlay = overlay_path.value_or(linkdb::default_overlay_path()); const auto effective_overlay = overlay_path.value_or(linkdb::default_overlay_path());
// Drop any auto-discovered overlay rows for this package before // Drop any auto-discovered overlay rows for this package before

View File

@@ -116,11 +116,25 @@ auto merge_lockfile(const manifest::Manifest& m,
auto emit_dep = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) { auto emit_dep = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) {
std::optional<std::string> rev; std::optional<std::string> rev;
std::string attr = rec.nixpkgs_attr; std::string attr = rec.nixpkgs_attr;
std::optional<std::string> cxx_pkgs_attr;
std::optional<std::string> cxx_pkgs_rev;
if (auto p = find_prior(dep.name, dep.version_spec); p) { if (auto p = find_prior(dep.name, dep.version_spec); p) {
rev = p->nixpkgs_rev; rev = p->nixpkgs_rev;
if (p->nixpkgs_attr && !p->nixpkgs_attr->empty()) { if (p->nixpkgs_attr && !p->nixpkgs_attr->empty()) {
attr = *p->nixpkgs_attr; attr = *p->nixpkgs_attr;
} }
cxx_pkgs_attr = p->cargoxx_pkgs_attr;
cxx_pkgs_rev = p->cargoxx_pkgs_rev;
}
// For cargoxx-pkgs-resolved deps, drop the nixpkgs fields — the
// recipe synthesized at `cargoxx add` time left them empty and
// the codegen layer prefers cargoxx_pkgs_attr anyway.
std::optional<std::string> nix_attr_opt;
if (cxx_pkgs_attr && !cxx_pkgs_attr->empty()) {
attr.clear();
rev.reset();
} else if (!attr.empty()) {
nix_attr_opt = attr;
} }
std::optional<std::string> source_kind; std::optional<std::string> source_kind;
std::optional<std::string> source_path; std::optional<std::string> source_path;
@@ -142,8 +156,10 @@ auto merge_lockfile(const manifest::Manifest& m,
.name = dep.name, .name = dep.name,
.version = dep.version_spec, .version = dep.version_spec,
.dependencies = {}, .dependencies = {},
.nixpkgs_attr = std::move(attr), .nixpkgs_attr = std::move(nix_attr_opt),
.nixpkgs_rev = std::move(rev), .nixpkgs_rev = std::move(rev),
.cargoxx_pkgs_attr = std::move(cxx_pkgs_attr),
.cargoxx_pkgs_rev = std::move(cxx_pkgs_rev),
.linkdb_source = rec.source, .linkdb_source = rec.source,
.find_package = rec.find_package, .find_package = rec.find_package,
.targets = rec.targets, .targets = rec.targets,

View File

@@ -16,7 +16,8 @@ namespace fs = std::filesystem;
namespace { 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 = "") auto err(util::ErrorCode code, std::string msg, std::string hint = "")
-> util::Error { -> util::Error {
@@ -232,8 +233,8 @@ auto path_exists_remote(const std::string& registry, const std::string& path)
} // namespace } // namespace
auto cmd_publish(const fs::path& project_root, bool dry_run, auto cmd_publish(const fs::path& project_root, bool dry_run)
std::optional<std::string> registry_slug_arg) -> util::Result<void> { -> util::Result<void> {
// 1. Read manifest + lockfile // 1. Read manifest + lockfile
auto m = manifest::parse(project_root / "Cargoxx.toml"); auto m = manifest::parse(project_root / "Cargoxx.toml");
if (!m) { if (!m) {
@@ -322,11 +323,8 @@ auto cmd_publish(const fs::path& project_root, bool dry_run,
return {}; return {};
} }
// 6. Registry slug + auth. // 6. Auth.
auto registry = registry_slug_arg.value_or([]() -> std::string { const std::string registry{CARGOXX_PKGS_REPO};
auto* env = std::getenv("CARGOXX_REGISTRY");
return env && *env ? env : std::string{DEFAULT_REGISTRY};
}());
auto publisher = tea_whoami(); auto publisher = tea_whoami();
if (!publisher) { if (!publisher) {

View File

@@ -44,14 +44,10 @@ auto run(int argc, char** argv) -> int {
"Path to write vendor.toml (default ./vendor.toml)"); "Path to write vendor.toml (default ./vendor.toml)");
auto* publish_cmd = app.add_subcommand( 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; bool publish_dry_run = false;
std::string publish_registry;
publish_cmd->add_flag("--dry-run", publish_dry_run, publish_cmd->add_flag("--dry-run", publish_dry_run,
"Print the recipe TOML; skip all network ops"); "Print the recipe TOML; skip all network ops");
publish_cmd->add_option("--registry", publish_registry,
"Registry repo slug <owner>/<repo> "
"(default $CARGOXX_REGISTRY or mozart/cargoxx-pkgs)");
auto* run_cmd = app.add_subcommand("run", "Build and run a binary target"); auto* run_cmd = app.add_subcommand("run", "Build and run a binary target");
bool run_release = false; bool run_release = false;
@@ -163,11 +159,7 @@ auto run(int argc, char** argv) -> int {
} }
if (*publish_cmd) { if (*publish_cmd) {
std::optional<std::string> registry; auto r = cmd_publish(cwd, publish_dry_run);
if (!publish_registry.empty()) {
registry = publish_registry;
}
auto r = cmd_publish(cwd, publish_dry_run, registry);
if (!r) { if (!r) {
std::cerr << util::format(r.error()); std::cerr << util::format(r.error());
return 1; return 1;

View File

@@ -9,6 +9,9 @@ namespace cargoxx::codegen {
namespace { namespace {
constexpr std::string_view CARGOXX_PKGS_URL =
"git+https://git.amadey.xyz/mozart/cargoxx-pkgs";
// One pinned dep gets its own nixpkgs flake input. Unpinned deps stay // One pinned dep gets its own nixpkgs flake input. Unpinned deps stay
// on the shared `nixpkgs` input (which always tracks nixos-unstable). // on the shared `nixpkgs` input (which always tracks nixos-unstable).
struct DepBinding { struct DepBinding {
@@ -20,6 +23,15 @@ struct DepBinding {
std::optional<std::string> rev; // pinned commit (null → unpinned) std::optional<std::string> rev; // pinned commit (null → unpinned)
}; };
// Parallel to DepBinding for cargoxx-pkgs-resolved deps. All deps from
// cargoxx-pkgs share a single `cargoxx-pkgs` flake input pinned at
// `rev` — if a project ever needs multiple revs simultaneously, this
// can grow per-rev inputs the way DepBinding does for nixpkgs.
struct CargoxxPkgsBinding {
std::string attr; // e.g. "greeter_0_1_1"
std::string rev; // cargoxx-pkgs repo HEAD at lock time
};
// Replaces every char outside [a-zA-Z0-9_] with '_'. The result is safe // Replaces every char outside [a-zA-Z0-9_] with '_'. The result is safe
// to use as a Nix identifier (let bindings, lambda destructure params) // to use as a Nix identifier (let bindings, lambda destructure params)
// and as an attribute name (inputs.<attr>) — Nix permits underscores in // and as an attribute name (inputs.<attr>) — Nix permits underscores in
@@ -42,34 +54,66 @@ auto sanitize_input_attr(std::string_view name, std::string_view version)
return std::format("nixpkgs_{}_{}", sanitize(name), sanitize(version)); return std::format("nixpkgs_{}_{}", sanitize(name), sanitize(version));
} }
struct LockfileRef { struct LockfileEntry {
std::optional<std::string> rev; std::optional<std::string> nixpkgs_attr;
std::optional<std::string> attr; std::optional<std::string> nixpkgs_rev;
std::optional<std::string> cargoxx_pkgs_attr;
std::optional<std::string> cargoxx_pkgs_rev;
}; };
auto find_lockfile_ref(const lockfile::Lockfile& lock, const std::string& name, auto find_lockfile_entry(const lockfile::Lockfile& lock, const std::string& name,
const std::string& version) -> LockfileRef { const std::string& version) -> LockfileEntry {
for (const auto& p : lock.packages) { for (const auto& p : lock.packages) {
if (p.name == name && p.version == version) { if (p.name == name && p.version == version) {
return LockfileRef{.rev = p.nixpkgs_rev, .attr = p.nixpkgs_attr}; return LockfileEntry{
.nixpkgs_attr = p.nixpkgs_attr,
.nixpkgs_rev = p.nixpkgs_rev,
.cargoxx_pkgs_attr = p.cargoxx_pkgs_attr,
.cargoxx_pkgs_rev = p.cargoxx_pkgs_rev,
};
} }
} }
return LockfileRef{}; return {};
} }
auto build_bindings(const GenerateInputs& in) -> std::vector<DepBinding> { struct Bindings {
std::vector<DepBinding> out; std::vector<DepBinding> nixpkgs;
out.reserve(in.manifest.dependencies.size() + in.manifest.dev_dependencies.size()); std::vector<CargoxxPkgsBinding> cargoxx_pkgs;
};
auto build_bindings(const GenerateInputs& in) -> Bindings {
Bindings out;
auto push = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) { auto push = [&](const manifest::Dependency& dep, const linkdb::Recipe& rec) {
auto ref = find_lockfile_ref(in.lock, dep.name, dep.version_spec); // cargoxx-source deps (path/git) don't live in nixpkgs — they're
std::string attr = (ref.attr && !ref.attr->empty()) ? *ref.attr // produced by a recursive buildCppPackage invocation when the
: rec.nixpkgs_attr; // consumer is built via `nix build`. Emitting them here would
out.push_back(DepBinding{ // 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, .name = dep.name,
.version = dep.version_spec, .version = dep.version_spec,
.nixpkgs_attr = std::move(attr), .nixpkgs_attr = std::move(attr),
.sanitized = sanitize_input_attr(dep.name, dep.version_spec), .sanitized = sanitize_input_attr(dep.name, dep.version_spec),
.rev = std::move(ref.rev), .rev = entry.nixpkgs_rev,
}); });
}; };
for (std::size_t i = 0; i < in.manifest.dependencies.size(); ++i) { for (std::size_t i = 0; i < in.manifest.dependencies.size(); ++i) {
@@ -99,7 +143,20 @@ auto pinned_inputs_dedup(const std::vector<DepBinding>& bindings)
return out; 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<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, auto emit_inputs_block(const std::vector<const DepBinding*>& pinned,
const std::optional<std::string>& cargoxx_pkgs_rev,
const lockfile::Lockfile& lock, const lockfile::Lockfile& lock,
const std::optional<VendorIndex>& vendor) const std::optional<VendorIndex>& vendor)
-> std::string { -> std::string {
@@ -131,18 +188,25 @@ auto emit_inputs_block(const std::vector<const DepBinding*>& pinned,
b->sanitized, *b->rev); 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 += std::format(" flake-utils.url = \"{}\";\n", flake_utils_url);
out += " };\n"; out += " };\n";
return out; return out;
} }
auto emit_outputs_params(const std::vector<const DepBinding*>& pinned) auto emit_outputs_params(const std::vector<const DepBinding*>& pinned,
-> std::string { bool any_cargoxx_pkgs) -> std::string {
std::string out = "{ self, nixpkgs"; std::string out = "{ self, nixpkgs";
for (const auto* b : pinned) { for (const auto* b : pinned) {
out += ", "; out += ", ";
out += b->sanitized; out += b->sanitized;
} }
if (any_cargoxx_pkgs) {
out += ", cargoxx-pkgs";
}
out += ", flake-utils }"; out += ", flake-utils }";
return out; return out;
} }
@@ -163,15 +227,23 @@ auto base_expr(const DepBinding& b) -> std::string {
: std::format("pkgs.{}", b.nixpkgs_attr); : std::format("pkgs.{}", b.nixpkgs_attr);
} }
auto emit_build_inputs(const std::vector<DepBinding>& bindings) -> std::string { auto emit_build_inputs(const std::vector<DepBinding>& nixpkgs_bs,
const std::vector<CargoxxPkgsBinding>& cxx_bs)
-> std::string {
std::set<std::string> seen; std::set<std::string> seen;
std::string out; std::string out;
for (const auto& b : bindings) { for (const auto& b : nixpkgs_bs) {
auto expr = base_expr(b); auto expr = base_expr(b);
if (seen.insert(expr).second) { if (seen.insert(expr).second) {
out += std::format(" {}\n", expr); out += std::format(" {}\n", expr);
} }
} }
for (const auto& b : cxx_bs) {
auto expr = std::format("cargoxx-pkgs.packages.${{system}}.{}", b.attr);
if (seen.insert(expr).second) {
out += std::format(" {}\n", expr);
}
}
return out; return out;
} }
@@ -179,13 +251,14 @@ auto emit_build_inputs(const std::vector<DepBinding>& bindings) -> std::string {
auto flake_nix(const GenerateInputs& in) -> std::string { auto flake_nix(const GenerateInputs& in) -> std::string {
auto bindings = build_bindings(in); auto bindings = build_bindings(in);
auto pinned = pinned_inputs_dedup(bindings); auto pinned = pinned_inputs_dedup(bindings.nixpkgs);
auto cxx_rev = cargoxx_pkgs_rev(bindings.cargoxx_pkgs);
std::string out; std::string out;
out += "{\n"; out += "{\n";
out += std::format(" description = \"{}\";\n\n", in.manifest.package.name); out += std::format(" description = \"{}\";\n\n", in.manifest.package.name);
out += emit_inputs_block(pinned, in.lock, in.vendor); out += emit_inputs_block(pinned, cxx_rev, in.lock, in.vendor);
const bool any_pkg_config = const bool any_pkg_config =
std::ranges::any_of(in.recipes, std::ranges::any_of(in.recipes,
@@ -201,7 +274,7 @@ auto flake_nix(const GenerateInputs& in) -> std::string {
out += "\n"; out += "\n";
out += " outputs = "; out += " outputs = ";
out += emit_outputs_params(pinned); out += emit_outputs_params(pinned, cxx_rev.has_value());
out += ":\n" out += ":\n"
" flake-utils.lib.eachDefaultSystem (system:\n" " flake-utils.lib.eachDefaultSystem (system:\n"
" let\n" " let\n"
@@ -219,7 +292,7 @@ auto flake_nix(const GenerateInputs& in) -> std::string {
} }
out += " ];\n" out += " ];\n"
" buildInputs = [\n"; " buildInputs = [\n";
out += emit_build_inputs(bindings); out += emit_build_inputs(bindings.nixpkgs, bindings.cargoxx_pkgs);
out += " ];\n" out += " ];\n"
" hardeningDisable = [\n" " hardeningDisable = [\n"
" \"all\"\n" " \"all\"\n"

View File

@@ -76,6 +76,12 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path)
if (auto v = tbl["nixpkgs_rev"].value<std::string>()) { if (auto v = tbl["nixpkgs_rev"].value<std::string>()) {
pkg.nixpkgs_rev = *v; pkg.nixpkgs_rev = *v;
} }
if (auto v = tbl["cargoxx_pkgs_attr"].value<std::string>()) {
pkg.cargoxx_pkgs_attr = *v;
}
if (auto v = tbl["cargoxx_pkgs_rev"].value<std::string>()) {
pkg.cargoxx_pkgs_rev = *v;
}
if (auto v = tbl["linkdb_source"].value<std::string>()) { if (auto v = tbl["linkdb_source"].value<std::string>()) {
pkg.linkdb_source = *v; pkg.linkdb_source = *v;
} }
@@ -200,6 +206,12 @@ auto write(const Lockfile& lock, const std::filesystem::path& path) -> util::Res
if (p.nixpkgs_rev) { if (p.nixpkgs_rev) {
tbl.insert_or_assign("nixpkgs_rev", *p.nixpkgs_rev); tbl.insert_or_assign("nixpkgs_rev", *p.nixpkgs_rev);
} }
if (p.cargoxx_pkgs_attr) {
tbl.insert_or_assign("cargoxx_pkgs_attr", *p.cargoxx_pkgs_attr);
}
if (p.cargoxx_pkgs_rev) {
tbl.insert_or_assign("cargoxx_pkgs_rev", *p.cargoxx_pkgs_rev);
}
if (p.linkdb_source) { if (p.linkdb_source) {
tbl.insert_or_assign("linkdb_source", *p.linkdb_source); tbl.insert_or_assign("linkdb_source", *p.linkdb_source);
} }

View File

@@ -12,6 +12,13 @@ struct LockfilePackage {
std::vector<std::string> dependencies; std::vector<std::string> dependencies;
std::optional<std::string> nixpkgs_attr; std::optional<std::string> nixpkgs_attr;
std::optional<std::string> nixpkgs_rev; std::optional<std::string> nixpkgs_rev;
// Parallel to nixpkgs_attr/rev: the dep was resolved through the
// cargoxx-pkgs flake (https://git.amadey.xyz/mozart/cargoxx-pkgs).
// `cargoxx_pkgs_attr` is the attribute name in
// `cargoxx-pkgs.packages.<system>` (e.g. "greeter_0_1_1"), and
// `cargoxx_pkgs_rev` pins the cargoxx-pkgs repo itself.
std::optional<std::string> cargoxx_pkgs_attr;
std::optional<std::string> cargoxx_pkgs_rev;
std::optional<std::string> linkdb_source; std::optional<std::string> linkdb_source;
std::optional<std::string> find_package; std::optional<std::string> find_package;
std::vector<std::string> targets; std::vector<std::string> targets;
@@ -19,9 +26,8 @@ struct LockfilePackage {
std::vector<std::string> brute_force_libs; std::vector<std::string> brute_force_libs;
std::vector<std::string> brute_force_includes; std::vector<std::string> brute_force_includes;
// For cargoxx-source deps (not nixpkgs/linkdb-resolved). // For cargoxx-source deps (not nixpkgs/linkdb-resolved).
// "cargoxx-path" → source_path only // "cargoxx-path" → source_path only
// "cargoxx-git" → source_git_url + source_git_commit + source_git_sha256 // "cargoxx-git" → source_git_url + source_git_commit + source_git_sha256
// "cargoxx-registry" → (1d)
std::optional<std::string> source_kind; std::optional<std::string> source_kind;
std::optional<std::string> source_path; std::optional<std::string> source_path;
std::optional<std::string> source_git_url; std::optional<std::string> source_git_url;

View File

@@ -73,6 +73,7 @@ auto extract_string_array(const toml::array& arr, std::string_view field,
constexpr std::array PACKAGE_KNOWN_KEYS = { constexpr std::array PACKAGE_KNOWN_KEYS = {
"name", "version", "edition", "authors", "license", "description", "repository", "name", "version", "edition", "authors", "license", "description", "repository",
"homepage",
}; };
constexpr std::array BUILD_KNOWN_KEYS = { constexpr std::array BUILD_KNOWN_KEYS = {

View File

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

View File

@@ -64,6 +64,42 @@ struct PrefetchedSource {
std::string hash; // SRI form, e.g. "sha256-<base64>" 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 // 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` // caller can persist it in a lockfile and feed it to `pkgs.fetchgit`
// as a fixed-output derivation pin. Used by cargoxx-git deps. // as a fixed-output derivation pin. Used by cargoxx-git deps.

View File

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

View File

@@ -181,16 +181,21 @@ optimize = "max"
REQUIRE(r.error().code == ErrorCode::ManifestUnknownField); REQUIRE(r.error().code == ErrorCode::ManifestUnknownField);
} }
TEST_CASE("parse accepts reserved [package] fields", "[manifest][parse]") { TEST_CASE("parse stores description / repository / homepage on Package",
"[manifest][parse]") {
auto p = write_manifest(R"( auto p = write_manifest(R"(
[package] [package]
name = "foo" name = "foo"
version = "0.1.0" version = "0.1.0"
description = "demo" description = "demo"
repository = "https://example.com/foo" repository = "https://example.com/foo"
homepage = "https://example.com"
)"); )");
auto r = parse(p); auto r = parse(p);
REQUIRE(r.has_value()); REQUIRE(r.has_value());
REQUIRE(r->package.description == "demo");
REQUIRE(r->package.repository == "https://example.com/foo");
REQUIRE(r->package.homepage == "https://example.com");
} }
TEST_CASE("parse accepts reserved top-level tables", "[manifest][parse]") { TEST_CASE("parse accepts reserved top-level tables", "[manifest][parse]") {

View File

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