RFA-102 · Case file with fixtures · Case 74 of 694 · Cargo workspace evidence
When Cargo Feature Unification Enables Mutually Exclusive Features
Cargo features are additive across matching dependency instances. If two dependents request opposite modes, both modes can reach one crate; model capabilities as composable features or split the incompatible boundary.
- Reviewed
- Rust
- Rust 1.98.1, Cargo 1.98.1
- Targets
- all targets; graph may vary by target
- Profiles
- check, dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Two dependency paths request different features for the same package instance, and Cargo combines those requests into one additive feature set.
- First discriminating check
- Inspect the inverse feature tree for the package and identify every edge, including defaults, that activates each feature.
I have seen feature declarations read like a selection menu: JSON or binary, TLS backend A or TLS backend B, client or server. Cargo does not generally treat features as a selection menu. Features enabled for the same package instance are combined.
The failing workspace makes this visible. A reader crate enables json. A writer crate enables binary. The application depends on both. Neither edge asks for both, but the codec is compiled once with both features, and its compile_error! guard fires.
Features are additive requests
The Cargo features reference gives the core rule: features should be additive. Enabling a feature should add functionality without removing or changing functionality in a way that breaks another user.
Suppose the graph contains:
app -> reader -> codec + json
app -> writer -> codec + binary
For one resolved codec package instance, Cargo builds the union {json, binary}. This prevents compiling many needless copies for every feature combination and lets different branches request capabilities independently.
The feature resolver has boundaries. Host dependencies, target dependencies, and some workspace situations can be separated under newer resolver versions. The resolver feature documentation describes these rules. But choosing resolver 2 or 3 is not a general way to make two ordinary normal-dependency features exclusive.
The guard detects a modeling problem
A common crate contains:
#[cfg(all(feature = "backend-a", feature = "backend-b"))]
compile_error!("choose exactly one backend");
The guard can provide a clear failure, but it cannot make the ecosystem coordinate. A downstream application may control its direct dependencies and still receive the second feature through another path. If a library exposes mutually exclusive global modes as ordinary features, composition becomes fragile.
I ask whether the choices are truly incompatible. Parsers for two formats are normally capabilities: both can exist. Two implementations exporting the same unmangled native symbols may be truly incompatible. The design should match that difference.
Prefer capabilities that compose
The repaired workspace renames the choices around capabilities: read-json and write-binary. Both can be enabled and the codec remains valid.
Other repairs I use are:
- Put runtime selection behind an enum or builder when one process may use several modes.
- Split backends into separate crates, leaving a small interface crate without a global choice.
- Let the final binary select an implementation while libraries depend only on traits.
- If native linking permits only one backend, keep that restriction at the application boundary and document it as a whole-graph constraint.
I avoid defining feature A as “not B.” Negative relationships are hard to compose because Cargo communicates positive requests. default-features = false only disables the default set on that dependency edge; another edge can still enable the feature.
Inspect edges, not only the final list
cargo tree -e features tells me why features are active. An inverted tree for the codec is more useful than staring at its manifest:
cargo tree -e features -i codec-package
I run it for the exact target and workspace selection used by the failure. Build dependencies and proc macros run for the host, while the main crate can compile for another target. CI may enable --all-features, which deliberately tests the combination every feature design should ideally tolerate.
When --all-features cannot work by design, I write an explicit feature matrix instead of pretending that one maximal build represents all valid modes. That matrix belongs in CI and release documentation.
Defaults are another graph input
Default features are enabled unless an edge disables them, and disabling them on one edge does not guarantee another edge does so. This is why “I wrote default-features = false” may not solve the final graph.
Removing a default feature is also a compatibility decision. Existing users can depend on that default behavior without naming it. I treat a feature-default change with the same seriousness as an API change.
My debugging sequence
When two supposedly exclusive features appear together, I do this:
- Reproduce with the exact package, target, and Cargo command.
- Run the inverse feature tree for the affected package.
- Identify every edge enabling each feature, including defaults.
- Check whether host/target resolution is relevant or whether this is one normal package instance.
- Decide if the features are capabilities, runtime choices, or genuinely conflicting implementations.
- Redesign the boundary and add the valid combination matrix to CI.
The important lesson is not a Cargo trick. A dependency graph is composed by many authors, so features are requests that accumulate. When I model them as additive capabilities, the graph remains predictable. When I model them as global switches, the next dependency can select both without doing anything unreasonable.