diff --git a/CHANGELOG.md b/CHANGELOG.md index d70b1f7..aa23ba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; `tests/devbox_resolve_live.cpp` (gated by `CARGOXX_NETWORK_TESTS=1`) 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 + (`@` whose lockfile entry has a non-null + `nixpkgs_rev`) gets its own `nixpkgs_` input, a + matching outputs-lambda parameter, a `pkgs_nixpkgs_` + `let` binding, and a `pkgs_nixpkgs_.` line in + `buildInputs`. Wildcard / unpinned deps stay on the shared + `pkgs.`. 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.lock`: when an existing entry's `(name, version)` still matches the manifest, its `nixpkgs_rev` is preserved. New deps and diff --git a/SPEC.md b/SPEC.md index 6d2b084..3a61d62 100644 --- a/SPEC.md +++ b/SPEC.md @@ -277,21 +277,34 @@ Stubs in v0.1 — accepted but only print a deprecation-style message: `cargoxx ## 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 +(`@`) 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 { description = "<>"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/<>"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # one line per pinned dep, in manifest order: + nixpkgs_<>.url = "github:NixOS/nixpkgs/<>"; + nixpkgs_<>.url = "github:NixOS/nixpkgs/<>"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, nixpkgs_<>, + nixpkgs_<>, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; + pkgs_nixpkgs_<> = + import nixpkgs_<> { inherit system; }; + pkgs_nixpkgs_<> = + import nixpkgs_<> { inherit system; }; llvmPkgs = pkgs.llvmPackages; in { devShell = llvmPkgs.libcxxStdenv.mkDerivation { @@ -302,6 +315,12 @@ cargoxx generates exactly this template. Fields in `<<...>>` are substituted fro pkgs.cmake pkgs.clang-tools ]; + buildInputs = [ + # pinned dep: from the per-dep nixpkgs set + pkgs_nixpkgs_<>.<> + # wildcard / unpinned dep: from the shared `pkgs` + pkgs.<> + ]; env.NIX_CFLAGS_COMPILE = toString [ "-stdlib=libc++" "-Wno-unused-command-line-argument" @@ -316,9 +335,21 @@ cargoxx generates exactly this template. Fields in `<<...>>` are substituted fro } ``` -`<>` 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. +`<>` is `_` 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. +`<>` 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 -`resolve_version(package, version_spec) -> (version, nixpkgs_rev)`: +cargoxx pins **per dep**: each `@` 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. -2. Query nixhub.io: `https://www.nixhub.io/packages/?_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=&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 = ""' -- pkgs/`. -5. Filter the candidate list by the version spec, choose the highest match, return `(version, rev)`. +`resolve_version(package, version) -> nixpkgs_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=&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 log --all -S '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 @` 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 (`` with no `@`, or `@*`) 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. --- diff --git a/docs/version-resolution.md b/docs/version-resolution.md index 51d5561..9733c92 100644 --- a/docs/version-resolution.md +++ b/docs/version-resolution.md @@ -309,9 +309,9 @@ mitigation is to append `-` to the input attr. | 1. devbox_resolve + parser | ✅ | `df2c25b` | | 2. nixpkgs_git_resolve fallback | ✅ | `cb82e91` | | 3. resolve_version + cmd_add wire-up | ✅ | `6f8e9c4` | -| 4. cmd_build lockfile merge | ✅ | (this commit) | -| 5. flake codegen for per-dep inputs | pending | — | -| 6. SPEC §7/§10 amendment + smoke | pending | — | +| 4. cmd_build lockfile merge | ✅ | `c4b2a1b` | +| 5. flake codegen for per-dep inputs | ✅ | (this commit) | +| 6. SPEC §7/§10 amendment + smoke | ✅ | (this commit) | ## End-to-end verification (Phase 6) diff --git a/src/codegen/flake.cpp b/src/codegen/flake.cpp index 7b3f3ba..ff11dc2 100644 --- a/src/codegen/flake.cpp +++ b/src/codegen/flake.cpp @@ -9,71 +9,139 @@ namespace cargoxx::codegen { namespace { -// SPEC.md §7 plus a `buildInputs` slot for resolved dep attrs (TECH_SPEC §10). -// `${...}` in the env.NIX_CFLAGS_COMPILE block is literal Nix, not a marker — -// our markers use the @@MARKER@@ form. -constexpr std::string_view FLAKE_TEMPLATE = R"({ - description = "@@DESCRIPTION@@"; +// One pinned dep gets its own nixpkgs flake input. Unpinned deps stay +// on the shared `nixpkgs` input (which always tracks nixos-unstable). +struct DepBinding { + std::string name; // manifest dep name + std::string version; // resolved version + std::string nixpkgs_attr; // recipe.nixpkgs_attr (e.g. "fmt_10") + std::string sanitized; // "nixpkgs_fmt_10_2_1" — input attr, + // let-binding stem, lambda param + std::optional rev; // pinned commit (null → unpinned) +}; - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/@@NIXPKGS_REV@@"; - flake-utils.url = "github:numtide/flake-utils"; - }; - - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { inherit system; }; - llvmPkgs = pkgs.llvmPackages; - 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 { +// 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 +// both places, hyphens only in attribute names. +auto sanitize(std::string_view s) -> std::string { std::string out; - out.reserve(tmpl.size()); - std::size_t pos = 0; - while (pos < tmpl.size()) { - auto next = tmpl.find(marker, pos); - if (next == std::string_view::npos) { - out.append(tmpl.substr(pos)); - break; + out.reserve(s.size()); + for (char c : s) { + if (std::isalnum(static_cast(c)) || c == '_') { + out += c; + } else { + out += '_'; } - out.append(tmpl.substr(pos, next - pos)); - out.append(value); - pos = next + marker.size(); } return out; } -auto stable_dedup(const std::vector& xs) -> std::vector { - std::vector out; +auto sanitize_input_attr(std::string_view name, std::string_view version) + -> 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 { + 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 { + std::vector 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& bindings) + -> std::vector { + std::vector out; std::set seen; - for (const auto& x : xs) { - if (seen.insert(x).second) { - out.push_back(x); + for (const auto& b : bindings) { + if (!b.rev || b.rev->empty()) { + continue; + } + if (seen.insert(b.sanitized).second) { + out.push_back(&b); + } + } + return out; +} + +auto emit_inputs_block(const std::vector& 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& 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& 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& bindings) -> std::string { + std::set 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; @@ -82,26 +150,48 @@ auto stable_dedup(const std::vector& xs) -> std::vector 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 attrs; - attrs.reserve(in.recipes.size()); - for (const auto& r : in.recipes) { - attrs.push_back(r.nixpkgs_attr); - } - auto deduped = stable_dedup(attrs); + std::string out; + out += "{\n"; + out += std::format(" description = \"{}\";\n\n", in.manifest.package.name); - std::string dep_lines; - for (const auto& a : deduped) { - dep_lines += " pkgs."; - dep_lines += a; - dep_lines += '\n'; - } + out += emit_inputs_block(pinned); - auto out = std::string{FLAKE_TEMPLATE}; - out = substitute(out, "@@DESCRIPTION@@", in.manifest.package.name); - out = substitute(out, "@@NIXPKGS_REV@@", rev); - out = substitute(out, "@@DEP_LINES@@", dep_lines); + out += "\n"; + out += " outputs = "; + out += emit_outputs_params(pinned); + 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; } diff --git a/tests/codegen_flake.cpp b/tests/codegen_flake.cpp index a61dccb..0ad357a 100644 --- a/tests/codegen_flake.cpp +++ b/tests/codegen_flake.cpp @@ -13,6 +13,7 @@ using cargoxx::layout::DiscoveredLayout; using cargoxx::linkdb::Recipe; using cargoxx::lockfile::Lockfile; using cargoxx::lockfile::LockfilePackage; +using cargoxx::manifest::Dependency; using cargoxx::manifest::Edition; using cargoxx::manifest::Manifest; 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 { return Recipe{ .nixpkgs_attr = std::move(attr), @@ -50,21 +59,21 @@ auto root_pkg(std::string name, std::string version, }; } -} // namespace - -TEST_CASE("flake_nix renders the package description and rev", - "[codegen][flake]") { - Manifest m{pkg("hello"), {}, {}}; - DiscoveredLayout layout{}; - Lockfile lock{1, {root_pkg("hello", "0.1.0", "abc123def456")}}; - GenerateInputs in{m, layout, lock, {}, "/tmp/hello"}; - - auto out = flake_nix(in); - REQUIRE(out.find("description = \"hello\";") != std::string::npos); - REQUIRE(out.find("nixpkgs/abc123def456") != std::string::npos); +auto dep_pkg(std::string name, std::string version, + std::optional rev) -> LockfilePackage { + return LockfilePackage{ + .name = std::move(name), + .version = std::move(version), + .dependencies = {}, + .nixpkgs_attr = std::nullopt, + .nixpkgs_rev = std::move(rev), + .linkdb_source = std::nullopt, + }; } -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]") { Manifest m{pkg("hello"), {}, {}}; 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"}; 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 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_ + 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 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 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", @@ -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); } -TEST_CASE("flake_nix emits one pkgs. line per dep", +TEST_CASE("flake_nix dedupes deps that share input + attr", "[codegen][flake]") { - Manifest m{pkg("app"), {}, {}}; + Manifest m{pkg("app"), + {dep("boost", "1.84.0"), dep("boost", "1.84.0")}, + {}}; DiscoveredLayout layout{}; - Lockfile lock{1, {root_pkg("app", "0.1.0", "rev42")}}; - std::vector recipes = {recipe("fmt_10"), recipe("spdlog")}; - 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("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 + Lockfile lock{1, { + root_pkg("app", "0.1.0"), + dep_pkg("boost", "1.84.0", "rev42"), + }}; std::vector recipes = {recipe("boost"), recipe("boost")}; GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; 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(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]") { - Manifest m{pkg("app"), {}, {}}; + Manifest m{pkg("app"), {dep("fmt", "10.2.1"), dep("spdlog", "*")}, {}}; DiscoveredLayout layout{}; - Lockfile lock{1, {root_pkg("app", "0.1.0", "rev42")}}; - std::vector recipes = {recipe("fmt_10"), recipe("spdlog"), - recipe("nlohmann_json")}; + Lockfile lock{1, { + root_pkg("app", "0.1.0"), + dep_pkg("fmt", "10.2.1", "abc"), + dep_pkg("spdlog", "*", std::nullopt), + }}; + std::vector recipes = {recipe("fmt_10"), recipe("spdlog")}; GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; - auto a = flake_nix(in); - auto b = flake_nix(in); - REQUIRE(a == b); + REQUIRE(flake_nix(in) == flake_nix(in)); } 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(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 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); +}