[M5+] flake codegen + SPEC §7/§10 amendment for per-dep nixpkgs pins

This commit is contained in:
2026-05-10 12:55:11 +00:00
parent c4b2a1bc55
commit 935e8d5f79
5 changed files with 348 additions and 129 deletions

View File

@@ -91,6 +91,26 @@ All notable changes to cargoxx will be documented in this file.
6 cases against fixtures derived from a real fmt 10.2.1 response; 6 cases against fixtures derived from a real fmt 10.2.1 response;
`tests/devbox_resolve_live.cpp` (gated by `CARGOXX_NETWORK_TESTS=1`) `tests/devbox_resolve_live.cpp` (gated by `CARGOXX_NETWORK_TESTS=1`)
hits the live API. hits the live API.
- `flake.nix` codegen rewritten for **per-dep nixpkgs revisions**. The
shared `nixpkgs` input always tracks `nixos-unstable` and provides
the toolchain. Each pinned manifest dep
(`<pkg>@<concrete-version>` whose lockfile entry has a non-null
`nixpkgs_rev`) gets its own `nixpkgs_<sanitized>` input, a
matching outputs-lambda parameter, a `pkgs_nixpkgs_<sanitized>`
`let` binding, and a `pkgs_nixpkgs_<sanitized>.<attr>` line in
`buildInputs`. Wildcard / unpinned deps stay on the shared
`pkgs.<attr>`. Sanitization replaces every char outside
`[a-zA-Z0-9_]` with `_`, giving a single identifier-safe form for
all three Nix contexts. Two pinned deps that share the same
`(name, version)` are deduplicated. Live verified:
`cargoxx new app && cargoxx add fmt@10.2.1 && cargoxx add zlib &&
cargoxx build` produces a binary that calls fmt 10.2.1 + zlib from
the toolchain nixpkgs; a second `cargoxx build` regenerates
byte-identical `flake.nix` + `Cargoxx.lock`.
- SPEC.md §7 (flake template) and §10 (version resolution) amended
to describe the per-dep model. The previous single-shared-rev
whole-project SAT was retired (user-directed; documented ABI
trade-off).
- `cargoxx build` now **merges** rather than overwrites - `cargoxx build` now **merges** rather than overwrites
`Cargoxx.lock`: when an existing entry's `(name, version)` still `Cargoxx.lock`: when an existing entry's `(name, version)` still
matches the manifest, its `nixpkgs_rev` is preserved. New deps and matches the manifest, its `nixpkgs_rev` is preserved. New deps and

60
SPEC.md
View File

