Repository: taiki-e/cargo-hack (language: rust)

Issue:
express mutually exclusive features
I don't really understand what `--group-features` is doing just from the readme or help output so it's possible I'm using it wrong and that this is possible already.

I want to provide an optional TLS backend `openssl` or `rustls` but they emit conflicting code so using `--feature-powerset` is not working out yet.

TIA


Discussion / hints:
I don't think there is currently a way to do this with a single command. (`--group-features` treats the specified features as if it were a single feature.)

If you are okay with splitting it into multiple commands, I think you can express this by combining `--skip`(`--exclude-features`) and `--features`.

```sh
# All combinations that contain openssl and do not contain rustls.
cargo hack build --feature-powerset --features openssl --skip rustls
# All combinations that contain rustls and do not openssl.
cargo hack build --feature-powerset --features rustls --skip openssl
# All combinations that do not contain both openssl and rustls.
cargo hack build --feature-powerset --skip openssl,rustls
```
I'm okay with adding a flag for such use-case, but given that cargo itself does not recommend the use of mutually exclusive features, we will probably need documentation to warn about it.

https://doc.rust-lang.org/cargo/reference/features.html#mutually-exclusive-features

> There are rare cases where features may be mutually incompatible with one another. This should be avoided if at all possible, because it requires coordinating all uses of the package in the dependency graph to cooperate to avoid enabling them together. If it is not possible, consider adding a compile error to detect this scenario.

> Instead of using mutually exclusive features, consider some other options:
>
> - Split the functionality into separate packages.
> - When there is a conflict, choose one feature over another. The cfg-if package can help with writing more complex cfg expressions.
> - Architect the code to allow the features to be enabled concurrently, and use runtime options to control which is used. For example, use a config file, command-line argument, or environment variable to choose which behavior to enable.
https://github.com/taiki-e/cargo-hack/issues/132#issuecomment-947800980

>> If it is not possible, consider adding a compile error to detect this scenario.

Btw, #115 is a feature request to check that a proper compilation error occurs in such cases.
> cargo itself does not recommend the use of mutually exclusive features

Yeah... it's a tricky one.

> If you are okay with splitting it into multiple commands

This is a perfectly reasonable way to handle it with just one set of mutually exclusive features.

Fix patch:
diff --git a/README.md b/README.md
index 9b670cc..1278be3 100644
--- a/README.md
+++ b/README.md
@@ -127,6 +127,14 @@ OPTIONS:
 
             This flag can only be used together with --feature-powerset flag.
 
+        --mutually-exclusive-features <FEATURES>...
+            Space or comma separated list of features to not use together.
+
+            To specify multiple groups, use this option multiple times:
+            `--mutually-exclusive-features a,b --mutually-exclusive-features c,d`
+
+            This flag can only be used together with --feature-powerset flag.
+
         --at-least-one-of <FEATURES>...
             Space or comma separated list of features. Skips sets of features that don't enable any
             of the features listed.
