Skip to content

← Modules, Visibility, Testing and Docs step 16 of 22

Hard Framework

Cargo features and the additivity rule

Features are the most misunderstood part of Cargo and the source of the ecosystem’s worst build failures. The resolution algorithm is a small, elegant graph problem; the design constraint that falls out of it is the thing you have to internalise before you design a crate.

pub fn resolve_features(
    features: Vec<(String, Vec<String>)>,
    optional_deps: Vec<String>,
    requested: Vec<String>,
    no_default: bool,
) -> Vec<String>

features is the [features] table as (name, entries) pairs. optional_deps is the list of dependencies declared optional = true. Return the sorted set of everything that ends up activated.

The four kinds of entry

[features]
default = ["json"]
json    = ["dep:serde", "serde?/derive"]
extra   = ["serde?/rc"]
fast    = ["dep:rayon", "rayon/threads"]
  • a plain name — activate that feature of this package.
  • dep:name — activate the optional dependency name. Notably it does not create a feature called name.
  • pkg/feat — activate pkg‘s feature feat, and if pkg is optional, activate pkg too.
  • pkg?/feat — the weak form. Activate pkg‘s feature feat only if pkg ends up activated by some other route. Never pulls the dependency in by itself.

Represent activation results as: the feature name for your own features, dep:name for an activated optional dependency, and pkg/feat for an activated dependency feature.

Why the weak form forces a fixpoint

Look at extra = ["serde?/rc"] and json = ["dep:serde", …].

Request ["extra"] alone and nothing happens — serde is not in, so the weak entry does nothing. Request ["extra", "json"] and serde/rc must end up activated. But if your algorithm walks the activated set once, in sorted order, it looks at extra before json, decides serde is not activated, and drops serde?/rc on the floor.

One pass is not enough. Loop until the activated set stops growing. That is the core of this problem and it is the shape of the real algorithm.

The implicit feature

Declaring a dependency optional = true implicitly creates a feature of the same name that turns it on — unless you mention it as dep:name somewhere in the table, which suppresses the implicit feature.

That default is a trap for library authors: the moment you publish with optional = true and never write dep:, your users can write features = ["serde"], and you can never remove that feature without a major version bump. Your choice of dependency has become public API. Writing dep:serde instead keeps the dependency an implementation detail behind a feature you named yourself.

The additivity rule

Here is the constraint everything else follows from.

When several packages in a build depend on the same crate, Cargo enables the UNION of all the features they asked for. There is no way to say “and not that one”. So a feature must only ever add behaviour.

The canonical wrong design is a no_std feature. It removes functionality, so if any package anywhere in the graph enables it, everybody gets it, and a crate three levels away that needed std fails to compile. The right shape is an additive std feature that is on by default and can be switched off with default-features = false.

Unification happens across the whole workspace, which is why cargo test can end up with more features enabled than cargo build — dev-dependencies join the union. That is the classic “works locally, fails in CI” (or the reverse), and the reason is never the machine.

Contract

  • default is activated unless no_default, and only if the table has it.
  • A requested name that is not a feature is ignored.
  • Entries naming a feature that does not exist are ignored.
  • A strong pkg/feat activates dep:pkg only when pkg is an optional dependency; a non-optional dependency is already there.