@@ -277,21 +277,34 @@ Stubs in v0.1 — accepted but only print a deprecation-style message: `cargoxx
## 7. Generated `flake.nix` ## 7. Generated `flake.nix`
cargoxx generates exactly this template. Fields in `<<...>>` are substituted from the manifest. The shared `nixpkgs` input always tracks `nixos-unstable` and provides the
toolchain (clang, cmake, ninja, libc++). Each *pinned* manifest dep
(`<pkg>@<concrete-version>`) gets its own additional `nixpkgs_<...>`
input pointing at a specific commit; the dep is built from that
flake's `pkgs` set instead of the shared one. Wildcard deps stay on
the shared `nixpkgs`.
```nix ```nix
{ {
description = "<<package.name>>"; description = "<<package.name>>";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/<<resolved-rev>>"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# one line per pinned dep, in manifest order:
nixpkgs_<<sanitized_dep_a>>.url = "github:NixOS/nixpkgs/<<rev_a>>";
nixpkgs_<<sanitized_dep_b>>.url = "github:NixOS/nixpkgs/<<rev_b>>";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs, nixpkgs_<<sanitized_dep_a>>,
nixpkgs_<<sanitized_dep_b>>, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
pkgs_nixpkgs_<<sanitized_dep_a>> =
import nixpkgs_<<sanitized_dep_a>> { inherit system; };
pkgs_nixpkgs_<<sanitized_dep_b>> =
import nixpkgs_<<sanitized_dep_b>> { inherit system; };
llvmPkgs = pkgs.llvmPackages; llvmPkgs = pkgs.llvmPackages;
in { in {
devShell = llvmPkgs.libcxxStdenv.mkDerivation { devShell = llvmPkgs.libcxxStdenv.mkDerivation {
@@ -302,6 +315,12 @@ cargoxx generates exactly this template. Fields in `<<...>>` are substituted fro
pkgs.cmake pkgs.cmake
pkgs.clang-tools pkgs.clang-tools
]; ];
buildInputs = [
# pinned dep: from the per-dep nixpkgs set
pkgs_nixpkgs_<<sanitized_dep_a>>.<<dep_a_attr>>
# wildcard / unpinned dep: from the shared `pkgs`
pkgs.<<dep_c_attr>>
];
env.NIX_CFLAGS_COMPILE = toString [ env.NIX_CFLAGS_COMPILE = toString [
"-stdlib=libc++" "-stdlib=libc++"
"-Wno-unused-command-line-argument" "-Wno-unused-command-line-argument"
@@ -316,9 +335,21 @@ cargoxx generates exactly this template. Fields in `<<...>>` are substituted fro
} }
``` ```
`<<resolved-rev>>` is the Nixpkgs commit hash from `Cargoxx.lock`. The toolchain (`clang_21`, `cmake`, `ninja`) is fixed in v0.1 — Clang because module support is most mature there. `<<sanitized_dep>>` is `<dep-name>_<dep-version>` with every char
outside `[a-zA-Z0-9_]` replaced by `_`. The same identifier is reused
in three places: the input attribute, the outputs lambda parameter,
and the `pkgs_...` `let` binding — keeping it a valid Nix identifier
in all three contexts.
Regeneration is idempotent: cargoxx writes the file only if its content would change. This avoids spurious `flake.lock` updates. `<<rev_a>>` etc. come from `Cargoxx.lock`'s per-dep `nixpkgs_rev`
field. Wildcard pins (`pkg = "*"`) leave the field null and emit no
extra input.
The toolchain (`clang_21`, `cmake`, `ninja`) is fixed in v0.1 — Clang
because module support is most mature there.
Regeneration is idempotent: cargoxx writes the file only if its
content would change. This avoids spurious `flake.lock` updates.
--- ---
@@ -515,17 +546,20 @@ Each successful step writes the result to the user overlay before returning.
## 10. Version resolution algorithm ## 10. Version resolution algorithm
`resolve_version(package, version_spec) -> (version, nixpkgs_rev)`: cargoxx pins **per dep**: each `<pkg>@<concrete-version>` resolves to
its own nixpkgs revision and lands as a separate flake input. The
shared `nixpkgs` input always tracks `nixos-unstable` and provides
the toolchain.
1. If `Cargoxx.lock` already pins this package and the spec is satisfied, return the lockfile entry. `resolve_version(package, version) -> nixpkgs_rev`:
2. Query nixhub.io: `https://www.nixhub.io/packages/<package>?_data=routes%2F_nixhub.packages.%24pkg._index`. Parse JSON for available versions and their commits.
3. If nixhub.io is unreachable, fall back to lazamar: `https://lazamar.co.uk/nix-versions/?package=<package>&channel=nixpkgs-unstable`. Parse HTML (well-formed table).
4. If both fail, fall back to a local Nixpkgs git clone at `~/.cache/cargoxx/nixpkgs/`. Run `git log --all -S 'version = "<version>"' -- pkgs/`.
5. Filter the candidate list by the version spec, choose the highest match, return `(version, rev)`.
For the *whole-project* resolution (multiple deps), cargoxx picks one revision: the latest revision that contains acceptable versions of every dependency. This is brute-force in v0.1: for each candidate revision (newest first, capped at 50 attempts), check whether all deps resolve. Take the first hit. 1. **Primary — devbox API.** `GET https://search.devbox.sh/v1/resolve?name=<package>&version=<version>`. The JSON response contains `commit_hash`. Same Jetify backend that powers nixhub.io, but the API is documented and returns the commit directly. (See `devbox/internal/searcher/client.go`.) 10-second timeout.
2. **Fallback — local nixpkgs git clone.** Lazy clone of `https://github.com/NixOS/nixpkgs.git` at `~/.cache/cargoxx/nixpkgs/` (or `$XDG_CACHE_HOME/cargoxx/nixpkgs/`). On a miss, run `git -C <repo> log --all -S 'version = "<version>"' --pretty='%H %ct' -- pkgs/` and pick the youngest matching commit.
3. If both fail, return `ResolutionVersionNotFound`.
If no revision satisfies all constraints simultaneously, fail with a list of conflicting deps. The user resolves manually by relaxing version specs. When `cargoxx add <pkg>@<ver>` succeeds, the rev is written to `Cargoxx.lock` next to the dep entry (`nixpkgs_rev`). `cargoxx build` then merges the lockfile rather than overwriting it — pins survive arbitrary rebuilds. Wildcard pins (`<pkg>` with no `@<ver>`, or `<pkg>@*`) skip resolution entirely; the dep tracks the shared `nixos-unstable` input.
There is no whole-project rev solver. Two pinned deps may pull from different nixpkgs commits, with the ABI-mismatch risk that implies (different glibc / libc++ majors). cargoxx accepts that trade-off in exchange for fine-grained control; a future `cargoxx update` may surface compatibility warnings.
--- ---

