Skip to content
rusthacks.dev

Feature Flags That Break Builds and How to Test Them All

Feature flags multiply build configurations. See the combinations that fail, how cargo hack tests them, and rules for additive features that keep builds safe.

6 min read
Summarize in AI
Flat illustration for the post “Feature Flags That Break Builds and How to Test Them All”, composed around ownership.

A crate with three features has eight possible builds, and CI usually checks two of them: the defaults and --all-features. The other six break in ways nobody sees until a downstream user turns on exactly the wrong pair. This post builds a tiny crate with a feature-combination bug, shows the compiler error it produces, and then sets up the loop and the tool that find these before a release does.

Everything compiled on Cargo 1.99 with edition 2024. The crate has no dependencies, so you can paste it into an empty directory.

A crate where one feature quietly needs another

The library has three features. std enables a function that formats a byte count with format!. logging adds a function that prints a decode message, and fast turns on std. Nothing here looks wrong:

Cargo.tomlTOML
[package]
name = "codec"
version = "0.1.0"
edition = "2024"

[features]
default = []
std = []
logging = []
fast = ["std"]
src/lib.rsrust
#[cfg(feature = "std")]
pub fn describe(bytes: &[u8]) -> String {
    format!("{} bytes", bytes.len())
}

#[cfg(feature = "logging")]
pub fn log_decode(bytes: &[u8]) {
    println!("decoding: {}", describe(bytes));
}

The bug is that log_decode calls describe, which only exists when std is on, but the logging feature does not say so. The two checks most projects run both pass. Defaults compile because both functions are gated off, and --all-features compiles because std is among them and rescues logging. The defect hides in the only combination nobody tests: logging alone.

The failure, in its real words

Shell
cargo check --no-default-features --features logging
Text
error[E0425]: cannot find function `describe` in this scope
 --> src/lib.rs:8:30
  |
8 |     println!("decoding: {}", describe(bytes));
  |                              ^^^^^^^^ not found in this scope
  |
note: found an item that was configured out
 --> src/lib.rs:2:8
  |
1 | #[cfg(feature = "std")]
  |       --------------- the item is gated behind the `std` feature

The note under the error is the useful part. Rust tracks the item that was cfg'd out and tells you which feature gates it, so the diagnosis is a single read: logging uses something that needs std. Run the loop that checks every subset and the pattern is clear:

Shell
for f in "" std logging fast "std,logging" "fast,logging"; do
  echo "--- features: [$f]"
  cargo check -q --no-default-features --features "$f"
done
Text
--- features: []
--- features: [std]
--- features: [logging]
error[E0425]: cannot find function `describe` in this scope
--- features: [fast]
--- features: [std,logging]
--- features: [fast,logging]

Six combinations, one failure. A quiet loop with -q prints nothing for the builds that pass, so a failure stands out. The loop leaves out fast,std and fast,std,logging, but they reach the same code paths as combinations already run, which is the kind of redundancy a dedicated tool removes.

Fix the dependency, not the test

The correct repair states the requirement where Cargo can enforce it. Make logging imply std:

Cargo.tomlTOML
[features]
default = []
std = []
logging = ["std"]
fast = ["std"]

Now --features logging also enables std and the code compiles. The alternative, wrapping log_decode in #[cfg(all(feature = "logging", feature = "std"))], silently removes the function for users who asked for logging alone, which is worse: no error, a missing function. Prefer the implication, because it turns a hidden requirement into a visible one in the manifest.

Automating it with cargo hack

Hand-rolled loops stop scaling at five features, because a powerset of n features is 2n builds. cargo-hack is the standard answer. Install it once and it replaces the loop:

Shell
cargo install cargo-hack --locked

cargo hack check --each-feature --no-dev-deps
cargo hack check --feature-powerset --no-dev-deps
cargo hack check --feature-powerset --depth 2 --no-dev-deps

--each-feature checks every feature alone plus the empty set, which is n + 1 builds and catches this exact bug. --feature-powerset tries every combination. --depth 2 caps the subset size at two features, a practical compromise: most interaction bugs involve a pair, and the count drops from exponential to quadratic. For a crate with ten features that is 56 builds instead of 1,024. --no-dev-deps stops test-only dependencies from hiding a missing feature declaration, and check rather than test keeps the runs fast, since the goal here is compiling, not behaviour.

When two features are mutually exclusive by design, tell the tool so it does not report a failure you already know about: --mutually-exclusive-features backend-a,backend-b. Exclusive features are a smell under feature unification, because one dependent can enable one while another enables the other, but some crates have to carry them.

Where it belongs in CI

Put the cheap form on every pull request and the exhaustive form on a schedule. A reasonable split is --each-feature on push, since it is linear, and --feature-powerset --depth 3 nightly. Compile checks do not catch behaviour changes between feature sets, so run cargo hack test --each-feature for crates whose features alter results rather than merely add items. For a no_std crate, also run one job against a target that has no standard library, such as thumbv7em-none-eabihf, since a stray std use passes on the host and fails there.

A CI job that runs the matrix

The two cheap forms fit in a few lines of workflow. The first job checks each feature alone on every push; the second runs the exhaustive form once a night, where a ten minute run costs nothing:

.github/workflows/features.ymlYAML
name: features
on:
  push:
  schedule:
    - cron: "0 3 * * *"

jobs:
  each-feature:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: taiki-e/install-action@cargo-hack
      - run: cargo hack check --each-feature --no-dev-deps

  powerset:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: taiki-e/install-action@cargo-hack
      - run: cargo hack check --feature-powerset --depth 3 --no-dev-deps

The install action downloads a prebuilt cargo-hack, which takes seconds, where cargo install would compile it from source on every run. See install versus binstall for why prebuilt binaries are the right default in CI. Cache target/ between runs, since each combination is a separate set of artifacts and the matrix is otherwise mostly recompiling dependencies.

Reading the failures in the right order

When the matrix goes red, read the failing combination first, then the error. A failure under --features logging that mentions an item gated by another feature is a missing implication in the manifest, as above. A failure that appears only when two specific features are on usually means both define the same name, or one gated block calls into the other's private helper. And a failure under no features at all means a gated import is not gated: a use at the top of the file that only one feature needs. Each class has a different fix, so naming it before you edit saves a round trip.

Keep one more habit alongside the matrix: when you add a feature, add its implications in the same commit. The bug in this post was introduced by the commit that added logging, and the commit message said nothing about std. A reviewer who sees a new #[cfg(feature = "...")] gate should ask which other gated items the new code calls, and the answer belongs in the manifest.

When this much checking is wasted

A crate with one or two features and no interactions does not need a matrix; the defaults and all-features runs cover it. An application, as opposed to a library, picks one feature set and ships it, so the only build that matters is the one it produces. The matrix earns its cost for libraries with five or more features, libraries that other crates depend on with different selections, and anything that supports no_std. For the feature design this protects, see optional dependencies, and for the rules Cargo applies when combining requests, the features reference.

Also inTesting
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.