diff --git a/src/cli.rs b/src/cli.rs
index 33c26c8..be7c779 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -82,6 +82,8 @@ pub(crate) struct Args {
     pub(crate) depth: Option<usize>,
     /// --group-features <FEATURES>...
     pub(crate) group_features: Vec<Feature>,
+    /// `--mutually-exclusive-features <FEATURES>`
+    pub(crate) mutually_exclusive_features: Vec<Feature>,
     /// --at-least-one-of <FEATURES>...
     /// Implies --exclude-no-default-features. Can be specified multiple times.
     pub(crate) at_least_one_of: Vec<Feature>,
@@ -169,6 +171,7 @@ impl Args {
         let mut exclude_all_features = false;
 
         let mut group_features: Vec<String> = vec![];
+        let mut mutually_exclusive_features: Vec<String> = vec![];
         let mut depth = None;
 
         let mut verbose = 0;
@@ -251,6 +254,9 @@ impl Args {
                 Short('p') | Long("package") => package.push(parser.value()?.parse()?),
                 Long("exclude") => exclude.push(parser.value()?.parse()?),
                 Long("group-features") => group_features.push(parser.value()?.parse()?),
+                Long("mutually-exclusive-features") => {
+                    mutually_exclusive_features.push(parser.value()?.parse()?);
+                }
 
                 Short('F') | Long("features") => parse_multi_opt!(features),
                 Long("skip" | "exclude-features") => parse_multi_opt!(exclude_features),
@@ -422,6 +428,8 @@ impl Args {
                 requires("--depth", &["--feature-powerset"])?;
             } else if !group_features.is_empty() {
                 requires("--group-features", &["--feature-powerset"])?;
+            } else if !mutually_exclusive_features.is_empty() {
+                requires("--mutually-exclusive-features", &["--feature-powerset"])?;
             } else if !at_least_one_of.is_empty() {
                 requires("--at-least-one-of", &["--feature-powerset"])?;
             }
@@ -429,6 +437,8 @@ impl Args {
 
         let depth = depth.as_deref().map(str::parse::<usize>).transpose()?;
         let group_features = parse_grouped_features(&group_features, "group-features")?;
+        let mutually_exclusive_features =
+            parse_grouped_features(&mutually_exclusive_features, "mutually-exclusive-features")?;
         let at_least_one_of = parse_grouped_features(&at_least_one_of, "at-least-one-of")?;
 
         if let Some(subcommand) = subcommand.as_deref() {
@@ -505,6 +515,9 @@ impl Args {
             if group_features.iter().any(|v| v.matches(f)) {
                 bail!("feature `{f}` specified by both --exclude-features and --group-features");
             }
+            if mutually_exclusive_features.iter().any(|v| v.matches(f)) {
+                bail!("feature `{f}` specified by both --exclude-features and --mutually-exclusive-features");
+            }
             if include_features.contains(f) {
                 bail!("feature `{f}` specified by both --exclude-features and --include-features");
             }
@@ -621,6 +634,7 @@ impl Args {
 
             depth,
             group_features,
+            mutually_exclusive_features,
 
             exclude_features,
             exclude_no_default_features,
@@ -738,6 +752,11 @@ const HELP: &[HelpText<'_>] = &[
          --group-features c,d`",
         "This flag can only be used together with --feature-powerset flag.",
     ]),
+    ("", "--mutually-exclusive-features", "<FEATURES>...", "Space or comma separated list of features to not use together", &[
+        "To specify multiple groups, use this option multiple times: `--mutually-exclusive-features \
+         a,b --mutually-exclusive-features c,d`",
+        "This flag can only be used together with --feature-powerset flag.",
+    ]),
     ("", "--at-least-one-of", "<FEATURES>...", "Space or comma separated list of features. Skips sets of features that don't enable any of the features listed", &[
         "To specify multiple groups, use this option multiple times: `--at-least-one-of a,b \
          --at-least-one-of c,d`",
diff --git a/src/features.rs b/src/features.rs
index 6d9ce76..6e3bb11 100644
--- a/src/features.rs
+++ b/src/features.rs
@@ -181,6 +181,7 @@ pub(crate) fn feature_powerset<'a>(
     features: impl IntoIterator<Item = &'a Feature>,
     depth: Option<usize>,
     at_least_one_of: &[Feature],
+    mutually_exclusive_features: &[Feature],
     package_features: &BTreeMap<String, Vec<String>>,
 ) -> Vec<Vec<&'a Feature>> {
     let deps_map = feature_deps(package_features);
@@ -205,6 +206,22 @@ pub(crate) fn feature_powerset<'a>(
                     .any(|f| required_set.contains(f.as_str()))
             })
         })
+        .filter(move |fs| {
+            // Filter any feature set containing more than one feature from the same mutually
+            // exclusive group.
+            let mut count = 0;
+            for f in fs.iter().flat_map(|f| f.as_group()) {
+                for group in mutually_exclusive_features {
+                    if group.matches(f) {
+                        count += 1;
+                        if count > 1 {
+                            return false;
+                        }
+                    }
+                }
+            }
+            true
+        })
         .collect()
 }
 
@@ -322,17 +339,32 @@ mod tests {
         let map = map![("a", v![]), ("b", v!["a"]), ("c", v!["b"]), ("d", v!["a", "b"])];
 
         let list = v!["a", "b", "c", "d"];
-        let filtered = feature_powerset(&list, None, &[], &map);
+        let filtered = feature_powerset(&list, None, &[], &[], &map);
         assert_eq!(filtered, vec![vec!["a"], vec!["b"], vec!["c"], vec!["d"], vec!["c", "d"]]);
 
-        let filtered = feature_powerset(&list, None, &["a".into()], &map);
+        let filtered = feature_powerset(&list, None, &["a".into()], &[], &map);
         assert_eq!(filtered, vec![vec!["a"], vec!["b"], vec!["c"], vec!["d"], vec!["c", "d"]]);
 
-        let filtered = feature_powerset(&list, None, &["c".into()], &map);
+        let filtered = feature_powerset(&list, None, &["c".into()], &[], &map);
         assert_eq!(filtered, vec![vec!["c"], vec!["c", "d"]]);
 
-        let filtered = feature_powerset(&list, None, &["a".into(), "c".into()], &map);
+        let filtered = feature_powerset(&list, None, &["a".into(), "c".into()], &[], &map);
         assert_eq!(filtered, vec![vec!["c"], vec!["c", "d"]]);
+
+        let map = map![("tokio", v![]), ("async-std", v![]), ("a", v![]), ("b", v!["a"])];
+        let list = v!["a", "b", "tokio", "async-std"];
+        let filtered =
+            feature_powerset(&list, None, &[], &["tokio".into(), "async-std".into()], &map);
+        assert_eq!(filtered, vec![
+            vec!["a"],
+            vec!["b"],
+            vec!["tokio"],
+            vec!["a", "tokio"],
+            vec!["b", "tokio"],
+            vec!["async-std"],
+            vec!["a", "async-std"],
+            vec!["b", "async-std"]
+        ]);
     }
 
     #[test]
@@ -365,7 +397,7 @@ mod tests {
             vec!["b", "c", "d"],
             vec!["a", "b", "c", "d"],
         ]);
-        let filtered = feature_powerset(&list, None, &[], &map);
+        let filtered = feature_powerset(&list, None, &[], &[], &map);
         assert_eq!(filtered, vec![vec!["a"], vec!["b"], vec!["c"], vec!["d"], vec!["c", "d"]]);
     }
 
diff --git a/src/main.rs b/src/main.rs
index 754acea..8c6a275 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -241,8 +241,13 @@ fn determine_kind<'a>(
             Some(PackageRuns { id, kind, feature_count })
         }
     } else if cx.feature_powerset {
-        let features =
-            features::feature_powerset(features, cx.depth, &cx.at_least_one_of, &package.features);
+        let features = features::feature_powerset(
+            features,
+            cx.depth,
+            &cx.at_least_one_of,
+            &cx.mutually_exclusive_features,
+            &package.features,
+        );
 
         if (pkg_features.normal().is_empty() && pkg_features.optional_deps().is_empty()
             || !cx.include_features.is_empty())


Test patch:
diff --git a/tests/long-help.txt b/tests/long-help.txt
index 3d06a9d..f8ce824 100644
--- a/tests/long-help.txt
+++ b/tests/long-help.txt
@@ -97,6 +97,14 @@ OPTIONS:
 
             This flag can only be used together with --feature-powerset flag.
 
+        --mutually-exclusive-features <FEATURES>...
+            Space or comma separated list of features to not use together.
+
+            To specify multiple groups, use this option multiple times:
+            `--mutually-exclusive-features a,b --mutually-exclusive-features c,d`
+
+            This flag can only be used together with --feature-powerset flag.
+
         --at-least-one-of <FEATURES>...
             Space or comma separated list of features. Skips sets of features that don't enable any
             of the features listed.
diff --git a/tests/short-help.txt b/tests/short-help.txt
index c974255..1f2b701 100644
--- a/tests/short-help.txt
+++ b/tests/short-help.txt
@@ -23,6 +23,8 @@ OPTIONS:
         --depth <NUM>                    Specify a max number of simultaneous feature flags of
                                          --feature-powerset
         --group-features <FEATURES>...   Space or comma separated list of features to group
+        --mutually-exclusive-features <FEATURES>... Space or comma separated list of features to not use
+                                         together
         --at-least-one-of <FEATURES>...  Space or comma separated list of features. Skips sets of
                                          features that don't enable any of the features listed
         --include-features <FEATURES>... Include only the specified features in the feature