View File

@@ -309,9 +309,9 @@ mitigation is to append `-<short-sha>` to the input attr.
| 1. devbox_resolve + parser | ✅ | `df2c25b` | | 1. devbox_resolve + parser | ✅ | `df2c25b` |
| 2. nixpkgs_git_resolve fallback | ✅ | `cb82e91` | | 2. nixpkgs_git_resolve fallback | ✅ | `cb82e91` |
| 3. resolve_version + cmd_add wire-up | ✅ | `6f8e9c4` | | 3. resolve_version + cmd_add wire-up | ✅ | `6f8e9c4` |
| 4. cmd_build lockfile merge | ✅ | (this commit) | | 4. cmd_build lockfile merge | ✅ | `c4b2a1b` |
| 5. flake codegen for per-dep inputs | pending | — | | 5. flake codegen for per-dep inputs | ✅ | (this commit) |
| 6. SPEC §7/§10 amendment + smoke | pending | — | | 6. SPEC §7/§10 amendment + smoke | ✅ | (this commit) |
## End-to-end verification (Phase 6) ## End-to-end verification (Phase 6)

View File

@@ -9,71 +9,139 @@ namespace cargoxx::codegen {
namespace { namespace {
// SPEC.md §7 plus a `buildInputs` slot for resolved dep attrs (TECH_SPEC §10). // One pinned dep gets its own nixpkgs flake input. Unpinned deps stay
// `${...}` in the env.NIX_CFLAGS_COMPILE block is literal Nix, not a marker — // on the shared `nixpkgs` input (which always tracks nixos-unstable).
// our markers use the @@MARKER@@ form. struct DepBinding {
constexpr std::string_view FLAKE_TEMPLATE = R"({ std::string name; // manifest dep name
description = "@@DESCRIPTION@@"; std::string version; // resolved version
std::string nixpkgs_attr; // recipe.nixpkgs_attr (e.g. "fmt_10")
inputs = { std::string sanitized; // "nixpkgs_fmt_10_2_1" — input attr,
nixpkgs.url = "github:NixOS/nixpkgs/@@NIXPKGS_REV@@"; // let-binding stem, lambda param
flake-utils.url = "github:numtide/flake-utils"; std::optional<std::string> rev; // pinned commit (null → unpinned)
}; };
outputs = { self, nixpkgs, flake-utils }: // Replaces every char outside [a-zA-Z0-9_] with '_'. The result is safe
flake-utils.lib.eachDefaultSystem (system: // to use as a Nix identifier (let bindings, lambda destructure params)
let // and as an attribute name (inputs.<attr>) — Nix permits underscores in
pkgs = import nixpkgs { inherit system; }; // both places, hyphens only in attribute names.
llvmPkgs = pkgs.llvmPackages; auto sanitize(std::string_view s) -> std::string {
in {
devShell = llvmPkgs.libcxxStdenv.mkDerivation {
name = "shell";
version = "1.0";
nativeBuildInputs = [
pkgs.ninja
pkgs.cmake
pkgs.clang-tools
];
buildInputs = [
@@DEP_LINES@@ ];
env.NIX_CFLAGS_COMPILE = toString [
"-stdlib=libc++"
"-Wno-unused-command-line-argument"
"-B${pkgs.lib.getLib pkgs.libcxx}/lib"
"-isystem ${pkgs.lib.getDev pkgs.libcxx}/include/c++/v1"
];
hardeningDisable = [
"all"
];
};
});
}
)";
auto substitute(std::string_view tmpl, std::string_view marker, std::string_view value)
-> std::string {
std::string out; std::string out;
out.reserve(tmpl.size()); out.reserve(s.size());
std::size_t pos = 0; for (char c : s) {
while (pos < tmpl.size()) { if (std::isalnum(static_cast<unsigned char>(c)) || c == '_') {
auto next = tmpl.find(marker, pos); out += c;
if (next == std::string_view::npos) { } else {
out.append(tmpl.substr(pos)); out += '_';
break;
} }
out.append(tmpl.substr(pos, next - pos));
out.append(value);
pos = next + marker.size();
} }
return out; return out;
} }
auto stable_dedup(const std::vector<std::string>& xs) -> std::vector<std::string> { auto sanitize_input_attr(std::string_view name, std::string_view version)
std::vector<std::string> out; -> std::string {
return std::format("nixpkgs_{}_{}", sanitize(name), sanitize(version));
}
auto find_lockfile_rev(const lockfile::Lockfile& lock, const std::string& name,
const std::string& version) -> std::optional<std::string> {
for (const auto& p : lock.packages) {
if (p.name == name && p.version == version) {
return p.nixpkgs_rev;
}
}
return std::nullopt;
}
auto build_bindings(const GenerateInputs& in) -> std::vector<DepBinding> {
std::vector<DepBinding> out;
out.reserve(in.manifest.dependencies.size());
for (std::size_t i = 0; i < in.manifest.dependencies.size(); ++i) {
const auto& dep = in.manifest.dependencies[i];
const auto& rec = in.recipes[i];
DepBinding b{
.name = dep.name,
.version = dep.version_spec,
.nixpkgs_attr = rec.nixpkgs_attr,
.sanitized = sanitize_input_attr(dep.name, dep.version_spec),
.rev = find_lockfile_rev(in.lock, dep.name, dep.version_spec),
};
out.push_back(std::move(b));
}
return out;
}
// Pinned deps share a `(name, version, rev)` identity — dedupe so two
// deps that happen to land on the same nixpkgs revision don't generate
// duplicate input attributes.
auto pinned_inputs_dedup(const std::vector<DepBinding>& bindings)
-> std::vector<const DepBinding*> {
std::vector<const DepBinding*> out;
std::set<std::string> seen; std::set<std::string> seen;
for (const auto& x : xs) { for (const auto& b : bindings) {
if (seen.insert(x).second) { if (!b.rev || b.rev->empty()) {
out.push_back(x); continue;
}
if (seen.insert(b.sanitized).second) {
out.push_back(&b);
}
}
return out;
}
auto emit_inputs_block(const std::vector<const DepBinding*>& pinned)
-> std::string {
// Always emit the shared toolchain `nixpkgs` and `flake-utils`
// inputs. Per-pinned-dep inputs land between them so the output
// diff stays stable across reruns.
std::string out =
" inputs = {\n"
" nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n";
for (const auto* b : pinned) {
out += std::format(" {}.url = \"github:NixOS/nixpkgs/{}\";\n",
b->sanitized, *b->rev);
}
out += " flake-utils.url = \"github:numtide/flake-utils\";\n"
" };\n";
return out;
}
auto emit_outputs_params(const std::vector<const DepBinding*>& pinned)
-> std::string {
std::string out = "{ self, nixpkgs";
for (const auto* b : pinned) {
out += ", ";
out += b->sanitized;
}
out += ", flake-utils }";
return out;
}
auto emit_let_bindings(const std::vector<const DepBinding*>& pinned)
-> std::string {
std::string out;
for (const auto* b : pinned) {
out += std::format(" pkgs_{} = import {} {{ inherit system; }};\n",
b->sanitized, b->sanitized);
}
return out;
}
auto emit_build_input_line(const DepBinding& b) -> std::string {
if (b.rev && !b.rev->empty()) {
return std::format(" pkgs_{}.{}\n", b.sanitized, b.nixpkgs_attr);
}
return std::format(" pkgs.{}\n", b.nixpkgs_attr);
}
auto emit_build_inputs(const std::vector<DepBinding>& bindings) -> std::string {
std::set<std::string> seen;
std::string out;
for (const auto& b : bindings) {
auto key = b.rev && !b.rev->empty()
? std::format("pkgs_{}.{}", b.sanitized, b.nixpkgs_attr)
: std::format("pkgs.{}", b.nixpkgs_attr);
if (seen.insert(key).second) {
out += emit_build_input_line(b);
} }
} }
return out; return out;
@@ -82,26 +150,48 @@ auto stable_dedup(const std::vector<std::string>& xs) -> std::vector<std::string
} // namespace } // namespace
auto flake_nix(const GenerateInputs& in) -> std::string { auto flake_nix(const GenerateInputs& in) -> std::string {
auto rev = in.lock.nixpkgs_rev().value_or("nixos-unstable"); auto bindings = build_bindings(in);
auto pinned = pinned_inputs_dedup(bindings);
std::vector<std::string> attrs; std::string out;
attrs.reserve(in.recipes.size()); out += "{\n";
for (const auto& r : in.recipes) { out += std::format(" description = \"{}\";\n\n", in.manifest.package.name);
attrs.push_back(r.nixpkgs_attr);
}
auto deduped = stable_dedup(attrs);
std::string dep_lines; out += emit_inputs_block(pinned);
for (const auto& a : deduped) {
dep_lines += " pkgs.";
dep_lines += a;
dep_lines += '\n';
}
auto out = std::string{FLAKE_TEMPLATE}; out += "\n";
out = substitute(out, "@@DESCRIPTION@@", in.manifest.package.name); out += " outputs = ";
out = substitute(out, "@@NIXPKGS_REV@@", rev); out += emit_outputs_params(pinned);
out = substitute(out, "@@DEP_LINES@@", dep_lines); out += ":\n"
" flake-utils.lib.eachDefaultSystem (system:\n"
" let\n"
" pkgs = import nixpkgs { inherit system; };\n";
out += emit_let_bindings(pinned);
out += " llvmPkgs = pkgs.llvmPackages;\n"
" in {\n"
" devShell = llvmPkgs.libcxxStdenv.mkDerivation {\n"
" name = \"shell\";\n"
" version = \"1.0\";\n"
" nativeBuildInputs = [\n"
" pkgs.ninja\n"
" pkgs.cmake\n"
" pkgs.clang-tools\n"
" ];\n"
" buildInputs = [\n";
out += emit_build_inputs(bindings);
out += " ];\n"
" env.NIX_CFLAGS_COMPILE = toString [\n"
" \"-stdlib=libc++\"\n"
" \"-Wno-unused-command-line-argument\"\n"
" \"-B${pkgs.lib.getLib pkgs.libcxx}/lib\"\n"
" \"-isystem ${pkgs.lib.getDev pkgs.libcxx}/include/c++/v1\"\n"
" ];\n"
" hardeningDisable = [\n"
" \"all\"\n"
" ];\n"
" };\n"
" });\n"
"}\n";
return out; return out;
} }

View File

@@ -13,6 +13,7 @@ using cargoxx::layout::DiscoveredLayout;
using cargoxx::linkdb::Recipe; using cargoxx::linkdb::Recipe;
using cargoxx::lockfile::Lockfile; using cargoxx::lockfile::Lockfile;
using cargoxx::lockfile::LockfilePackage; using cargoxx::lockfile::LockfilePackage;
using cargoxx::manifest::Dependency;
using cargoxx::manifest::Edition; using cargoxx::manifest::Edition;
using cargoxx::manifest::Manifest; using cargoxx::manifest::Manifest;
using cargoxx::manifest::Package; using cargoxx::manifest::Package;
@@ -29,6 +30,14 @@ auto pkg(std::string name) -> Package {
}; };
} }
auto dep(std::string name, std::string version) -> Dependency {
return Dependency{
.name = std::move(name),
.version_spec = std::move(version),
.components = {},
};
}
auto recipe(std::string attr) -> Recipe { auto recipe(std::string attr) -> Recipe {
return Recipe{ return Recipe{
.nixpkgs_attr = std::move(attr), .nixpkgs_attr = std::move(attr),
@@ -50,21 +59,21 @@ auto root_pkg(std::string name, std::string version,
}; };
} }
} // namespace auto dep_pkg(std::string name, std::string version,
std::optional<std::string> rev) -> LockfilePackage {
TEST_CASE("flake_nix renders the package description and rev", return LockfilePackage{
"[codegen][flake]") { .name = std::move(name),
Manifest m{pkg("hello"), {}, {}}; .version = std::move(version),
DiscoveredLayout layout{}; .dependencies = {},
Lockfile lock{1, {root_pkg("hello", "0.1.0", "abc123def456")}}; .nixpkgs_attr = std::nullopt,
GenerateInputs in{m, layout, lock, {}, "/tmp/hello"}; .nixpkgs_rev = std::move(rev),
.linkdb_source = std::nullopt,
auto out = flake_nix(in); };
REQUIRE(out.find("description = \"hello\";") != std::string::npos);
REQUIRE(out.find("nixpkgs/abc123def456") != std::string::npos);
} }
TEST_CASE("flake_nix uses 'nixos-unstable' when no rev is pinned", } // namespace
TEST_CASE("flake_nix always emits the shared nixos-unstable nixpkgs input",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("hello"), {}, {}}; Manifest m{pkg("hello"), {}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
@@ -72,7 +81,63 @@ TEST_CASE("flake_nix uses 'nixos-unstable' when no rev is pinned",
GenerateInputs in{m, layout, lock, {}, "/tmp/hello"}; GenerateInputs in{m, layout, lock, {}, "/tmp/hello"};
auto out = flake_nix(in); auto out = flake_nix(in);
REQUIRE(out.find("nixpkgs/nixos-unstable") != std::string::npos); REQUIRE(out.find("description = \"hello\";") != std::string::npos);
REQUIRE(out.find("nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";") !=
std::string::npos);
// No pinned deps → no extra `nixpkgs_*` inputs.
REQUIRE(out.find("nixpkgs_") == std::string::npos);
}
TEST_CASE("flake_nix emits a per-pinned-dep nixpkgs input", "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "10.2.1")}, {}};
DiscoveredLayout layout{};
Lockfile lock{1, {
root_pkg("app", "0.1.0"),
dep_pkg("fmt", "10.2.1", "abc123def456"),
}};
std::vector<Recipe> recipes = {recipe("fmt_10")};
GenerateInputs in{m, layout, lock, recipes, "/tmp/app"};
auto out = flake_nix(in);
// Per-dep input attribute
REQUIRE(out.find("nixpkgs_fmt_10_2_1.url = \"github:NixOS/nixpkgs/abc123def456\";") !=
std::string::npos);
// Outputs lambda binds it
REQUIRE(out.find(", nixpkgs_fmt_10_2_1,") != std::string::npos);
// let-binding constructs pkgs_<sanitized>
REQUIRE(out.find("pkgs_nixpkgs_fmt_10_2_1 = import nixpkgs_fmt_10_2_1") !=
std::string::npos);
// buildInputs uses the pinned set, not the shared `pkgs`
REQUIRE(out.find("pkgs_nixpkgs_fmt_10_2_1.fmt_10") != std::string::npos);
}
TEST_CASE("flake_nix uses shared `pkgs` for unpinned deps",
"[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "*")}, {}};
DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("app", "0.1.0"), dep_pkg("fmt", "*", std::nullopt)}};
std::vector<Recipe> recipes = {recipe("fmt_10")};
GenerateInputs in{m, layout, lock, recipes, "/tmp/app"};
auto out = flake_nix(in);
REQUIRE(out.find("pkgs.fmt_10") != std::string::npos);
REQUIRE(out.find("nixpkgs_fmt_") == std::string::npos);
}
TEST_CASE("flake_nix mixes pinned and unpinned deps", "[codegen][flake]") {
Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("zlib", "*")}, {}};
DiscoveredLayout layout{};
Lockfile lock{1, {
root_pkg("app", "0.1.0"),
dep_pkg("fmt", "10.2.1", "abc"),
dep_pkg("zlib", "*", std::nullopt),
}};
std::vector<Recipe> recipes = {recipe("fmt_10"), recipe("zlib")};
GenerateInputs in{m, layout, lock, recipes, "/tmp/app"};
auto out = flake_nix(in);
REQUIRE(out.find("pkgs_nixpkgs_fmt_10_2_1.fmt_10") != std::string::npos);
REQUIRE(out.find("pkgs.zlib") != std::string::npos);
} }
TEST_CASE("flake_nix emits an empty buildInputs list when there are no deps", TEST_CASE("flake_nix emits an empty buildInputs list when there are no deps",
@@ -86,44 +151,38 @@ TEST_CASE("flake_nix emits an empty buildInputs list when there are no deps",
REQUIRE(out.find("buildInputs = [\n ];") != std::string::npos); REQUIRE(out.find("buildInputs = [\n ];") != std::string::npos);
} }
TEST_CASE("flake_nix emits one pkgs.<attr> line per dep", TEST_CASE("flake_nix dedupes deps that share input + attr",
"[codegen][flake]") { "[codegen][flake]") {
Manifest m{pkg("app"), {}, {}}; Manifest m{pkg("app"),
{dep("boost", "1.84.0"), dep("boost", "1.84.0")},
{}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("app", "0.1.0", "rev42")}}; Lockfile lock{1, {
std::vector<Recipe> recipes = {recipe("fmt_10"), recipe("spdlog")}; root_pkg("app", "0.1.0"),
GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; dep_pkg("boost", "1.84.0", "rev42"),
}};
auto out = flake_nix(in);
REQUIRE(out.find("pkgs.fmt_10") != std::string::npos);
REQUIRE(out.find("pkgs.spdlog") != std::string::npos);
}
TEST_CASE("flake_nix dedupes duplicate nixpkgs_attrs", "[codegen][flake]") {
Manifest m{pkg("app"), {}, {}};
DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("app", "0.1.0", "rev42")}};
// boost appears twice — same nixpkgs_attr from two component-bearing entries
std::vector<Recipe> recipes = {recipe("boost"), recipe("boost")}; std::vector<Recipe> recipes = {recipe("boost"), recipe("boost")};
GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; GenerateInputs in{m, layout, lock, recipes, "/tmp/app"};
auto out = flake_nix(in); auto out = flake_nix(in);
auto first = out.find("pkgs.boost"); auto first = out.find("pkgs_nixpkgs_boost_1_84_0.boost");
REQUIRE(first != std::string::npos); REQUIRE(first != std::string::npos);
REQUIRE(out.find("pkgs.boost", first + 1) == std::string::npos); REQUIRE(out.find("pkgs_nixpkgs_boost_1_84_0.boost", first + 1) ==
std::string::npos);
} }
TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") { TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") {
Manifest m{pkg("app"), {}, {}}; Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("spdlog", "*")}, {}};
DiscoveredLayout layout{}; DiscoveredLayout layout{};
Lockfile lock{1, {root_pkg("app", "0.1.0", "rev42")}}; Lockfile lock{1, {
std::vector<Recipe> recipes = {recipe("fmt_10"), recipe("spdlog"), root_pkg("app", "0.1.0"),
recipe("nlohmann_json")}; dep_pkg("fmt", "10.2.1", "abc"),
dep_pkg("spdlog", "*", std::nullopt),
}};
std::vector<Recipe> recipes = {recipe("fmt_10"), recipe("spdlog")};
GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; GenerateInputs in{m, layout, lock, recipes, "/tmp/app"};
auto a = flake_nix(in); REQUIRE(flake_nix(in) == flake_nix(in));
auto b = flake_nix(in);
REQUIRE(a == b);
} }
TEST_CASE("flake_nix output ends with a newline", "[codegen][flake]") { TEST_CASE("flake_nix output ends with a newline", "[codegen][flake]") {
@@ -136,3 +195,19 @@ TEST_CASE("flake_nix output ends with a newline", "[codegen][flake]") {
REQUIRE_FALSE(out.empty()); REQUIRE_FALSE(out.empty());
REQUIRE(out.back() == '\n'); REQUIRE(out.back() == '\n');
} }
TEST_CASE("flake_nix sanitizes hyphens and dots in dep names",
"[codegen][flake]") {
Manifest m{pkg("app"), {dep("range-v3", "0.12.0")}, {}};
DiscoveredLayout layout{};
Lockfile lock{1, {
root_pkg("app", "0.1.0"),
dep_pkg("range-v3", "0.12.0", "rev123"),
}};
std::vector<Recipe> recipes = {recipe("range-v3")};
GenerateInputs in{m, layout, lock, recipes, "/tmp/app"};
auto out = flake_nix(in);
REQUIRE(out.find("nixpkgs_range_v3_0_12_0.url = "
"\"github:NixOS/nixpkgs/rev123\";") != std::string::npos);
}