February 25, 2022 by Olivier Goffart

Rust: Adding default cargo features without breaking Semantic Versioning

We added a compat mandatory feature (enabled by default) to help with keeping our crates backwards compatible.


TL;DR

[features]
default = ["compat", "foobar"]
compat = []

In the next release we can add more default features like this:

[features]
default = ["compat", "foobar"]
compat = ["compat2", "new-feature"]
compat2 = []

Read on for more details

What's the problem?

It is often desirable to put existing functionality of a crate behind a feature flag.

Cargo features must be additive. So while you can do enable-foo to enable extra functionality, it is not possible do have a disable-foo feature to turn something off.

You can always add features to the default feature set, but what happens to users that have set default-features = false in their Cargo.toml? They will not get that new feature, and this can cause trouble down the road!

Update: To clarify, this is about moving existing functionality behind a new feature flag, that must be enabled by default so crates using a previous (minor) version and rely on this functionality to be present don't break when the new version is automatically updated

Examples of cases where it is desirable to add default features:

  • When you add a "std" feature to support #![no_std] environments
  • When you exposed types from another crate, and that crate does breaking changes and upgrades its version. You might want to use the new version of that crate in a feature, and also make the old version optional with a new enabled-by-default feature.
  • When deciding to switch implementation "backends" for some feature.

Why bother?

Don't bother, just increase the major version! This seems to be a popular interpretation of semver. However this reduces the major version number to a compatibility-flag and decouples it from the actual progress on the crate.

Regardless, there are a few downsides to increasing the major version number of a crate: It requires all the dependencies of your project to be updated to this new major version at the same time. Otherwise your project might end up with different versions of the same crate in its dependency tree. This is bloat an might even break your build — if symbols are exported or there is C code in the mix. Incrementing the major version of your crate causes a maintenance burden on every one of your users!

Often the information about the actual changes in the crate are lacking. Some projects do not have a ChangeLog anywhere -- and those that do suffer from a lack of standardization as there is no common convention for it in the rust ecosystem. So you often have no easy way to find out what you need to watch out for! This often ends in just hoping that everything works fine after all compilation errors are fixed.

There are some other hacks like the semver-trick that would allow to re-export the new version of your crate from the previous version of your crate (with different features selected, but that would still be an annoyance)

Our solution

The solution we have chosen for our crates is to add a mandatory feature, enabled by default: (compat-0-2-0). Our lib.rs enforce this:

#[cfg(not(feature = "compat-0-2-0"))]
compile_error!(
    "The feature `compat-0-2-0` must be enabled to ensure \
    forward compatibility with future version of this crate"
);

Users that want to disable our default features can do so, but they must enable "compat-0.2.0" again to get the set of required features:

slint = { version = "0.2", default-features = false, features = ["compat-0-2-0", ...] }

Later if we want to introduce a new default feature, we will not only add it to the default, but we will add it to the compat feature as well.

Currently, Slint supports SVG unconditionally. Say we want to make SVG support optional in Slint 0.2.3, our Cargo.toml will have

[features]
default = ["compat-0-2-0", "std", "svg"]
compat-0-2-0 = ["compat-0-2-3", "svg"]
## Mandatory feature to stay forward compatible with future version
compat-0-2-3 = []

(We still add svg to the default feature so that it can be shown as such )

We also change the compile_error in lib.rs so that compat-0-2-3 becomes the mandatory feature and no longer compat-0-2-0

That's it: We can add new features that will be enabled for all downstream crates that used an older version before.

And what if the current version doesn't have any feature enabled by default? Well, I still recommend preventing the use of the default-features = false like this:

[features]
default = []
#[cfg(not(feature = "default"))]
compile_error!(
    "The default feature must be enabled to ensure \
    forward compatibility with future version of this crate"
);

Conclusion

This is just a hack we now use in our crates to make sure we can add new features without breaking forward compatibility. Of course, the ideal would be for cargo to get negative features or other ways of doing features migration. But unfortunately this doesn't seem likely to happen soon.

By the way, if you're looking for a way to document features in the Cargo.toml, we also made a crate for that.

Comments


Slint is a declarative GUI toolkit to build native user interfaces for desktop and embedded applications written in Rust, C++, or JavaScript. Find more information at https://slint.dev/ or check out the source code at https://github.com/slint-ui/slint