ADR-053: One Rust Crate per Nikaia Package

Status: Superseded by ADR-286 — its D1-D4 are D19-D22 there Related: ADR-047 D2 (the path dependency this completes, and the five rules it carries), ADR-046 D4 (the use check that does not have to widen under this answer), ADR-002 D1 (the same move, made once already: the manifest is translated and Cargo resolves) and D4 (std by path into the sysroot, which is this shape working today), ADR-043 (the overflow checks, and the one thing that stops being free)

1. Context

ADR-047 D2 gave a package a path dependency, and it is built one level deep: a package that declares Nikaia dependencies of its own is refused rather than resolved. So a library that uses a library does not exist, and the refusal’s own advice — “depend on them directly for now” — asks the consumer to know and restate the library’s internals, which is what a library is for avoiding.

What stops the next level is not the visibility rule. D2 rule 2 already says a transitive dependency is invisible, and it is enforced twice. What stops it is that everything is flattened into one Rust crate: cargo_project says of a Nikaia dependency that it “declares nothing to Cargo: its files are read into the same crate”, and collect_with reads each dependency’s src/ into the same unit list.

The flattening is deliberate, and it buys exactly one thing: D2 rule 5. A Nikaia dependency gets ADR-043’s overflow checks because it is literally the program, rather than landing on the foreign side where a dependency mechanically appears.

It also creates a question Cargo does not have. Every package becomes a mod at one root, named by the manifest key of whoever depends on it, so:

The two other answers, and why neither is taken

There are three ways to answer the question above, and the two not taken are recorded here because each fails for a reason that outlives this decision.

(a) Name the module by package identity. The root module’s name comes from something stable about the package rather than from the consumer’s word, and the manifest key becomes a local alias onto it. The clash disappears; the leak does not — the module is still at the root, so ADR-046 D4’s check has to widen from use lines to qualified names. And “package identity” is the registry-shaped question ADR-002 D1 §5 declines to answer, arriving early and for a reason that has nothing to do with distribution.

(b) Nest the modules. B’s dependencies live inside B’s module: mod b, with mod c inside it. A has no path to C, so rule 2 is structural rather than checked — the same property D1 gets. But the same package reached through two parents becomes two modules, which contradicts ADR-047 D2 rule 3: same path, same package. It is the cheapest of the three to emit and it breaks a rule decided one record earlier.

What each costs. (a) pulls the registry question forward and widens a check that was written for use lines. (b) is cheap and breaks rule 3. Neither removes the question — they answer it with machinery this project would then own, where D1 answers it with machinery it already leans on: a name resolves per crate, so two packages under one key are in two different crates and never meet, and a package reached twice is one crate because Cargo already works that way.

2. Decisions

D1 — A Nikaia package is generated as its own Rust crate.

A build emits a Cargo workspace: the entry package as the binary crate, and every package it depends on — transitively — as a library crate beside it, each with its own Cargo.toml naming its own dependencies under its own manifest keys.

std already arrives this way (ADR-002 D4), so this generalises the shape the generated project already has rather than introducing one.

D2 — The manifest key is the crate’s local name for the package, and nothing more.

A dependency is emitted as Cargo’s renaming form — the package is named by its identity, the key is the name the depending crate uses:

[dependencies]
c = { package = "…", path = "…" }

So two packages that both want the key c are in two different crates and never meet, and the same package reached through two parents is one crate, which is ADR-047 D2 rule 3 holding because Cargo already works that way.

D3 — Rule 2 stops being checked and becomes structural.

A transitive package is not a dependency of the crate that would name it, so naming it is a resolution failure in Rust with no Nikaia rule behind it. The two places D2 rule 2 is enforced today — the empty reachable set collect_with hands a dependency, and the refusal in packages_of — are no longer what makes it true.

And ADR-046 D4 does not have to widen. The alternative answers to this question both left the transitive module reachable from the root and needed the use-line check extended to qualified names; under D1 there is nothing at the root to reach.

D4 — Rule 5 is generated rather than free.

This is what the flattening was buying, and it is the price of the answer: with separate crates, each generated Nikaia crate has to be named on the program’s side of the profile, beside the exception that turns the checks off for every foreign package.

[profile.release]
overflow-checks = false
[profile.release.package.<each Nikaia crate>]
overflow-checks = true

Without it a library computes silently wrong numbers while its caller aborts, which is exactly the accident ADR-047 D2 rule 5 was written to prevent.

3. The shape was built by hand before this was decided

Four crates, written out as the generator would emit them, and the four things the answer turns on, each run rather than argued:

asked result
does the workspace build yes
can app name the transitive c no — E0433: failed to resolve: use of unresolved module or unlinked crate
does the overflow check reach a Nikaia library crate yes — panic_const_add_overflow from inside it
does a package not on the list still wrap yes — i32::MAX + 1 came back -2147483648
does the diamond unify yes — app and libb both take libc, and a c::Id built in app is accepted by a function in libb. In Cargo when this was written by hand, and in the checker in front of it since: see §5

The third and fourth together are D4: the split is what ADR-043 requires, and it works in the generated manifest’s own terms.

4. What this does not decide

5. What is built

All four decisions, and the generator produces the shape §3 was built by hand to check.

project::members_of walks the graph from the entry package breadth-first, so a package reached twice is one member and the order is the same on every machine; project::cargo_workspace writes a virtual root and one member per package, with D2’s renaming form for each Nikaia dependency and D4’s profile rows for each crate. A build emits a workspace always, even for a program that depends on no package at all: one shape that is always taken is worth more than a second one taken rarely, and it is the shape the overflow rows need. The refusal in project::packages_of is gone, and rule 2 is enforced nowhere — naming a transitive package now fails to resolve in Rust, reported against the .nika line that wrote it.

modules::collect_with still reads a dependency’s files, and this is the one place the answer is not simply “its own crate”. A package’s public surface is what its files declare, so the ledger and the type check need them; the crate that carries them is generated beside this one, so Program::emit writes none of them. Their own use lines are checked against their own manifest keys rather than against nothing, which is D3 from the other side: the level below a dependency is that dependency’s business.

The diamond is built too, and it was the last thing D2 decided that the compiler in front of Cargo did not do. Cargo unifies a package reached through two parents and §3 shows it accepting the value; the ledger named a type by the key it was reached through, so a program that depends on libc as deep and on libb, which depends on libc as c, was told deep::Id is not c::Id — one type refused for having two spellings, with a help line asking for what was already written.

A type’s identity is now the package rather than the word a consumer happened to use for it. project::renames_in reads each dependency’s own manifest keys and resolves each to its directory, giving the one word this build uses for it: the program’s own key where the program names it too, and otherwise the package’s [package] name, which members_of has already refused to let two packages share. Either way it is a function of the directory and not of who asked. Ledger::absorb_renaming applies the table after qualifying, so a name the package declares itself is never touched — qualify has already put that package’s word in front of it.

One level deep, because one level is what is read: what a dependency’s own dependencies are named is that crate’s business (D3), and nothing below the level modules::collect_with reads is in this program’s ledger to be renamed.

This site is open source. Improve this page.