- Feature Name:
cargo-mutex-features
- Start Date: 2020-10-06
- RFC PR: rust-lang/rfcs#0000
- Rust Issue: rust-lang/cargo#2980
NOTE: This is work in progress!
Based on the idea from: rust-lang/cargo#2980 (comment)
This RFC proposes a way to implement "mutually exclusive" features in Cargo, by introducing the concept of "capabilities" of a feature.
Cargo currently supports features, which allow one to use conditional compilation. A common use case is to provide some kind of capability, backed by different implementations, based on the feature selected by the user.
In group of cases, these implementations are mutually exclusive. While custom build.rs
scripts can be used to
implement this behavior partly, it is not a great experience. Neither from the implementor's side, nor from the
user's side.
One example is the crate k8s-openapi
. The crate compiles for different
Kubernetes versions. However, at most one API version may be active.
To achieve this, the Cargo.toml currently contains
features (v1_11
to v1_19
), and has a build.rs,
which uses custom code to validate this.
Another example is the crate stm32f7xx-hal
(and its sibling crates),
which require exactly one hardware configuration to be enabled. Again, the Cargo.toml
contains a list of features,
one for each hardware configuration. It also introduces a meta feature named device-selected
, which gets set to
simplify checks in the code if a platform was selected.
In addition to an identifier, features can specify one or more capabilities that they provide or require, when the feature is enabled.
The following example shows the basic idea:
[features]
json = []
yaml = []
json_foo = [ "foo_json" ]
json_bar = [ "bar_json" ]
[features.json]
requires = ["json_backend"]
[features.json_foo]
provides = ["json_backend"]
[features.json_bar]
provides = ["json_backend"]
In the above example there are two standard features json
and yaml
, which can be activated as needed. The json
feature additionally declares, that it requires the capability json_backend
. json_backend
is a simple identifier,
which is local to the crate. The two additional features json_foo
and json_bar
both provide the functionality of
a JSON backend. Due to some limitation, they are mutually exclusive, and the user has to choose one.
If the user requests the feature json
, but does not select a feature, providing the capability json_backend
, then
cargo will fail the build:
$ cargo build --features json
error: Package `example v0.0.0 (/path)` requires the capability `json_backed`, but no feature which could provide
the capability is enabled. Possible candiates are:
- json_foo
- json_bar
At the same time, if a user select more than one feature that provides json_backend
, cargo will fail the build as well:
$ cargo build --features json,json_foo,json_bar
error: Package `example v0.0.0 (/path)` has multiple features enabled that provide the mutually exclusive capability
`json_backend`. Enabled features providing this capability are:
- json_foo
- json_bar
In the area of embedded development, it may be interested to let the user provide buffer sizes during compile time to tweak the memory footprint of an application. The following example shows how this can be achieved using features and capabilities:
[package]
requires = ["buffer-size"]
[features]
default = ["1k"]
1k=[]
2k=[]
4k=[]
[features.1k]
provides = ["buffer-size"]
[features.2k]
provides = ["buffer-size"]
[features.4k]
provides = ["buffer-size"]
Using the requires
field, the build requests the user to select a feature providing the capability buffer-size
, and
at the same time, defaults to the feature 1k
, which will provide an implementation of a 1k buffer.
The user has the ability to choose a different buffer size, using the different features. At the same time, cargo will
ensure that exactly one provider of buffer_size
is enabled.
The use of capabilities would need to be added to the list of cargo features.
The model of the cargo file needs to extended, to allow for additional feature information. Specifically:
Add a field requires: Vec<String>
to Package
, which lists the capabilities required by this crate.
Currently features
in a Cargo file is expected to be a map of String -> String
, which is translated into
a FeatureMap
, which is defined as BTreeMap<InternedString, Vec<FeatureValue>>
.
The type FeatureValue
is currently defined as:
pub enum FeatureValue {
Feature(InternedString),
Crate(InternedString),
CrateFeature(InternedString, InternedString),
}
Instead of having Vec<FeatureValue>
as the map's value, we would add a FeatureInformation
(or other name) struct:
pub struct FeatureInformation {
capabilities: Vec<InternedString>,
enables: Vec<FeatureValue>,
}
Which would be represented in the Cargo.toml
as:
[dependencies]
bar = "*"
[features]
#foo = [ "bar", "bar/feature", "baz"] # should still work
foo = { capabilities = ["cap1"], enables = ["bar", "bar/feature", "baz"] }
# alternatively
[features.foo]
capabilities = ["cap1"]
enables = ["bar", "bar/feature", "baz"]
Of course the existing format (commented out in the above example) should still work. However, it doesn't support the use of capabilities.
Aside from moving the string array to the enables
field, I would not change its format. This should help
people migrate to the new format, as you only need to shift the values to the new field.
When validating, cargo builds up a list of capabilites: Map<String, Vec<&FeatureInformation>>
, listing the
found capabilities, and their providing features. Now it validates:
- that the number of features providing a capability less then 2
- that for each
required
capability (for package and feature) the number of features is exactly 1
It would be possible that no selected feature provides a certain capability. Thus it makes sense to allow conditional
compilation also using capabilities (in addition to #[cfg(feature="foo")]
).
Conditional compilation can use the attribute capability
, which is handled the same way feature
is, just
with capabilities:
#[cfg(capability="json_provider")]
fn get_provider() -> Option<Provider> {
Some(json_provider::new())
}
#[cfg(not(capability="json_provider"))]
fn get_provider() -> Option<Provider> {
None
}
The capability identifier should be limited to: ASCII letters, digits, _
, and -
. A future extension (see below)
could be a "global capability", which could be scoped by adding a prefix like global:
.
An argument to not do this, could be that using build.rs
, you can already achieve similar results. Without the need
to introduce additional concepts. And while this is partially true, it is not a very user-friendly experience.
Additionally, if different people create different ways of processing situations like this in Rust code, contained
in build.rs
, it may be complicated over time as different concepts and ways of handling similar situations evolve.
Also, is it problematic (or even impossible) to crate proper tooling on logic contained in build.rs
files.
NOTE: to be written …
- Why is this design the best in the space of possible designs?
- What other designs have been considered and what is the rationale for not choosing them?
- What is the impact of not doing this?
This design solves the original use case, of providing mutually exclusive features. It does this by using a clearly defined piece of information, the "capability". It also re-uses the existing feature definition and format (in Cargo.toml) as far as possible. So it isn't completely new, just an extension.
Additionally, it leverages the capability concept to allow conditional compilation and "requiring" of a capability, which plays along with the original use case.
Leveraging the concept at a global level is left as a future task (see below).
As the feature definition (FeatureInformation
) could easily be amended now with additional information
(like "conflicts", "deprecated", …) it feels as a future proof, yet simple extension to the current information model.
However, the current proposal stops at this point and leaves out implementing additional capability requirements (also see Prior art).
An alternate definition of capabilities for features in the cargo file could be to amend the FeatureValue
enum:
pub enum FeatureValue {
Feature(InternedString),
Crate(InternedString),
CrateFeature(InternedString, InternedString),
Capability(InternedString),
}
In Cargo.toml
this could be represented as:
[dependencies]
bar = "*"
[features]
foo = ["provides:bar", "bar", "bar/feature", "baz"]
^ ^ ^ ^
| | | |--- Feature
| | |
| | |--- CrateFeature
| |
| |--- Crate
|
|--- Capability
However, this would require to change the way FeatureValue
is parsed from a string. This would mean putting more
logic into the specialized format. I think that the value/format of the features is already overloaded. So it makes
sense to provide some actual fields, like the dependencies
have as well.
Packages managers like RPM and others, already have the concept of capabilities. It is possible to define dependencies on specific other packages, or on "capabilities", which may be provided by different packages.
For example, an RPM of sendmail
may declare that it provides the capability of MTA
(mail transfer agent).
http://rpmfind.net/linux/RPM/fedora/32/x86_64/s/sendmail-8.15.2-43.fc32.x86_64.html. At the same time,
other packages can provide the same capability: http://rpmfind.net/linux/rpm2html/search.php?query=MTA
A crate can be seen a "software package" and Cargo as a package manager.
A key difference to packages managers like RPM is, that RPM doesn't consider a capability "mutually exclusive". This can however be achieved by the use of additional rules, like "Conflicts". Also do such package managers support weak dependencies, like "Suggest" or "Recommends". However, adding such concepts makes things more complex, harder to implement and harder to understand for the user.
The Java programming language itself did not have module concept before Java 9. However, OSGi did provide a "bundle" concept on top of Java before that. Dependencies between bundles can be declared explicitly by referencing another bundle, or using generic capabilities. See: https://blog.osgi.org/2015/12/using-requirements-and-capabilities.html
"Bundles" in OSGi would map to "crates" in Rust.
While the capabilities concepts have been re-used in OSGi package managers (like P2 or BND), OSGi itself enforces the capabilities during runtime. Which Rust/Cargo would not do.
For OSGi it is also possible to use much more complex definitions, providing actual values in the capabilities, and enforcing requirements using LDAP queries. While this allows one to crate rather complex constructs, it also makes things harder to understand and maintain. Both from an implementation as well from a user perspective.
If such complex scenerios are required, this proposal still allows for the use of custom code in the build.rs
script
to implement more complex requirements.
In the future there should definitely a second look at the use of "global capabilities" (see below).
The focus of this RFC is on crate-local capabilities. However, it would be reasonable to expand this also on a global level.
A common example in the embedded space is the "panic handler" or "allocator", which must be selected, but must also be unique on the whole application.
Currently, people simply comment out alternatives (https://github.com/rust-embedded/cortex-m-quickstart/blob/master/Cargo.toml).
For example, if it would be possible to declare global capabilities as well, different crates could offer different panic handler implementations. Cargo could then ensure that exactly one is part of the build tree.
Consider the application having the following Cargo.toml
requires = ["global:panic-handler"]
[dependencies]
panic-rtt-target = { version = "0.1.1", features = ["cortex-m"] }
panic-halt = "0.2.0"
panic-itm = "0.4.1"
[features]
panic-by-rtt = ["panic-rtt-target"]
panic-by-halt = ["panic-halt"]
panic-by-itm = ["panic-itm"]
panic-by-mock = []
[features.panic-by-mock]
provides = ["global:panic-handler"]
Additionally, assume the dependencies panic-rtt-target
, panic-halt
and panic-itm
each have a Cargo.toml
containing:
provides = ["global:panic-handler"]
A provides
declaration on the global section defines that a crate will always provide this capability.
With this configuration, Cargo will now ensure that exactly one implementation of a panic-handler
is enabled in the
build. It can also provide guidance on features to enable or disable.