cc @epage @joshtriplett @ehuss @weihanglo @Byron
This asks the Cargo team to settle four related parts of -Z bindeps (currently unstable): how features are shared, whether versions remain shared, whether lib = true remains, and how artifact dependencies appear in cargo metadata. An artifact dependency asks Cargo to build a binary, static library, or dynamic library from another package and provide the resulting path to a crate or build script.
I propose:
- Features requested by one artifact dependency do not affect any other use of that package, including its transitive dependencies.
- Several outputs requested by one dependency continue to share features.
- Versions remain shared across the entire Cargo dependency tree.
lib = true is retired for new manifests.
- Metadata reports one structured record per artifact dependency rather than one record per output.
Here, a dependency declaration means one named entry in [dependencies], [build-dependencies], or [dev-dependencies]. This proposal does not change feature sharing between ordinary dependencies outside an artifact dependency's tree, or stabilize any part of -Z bindeps.
Current behavior and the problem
Suppose schema contains a code generator binary and the runtime library used by its generated code:
[dependencies]
schema-runtime = { package = "schema", version = "1", features = ["runtime"] }
[build-dependencies]
schema-codegen = { package = "schema", version = "1", artifact = "bin:schema-codegen", features = ["cli"] }
Cargo builds schema-codegen before the build script runs and exposes its path through a CARGO_BIN_FILE_... environment variable. The parent crate links the schema library as schema_runtime. Resolver v2 already keeps their features separate because one is a normal dependency and the other is a build dependency. Cargo selects one version of schema for both.
RFC 3176 permits several names for the same package. Cargo still rejects that case today; #17067 implements it without changing feature behavior. It can land before this design is settled. With that PR applied, this becomes valid:
[build-dependencies]
schema-json = { package = "schema", version = "1", artifact = "bin:schema-codegen", features = ["json"] }
schema-protobuf = { package = "schema", version = "1", artifact = "bin:schema-codegen", features = ["protobuf"] }
#17067 keeps Cargo's current rule of selecting features by package and artifact target. Because these declarations have the same package and target, both generator builds receive json and protobuf. The features need not conflict for this to be surprising: an optional parser, backend, or logging stack requested for one generator also changes the other.
I propose that schema-json receive json and schema-protobuf receive protobuf. The separation follows their normal and build dependencies as well. The same rule applies when an artifact and an ordinary library dependency use the same package and dependency kind: features requested through the artifact do not move in either direction between the two declarations.
All outputs listed by one declaration still share features. They describe products of one requested build. Users who need different feature sets can write separate declarations.
This change can compile the same package and its transitive dependencies more than once. That increases build time and target-directory size. Nightly users whose builds rely on a feature being enabled indirectly through another declaration may also stop compiling, and cargo tree and --unit-graph will show separate builds. No stable behavior, lockfile, or stable metadata changes, so this remains inexpensive to reverse before stabilization.
How we arrived here
RFC 3028 introduced artifact dependencies and lib = true; #9992 implemented them. It kept version selection unified and separated features when an artifact was built for a different target.
RFC 3176 added same-package aliases. It says Cargo combines declarations for a given artifact target and separates features only across different targets. Declaration-private features reverse that part of the accepted design, so they require an RFC amendment and explicit Cargo team agreement.
Why keep version selection shared?
Independent versions have a real benefit. For example, a generator might require helper >=1,<1.5 while the runtime requires helper >=1.8,<2. Cargo treats those versions as compatible and tries to select one, so the build cannot resolve. Independent artifact resolution could select both.
I do not think that benefit currently justifies the implementation cost. Cargo identifies resolved packages by name, version, and source throughout the resolver, lockfile, metadata, and package-selection commands. Independent resolution would require another identity for each occurrence, a new lockfile format, new cargo update and [patch] rules, and cycle handling for nested artifacts. It would also increase duplicate downloads and builds. Users encountering the conflict must currently align the requirements, patch a dependency, fork one side, or build the tool separately.
We do not have evidence that this problem is common enough to warrant that system-wide change. Shared versions also help keep a code generator and runtime on compatible releases. This proposal therefore retains the behavior required by RFCs 3028 and 3176. Version isolation can return as a separate proposal if real use cases justify its cost.
Why retire lib = true?
The original syntax can ask one declaration for both an artifact and the package's Rust library:
[dependencies]
schema = { version = "1", artifact = "bin:schema-codegen", lib = true, features = ["json"] }
The one features list then controls two relationships with different sharing rules: an artifact with private features and a library using Cargo's ordinary feature behavior. Separate declarations state those roles directly, as in the first example. This is not a mechanical rewrite: each new declaration must list the features its role needs. The split becomes possible once #17067 permits two names for one package.
lib = true should become an error in new manifests, and cargo publish should stop writing it. Cargo should continue parsing the field in registry entries published by older nightly versions, but apply the new feature rule. Already-published nightly packages that relied on features being shared between the library and artifact may therefore need an update. Preserving the old behavior would require a permanent second sharing model in the resolver, which does not seem worthwhile for an unstable feature. This is another change to RFC 3028 and belongs in the same amendment.
Alternative: opt-in feature isolation
@epage suggested preserving today's sharing rule and using a future “opaque dependency” feature for isolation. In behavioral terms, users would add a separate option when they want one declaration's features not to combine with another use of the package.
A general option may be useful, but artifact dependencies already name separately built products and select their targets and outputs. Requiring another option would make sharing the default even though it is not visible from these declarations. I prefer private features as the artifact default; a general mechanism could later reuse the implementation.
Implementation
I tested feasibility in an unpublished local prototype based on an earlier revision of #17067. It carries a declaration's identity through feature resolution and compilation without changing version resolution or Cargo.lock. Its tests cover isolation from ordinary dependencies, isolation between aliases, transitive isolation, and feature sharing between outputs from one declaration. Nested artifact declarations still need explicit coverage.
The expected cost is visible in the tests: a transitive package used by two artifact declarations becomes two compilation units when each reaches it with different features.
Metadata
Metadata consumers need to match an artifact declaration to its resolved package, outputs, dependency kind, platform condition, and compilation target. Requested features already appear on packages[].dependencies[]. Command-specific activated features belong in --unit-graph, because cargo metadata cannot accurately represent feature sets that vary by command and dependency kind.
The discussion in #17067 exposed a naming problem in the current representation. A dependency declaration has a name, a Rust library dependency has an extern-crate name, and an artifact has a target name. For a binary artifact there is no extern crate, so extern_name does not describe what the field contains.
Today Cargo groups dependencies by resolved package and writes one dep_kinds entry per output:
{
"name": "schema_runtime",
"pkg": "registry+https://github.com/rust-lang/crates.io-index#schema@1.2.3",
"dep_kinds": [
{ "kind": null, "target": null },
{ "kind": "build", "target": null, "artifact": "bin", "extern_name": "schema_codegen", "bin_name": "schema-codegen" }
]
}
Here target is a platform condition such as cfg(windows), while compile_target is the Rust target used to build the artifact. extern_name is confusing for a binary that is never passed to Rust as an extern crate.
I propose keeping ordinary Rust dependencies in dep_kinds and grouping each artifact declaration once:
{
"name": "schema_runtime",
"pkg": "registry+https://github.com/rust-lang/crates.io-index#schema@1.2.3",
"dep_kinds": [{ "kind": null, "target": null }],
"artifacts": [{
"dependency_name": "schema-codegen",
"kind": "build",
"platform": null,
"compile_target": null,
"outputs": [{ "kind": "bin", "name": "schema-codegen" }]
}]
}
Two aliases produce two artifacts entries. The outer name retains its metadata-v1 meaning: the extern-crate name used for an ordinary Rust library dependency. It is empty when the resolved package has no library edge. artifacts[].dependency_name is the dependency key from the manifest, before Rust extern-crate normalization. artifacts[].outputs[].name is the selected target's name. platform replaces the old target name for the declaration's platform condition; compile_target still names the Rust target used to build the artifact.
This supersedes #17350. These fields have only appeared under -Z bindeps, so revising them does not change stable metadata, but nightly consumers will need to migrate. In particular, an artifact-only dependency will have an empty dep_kinds array; consumers must read artifacts to retain that edge. Cargo should emit both forms for one release cycle, document the old fields as deprecated, notify cargo_metadata maintainers and known build-system consumers, and then remove the old fields.
Decisions requested
- Should features requested by an artifact declaration be kept separate from every other declaration of that package?
- Should that separation include features reached through transitive dependencies, accepting duplicate compilation?
- Should several outputs requested by one declaration continue to share features?
- Should version selection remain shared?
- Should
lib = true be retired for new manifests, with older registry entries parsed but resolved under the new feature rule?
- Should metadata use one structured entry per artifact declaration instead of one
dep_kinds entry per output?
cc @epage @joshtriplett @ehuss @weihanglo @Byron
This asks the Cargo team to settle four related parts of
-Z bindeps(currently unstable): how features are shared, whether versions remain shared, whetherlib = trueremains, and how artifact dependencies appear incargo metadata. An artifact dependency asks Cargo to build a binary, static library, or dynamic library from another package and provide the resulting path to a crate or build script.I propose:
lib = trueis retired for new manifests.Here, a dependency declaration means one named entry in
[dependencies],[build-dependencies], or[dev-dependencies]. This proposal does not change feature sharing between ordinary dependencies outside an artifact dependency's tree, or stabilize any part of-Z bindeps.Current behavior and the problem
Suppose
schemacontains a code generator binary and the runtime library used by its generated code:Cargo builds
schema-codegenbefore the build script runs and exposes its path through aCARGO_BIN_FILE_...environment variable. The parent crate links theschemalibrary asschema_runtime. Resolver v2 already keeps their features separate because one is a normal dependency and the other is a build dependency. Cargo selects one version ofschemafor both.RFC 3176 permits several names for the same package. Cargo still rejects that case today; #17067 implements it without changing feature behavior. It can land before this design is settled. With that PR applied, this becomes valid:
#17067 keeps Cargo's current rule of selecting features by package and artifact target. Because these declarations have the same package and target, both generator builds receive
jsonandprotobuf. The features need not conflict for this to be surprising: an optional parser, backend, or logging stack requested for one generator also changes the other.I propose that
schema-jsonreceivejsonandschema-protobufreceiveprotobuf. The separation follows their normal and build dependencies as well. The same rule applies when an artifact and an ordinary library dependency use the same package and dependency kind: features requested through the artifact do not move in either direction between the two declarations.All outputs listed by one declaration still share features. They describe products of one requested build. Users who need different feature sets can write separate declarations.
This change can compile the same package and its transitive dependencies more than once. That increases build time and target-directory size. Nightly users whose builds rely on a feature being enabled indirectly through another declaration may also stop compiling, and
cargo treeand--unit-graphwill show separate builds. No stable behavior, lockfile, or stable metadata changes, so this remains inexpensive to reverse before stabilization.How we arrived here
RFC 3028 introduced artifact dependencies and
lib = true; #9992 implemented them. It kept version selection unified and separated features when an artifact was built for a different target.RFC 3176 added same-package aliases. It says Cargo combines declarations for a given artifact target and separates features only across different targets. Declaration-private features reverse that part of the accepted design, so they require an RFC amendment and explicit Cargo team agreement.
Why keep version selection shared?
Independent versions have a real benefit. For example, a generator might require
helper >=1,<1.5while the runtime requireshelper >=1.8,<2. Cargo treats those versions as compatible and tries to select one, so the build cannot resolve. Independent artifact resolution could select both.I do not think that benefit currently justifies the implementation cost. Cargo identifies resolved packages by name, version, and source throughout the resolver, lockfile, metadata, and package-selection commands. Independent resolution would require another identity for each occurrence, a new lockfile format, new
cargo updateand[patch]rules, and cycle handling for nested artifacts. It would also increase duplicate downloads and builds. Users encountering the conflict must currently align the requirements, patch a dependency, fork one side, or build the tool separately.We do not have evidence that this problem is common enough to warrant that system-wide change. Shared versions also help keep a code generator and runtime on compatible releases. This proposal therefore retains the behavior required by RFCs 3028 and 3176. Version isolation can return as a separate proposal if real use cases justify its cost.
Why retire
lib = true?The original syntax can ask one declaration for both an artifact and the package's Rust library:
The one
featureslist then controls two relationships with different sharing rules: an artifact with private features and a library using Cargo's ordinary feature behavior. Separate declarations state those roles directly, as in the first example. This is not a mechanical rewrite: each new declaration must list the features its role needs. The split becomes possible once #17067 permits two names for one package.lib = trueshould become an error in new manifests, andcargo publishshould stop writing it. Cargo should continue parsing the field in registry entries published by older nightly versions, but apply the new feature rule. Already-published nightly packages that relied on features being shared between the library and artifact may therefore need an update. Preserving the old behavior would require a permanent second sharing model in the resolver, which does not seem worthwhile for an unstable feature. This is another change to RFC 3028 and belongs in the same amendment.Alternative: opt-in feature isolation
@epage suggested preserving today's sharing rule and using a future “opaque dependency” feature for isolation. In behavioral terms, users would add a separate option when they want one declaration's features not to combine with another use of the package.
A general option may be useful, but artifact dependencies already name separately built products and select their targets and outputs. Requiring another option would make sharing the default even though it is not visible from these declarations. I prefer private features as the artifact default; a general mechanism could later reuse the implementation.
Implementation
I tested feasibility in an unpublished local prototype based on an earlier revision of #17067. It carries a declaration's identity through feature resolution and compilation without changing version resolution or
Cargo.lock. Its tests cover isolation from ordinary dependencies, isolation between aliases, transitive isolation, and feature sharing between outputs from one declaration. Nested artifact declarations still need explicit coverage.The expected cost is visible in the tests: a transitive package used by two artifact declarations becomes two compilation units when each reaches it with different features.
Metadata
Metadata consumers need to match an artifact declaration to its resolved package, outputs, dependency kind, platform condition, and compilation target. Requested features already appear on
packages[].dependencies[]. Command-specific activated features belong in--unit-graph, becausecargo metadatacannot accurately represent feature sets that vary by command and dependency kind.The discussion in #17067 exposed a naming problem in the current representation. A dependency declaration has a name, a Rust library dependency has an extern-crate name, and an artifact has a target name. For a binary artifact there is no extern crate, so
extern_namedoes not describe what the field contains.Today Cargo groups dependencies by resolved package and writes one
dep_kindsentry per output:{ "name": "schema_runtime", "pkg": "registry+https://github.com/rust-lang/crates.io-index#schema@1.2.3", "dep_kinds": [ { "kind": null, "target": null }, { "kind": "build", "target": null, "artifact": "bin", "extern_name": "schema_codegen", "bin_name": "schema-codegen" } ] }Here
targetis a platform condition such ascfg(windows), whilecompile_targetis the Rust target used to build the artifact.extern_nameis confusing for a binary that is never passed to Rust as an extern crate.I propose keeping ordinary Rust dependencies in
dep_kindsand grouping each artifact declaration once:{ "name": "schema_runtime", "pkg": "registry+https://github.com/rust-lang/crates.io-index#schema@1.2.3", "dep_kinds": [{ "kind": null, "target": null }], "artifacts": [{ "dependency_name": "schema-codegen", "kind": "build", "platform": null, "compile_target": null, "outputs": [{ "kind": "bin", "name": "schema-codegen" }] }] }Two aliases produce two
artifactsentries. The outernameretains its metadata-v1 meaning: the extern-crate name used for an ordinary Rust library dependency. It is empty when the resolved package has no library edge.artifacts[].dependency_nameis the dependency key from the manifest, before Rust extern-crate normalization.artifacts[].outputs[].nameis the selected target's name.platformreplaces the oldtargetname for the declaration's platform condition;compile_targetstill names the Rust target used to build the artifact.This supersedes #17350. These fields have only appeared under
-Z bindeps, so revising them does not change stable metadata, but nightly consumers will need to migrate. In particular, an artifact-only dependency will have an emptydep_kindsarray; consumers must readartifactsto retain that edge. Cargo should emit both forms for one release cycle, document the old fields as deprecated, notifycargo_metadatamaintainers and known build-system consumers, and then remove the old fields.Decisions requested
lib = truebe retired for new manifests, with older registry entries parsed but resolved under the new feature rule?dep_kindsentry per output?