Skip to content
Anlis
Go back

Handling exactly-one feature validation in Rust

TL;DR

While developing my event-sourcing framework, I ran into a problem: Cargo has no way to declare mutually exclusive features. In this post I walk through the options for solving it. If you just want the working code, jump straight to the conclusion.

Intro

I’m developing historia - an event-driven framework. The first release included a single broker (Kafka) and a single database for transactional outbox (Postgres). In version 0.1.3 I added a new broker - NATS. As long as transactional outbox is provided as both binary (to simplify deployment) and library (for cases when the user wants to adjust behavior) I had to restrict a binary to run with exactly one broker and exactly one database.

How can we check it

The story is that we have multiple feature groups: brokers and databases. We need exactly one feature from each group to be enabled. So, does Rust provide us any mechanism to check it? Well, yes and no. Using built-in cfg attribute macros we can only check if any or all conditions are true.

For example, we can easily check that at least one feature is enabled:

#[cfg(not(any(feature = "kafka", feature = "nats")))]
compile_error!("At least one feature from the list should be enabled: kafka, nats");

Or we can check that not all features are enabled:

#[cfg(all(feature = "kafka", feature = "nats"))]
compile_error!("All features from the list should not be enabled: kafka, nats");

This combination of two checks might work fine if we have only 2 features, otherwise we’d have to write not-all check for each potential combination like:

#[cfg(all(feature = "kafka", feature = "nats"))]
compile_error!("All features from the list should not be enabled: kafka, nats");

#[cfg(all(feature = "kafka", feature = "rabbitmq"))]
compile_error!("All features from the list should not be enabled: kafka, rabbitmq");

#[cfg(all(feature = "rabbitmq", feature = "nats"))]
compile_error!("All features from the list should not be enabled: rabbitmq, nats");

But there is no attribute that checks that exactly one of the provided conditions is true.

On the other hand, there’s a trick: it’s also possible to make some checks at compile time. We can define a const which is calculated at compile time. In this const we can define a block which will be executed in compile time. In that block we can make our check:

// Define anonymous const just to trigger inner block execution at compile time
const _: () = {
    // `cfg` macro returns a boolean value which can be also represented as a number - `0` or `1`
    let count: u32 = 0 + cfg!(feature = "kafka") as u32 + cfg!(feature = "nats") as u32;
    // and then we can just check that exactly one feature is enabled
    assert!(count == 1, "Exactly one feature from the list should be enabled: kafka, nats");
};

In that way we can check that exactly one feature is enabled. But if we have multiple feature lists it might be a bit verbose to write those consts, moreover they are a bit hard to read. So we also can wrap it to the macro which will write it down for us:

#[macro_export]
macro_rules! assert_exactly_one_feature {
    // This macro receives a message which will be shown in case of error and list of features to check
    ($message:literal, $($feat:literal),+ $(,)?) => {
        const _: () = {
            let count: u32 = 0 $(+ cfg!(feature = $feat) as u32)+;
            assert!(count == 1, $message);
        };
    };
}

And then it can be called in code like this:

assert_exactly_one_feature!(
    "Exactly one broker feature must be enabled: kafka, nats",
    "kafka",
    "nats",
);

assert_exactly_one_feature!(
    "Exactly one database feature must be enabled: sqlx-postgres",
    "sqlx-postgres",
);

So everything works… or not?

Well, historia project contains multiple crates each of which has a bunch of features and I used to run all tests, checks and lints with --all-features flag. But since we added exactly-one feature validation, --all-features flag will break a compilation in the code parts where it’s used.

Ok, then how can we avoid it? At the start I mentioned that only binary has such restriction. So maybe just run checks for everything except binaries? It sounds good enough, we can run check for the binary separately, it should stay slim and contain just a main function anyway. And yeah, we really can run something like:

cargo check --all-features --lib

and it will work just fine. Binary can be then checked separately with one or multiple feature sets to check it works fine:

cargo check --features kafka,sqlx-postgres --bin historia_transactional_outbox
cargo check --features nats,sqlx-postgres --bin historia_transactional_outbox

and it works fine… but there’s a nuance. Running cargo commands with --lib flag will also exclude any integration tests (under tests directory) from compilation. And if we gather a coverage report which should include integration tests, those tests won’t be compiled, run, or included to the final report. Theoretically we can also enable --tests flag to include them too but in practice it will compile all binaries including the one which has exact-one feature check, so it fails.

The most obvious alternative is just run commands with enumeration of all features:

cargo llvm-cov nextest --features feature1,feature2,feature3 ...

and it works fine but it’s quite verbose. And things become even worse if we add new features - we’ll have to remember to add them to each command in CI and remember about them when running anything locally.

So we came to 2 questions:

  1. How can we avoid compilation of a single binary crate but not the integration tests?
  2. How can we avoid enumerating all features in cargo commands?

First issue can be solved by (surprisingly) adding another feature just to require it for binary compilation. In Cargo.toml we can define:

[features]
# Special feature just to control binary compilation, not used anywhere else
cli = []

[[bin]]
name = "historia_transactional_outbox"
# This binary will be compiled only if `cli` feature is enabled
required-features = ["cli"]

Ok, we have another feature and we can control binary compilation. The second issue: avoid enumerating all features in each cargo command. And again, we can solve this issue with one more feature. The idea is to add a single feature which will enable all the others but cli:

[features]
feature1 = []
feature2 = []
feature3 = []

cli = []

full = [
  "feature1",
  "feature2",
  "feature3",
]

default = ["cli"]

so then we can run:

cargo llvm-cov nextest --features full ...

and have a coverage report which will include integration tests with all features enabled but avoid issues with the binary which has an exact-one feature requirement. Also note that we defined default feature and included cli there. This is done to simplify cargo install command so we can avoid specifying that feature each time.

The one thing left is to also check that binary compiles too. Ideally, with all possible feature combinations. Fortunately, there’s no need to invent something, cargo hack solves exactly that problem. Moreover, it has both at least one and mutually exclusive features filters built-in. After installing it we can run the following command:

# --feature-powerset: make cargo hack run for all available features combinations
# --no-dev-deps:      exclude dev-deps on checks. Note that it modifies Cargo.toml, so don't interrupt the process
# --at-least-one-of, --mutually-exclusive-features: this combination makes exactly one feature enabled
# --skip:             feature combination which includes any of these features won't be run. We skip both
#                     full to avoid compilation error on exactly-one check
#                     and default to avoid cargo hack running with just default features like `cargo check`
# --exclude-no-default-features: skip the plain `cargo check --no-default-features` run
# --at-least-one-of:  in order to make a binary compile we need to include `cli` feature in each check
#                     cargo hack does not allow --at-least-one flag to contain single feature but there's a trick:
#                     we can just duplicate feature namecargo hack check \
  --feature-powerset \
  --no-dev-deps \
  --bin historia_transactional_outbox \
  --at-least-one-of nats,kafka \
  --mutually-exclusive-features nats,kafka \
  --skip full,default \
  --exclude-no-default-features \
  --at-least-one-of cli,cli

Conclusion

Adding a feature restriction like mutual exclusivity also makes using --all-features flag impossible. It’s always better to keep these restrictions only in some limited space so it doesn’t affect the whole codebase. In order to check that exactly one feature is enabled, we can define a macro:

#[macro_export]
macro_rules! assert_exactly_one_feature {
    // This macro receives a message which will be shown in case of error and list of features to check
    ($message:literal, $($feat:literal),+ $(,)?) => {
        const _: () = {
            let count: u32 = 0 $(+ cfg!(feature = $feat) as u32)+;
            assert!(count == 1, $message);
        };
    };
}

and then use it like this:

assert_exactly_one_feature!(
    "Exactly one broker feature must be enabled: kafka, nats",
    "kafka",
    "nats",
);

Also, when we need to run checks, tests coverage gathering or any other cargo commands with all features enabled, we can restrict compilation of a specific code part by hiding it behind a feature (cli in the example) and define another feature (full in the example) which will enable all other features. Example of Cargo.toml:

[features]
feature1 = []
feature2 = []
feature3 = []

# Special feature just to control binary compilation, not used anywhere else
cli = []

# Enables all features except `cli`
full = [
  "feature1",
  "feature2",
  "feature3",
]

# Make `cli` feature enabled by default. It should not affect library compilation
# but allows running `cargo install` without specifying the `cli` feature
default = ["cli"]

[[bin]]
name = "binary_name"
# This binary will be compiled only if `cli` feature is enabled
required-features = ["cli"]

so then we can run something like:

cargo check --features full

and check the code with all features enabled excluding the part which requires mutually exclusive features.

In order to check only binary with all available feature combinations (with exactly-one restrictions) we can use cargo hack:

# --feature-powerset: make cargo hack run for all available feature combinations
# --no-dev-deps:      exclude dev-deps on checks. Note that it modifies Cargo.toml, so don't interrupt it mid-run
# --at-least-one-of, --mutually-exclusive-features: this combination makes exactly one feature enabled
# --skip:             feature combination which includes any of these features won't be run. We skip both
#                     full to avoid compilation error on exactly-one check
#                     and default to avoid cargo hack running with just default features like `cargo check`
# --at-least-one-of:  in order to make a binary compile we need to include `cli` feature in each check
#                     cargo hack does not allow --at-least-one flag to contain single feature but there's a trick:
#                     we can just duplicate feature name 
cargo hack check \
  --feature-powerset \
  --no-dev-deps \
  --bin historia_transactional_outbox \
  --at-least-one-of nats,kafka \
  --mutually-exclusive-features nats,kafka \
  --skip full,default \
  --exclude-no-default-features \
  --at-least-one-of cli,cli

Share this post:

Previous Post
Historia framework announcement