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

Issue:
Maybe: require one of a set of features in `--feature-powerset`
As an example, consider a crate which uses a hashmap, and has a feature `std` to use the std hashmap and a feature `hashbrown` to use the hashmap from hashbrown. This crate *requires* that at least one of `std` or `hashbrown` are used, so `--feature-powerset` won't work as-is, because it will want to test the noncompiling `--no-default-features` configuration. There are other feature flags which the library would like to test as well, so `--exclude-no-default-features` isn't sufficient.

Currently, the crate can use two runs of `cargo hack`, e.g.

```text
cargo hack --feature-powerset --features=std check
cargo hack --feature-powerset --features=hashbrown check
```

If there's more than one such set of "require one of." you can see how this could get complicated quickly, basically being its own separate powerset.

cargo-hack could potentially provide a flag `--required-features-group std,hashbrown` with the semantics of discarding any feature set which does not include at least one of the listed features in order to make this situation easier to test.


Discussion / hints:
I would accept a PR to add this. (This appears to be a more manageable version of https://github.com/taiki-e/cargo-hack/issues/115)

Fix patch:
diff --git a/README.md b/README.md
index 2b9f7333..4bccb550 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,15 @@ OPTIONS:
 
             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`
+
+            This flag can only be used together with --feature-powerset flag.
+
         --include-features <FEATURES>...
             Include only the specified features in the feature combinations instead of package
             features.
diff --git a/src/cli.rs b/src/cli.rs
index 6838e1ed..df8c8126 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -78,6 +78,9 @@ pub(crate) struct Args {
     pub(crate) depth: Option<usize>,
     /// --group-features <FEATURES>...
     pub(crate) group_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>,
 
     // options that will be propagated to cargo
     /// --features <FEATURES>...
@@ -151,6 +154,7 @@ impl Args {
 
         let mut optional_deps = None;
         let mut include_features = vec![];
+        let mut at_least_one_of = vec![];
         let mut include_deps_features = false;
 
         let mut exclude_features = vec![];
@@ -277,6 +281,7 @@ impl Args {
                 Long("remove-dev-deps") => parse_flag!(remove_dev_deps),
                 Long("each-feature") => parse_flag!(each_feature),
                 Long("feature-powerset") => parse_flag!(feature_powerset),
+                Long("at-least-one-of") => at_least_one_of.push(parser.value()?.parse()?),
                 Long("no-private") => parse_flag!(no_private),
                 Long("ignore-private") => parse_flag!(ignore_private),
                 Long("exclude-no-default-features") => parse_flag!(exclude_no_default_features),
@@ -391,8 +396,16 @@ impl Args {
                 requires("--include-features", &["--each-feature", "--feature-powerset"])?;
             } else if include_deps_features {
                 requires("--include-deps-features", &["--each-feature", "--feature-powerset"])?;
+            } else if !at_least_one_of.is_empty() {
+                requires("--at-least-one-of", &["--feature-powerset"])?;
             }
         }
+
+        if !at_least_one_of.is_empty() {
+            // there will always be a feature set
+            exclude_no_default_features = true;
+        }
+
         if !feature_powerset {
             if depth.is_some() {
                 requires("--depth", &["--feature-powerset"])?;
@@ -410,21 +423,8 @@ impl Args {
         }
 
         let depth = depth.as_deref().map(str::parse::<usize>).transpose()?;
-        let group_features =
-            group_features.iter().try_fold(Vec::with_capacity(group_features.len()), |mut v, g| {
-                let g = if g.contains(',') {
-                    g.split(',')
-                } else if g.contains(' ') {
-                    g.split(' ')
-                } else {
-                    bail!(
-                        "--group-features requires a list of two or more features separated by space \
-                         or comma"
-                    );
-                };
-                v.push(Feature::group(g));
-                Ok(v)
-            })?;
+        let group_features = parse_grouped_features(&group_features, "group-features")?;
+        let at_least_one_of = parse_grouped_features(&at_least_one_of, "at-least-one-of")?;
 
         if let Some(subcommand) = subcommand.as_deref() {
             match subcommand {
@@ -567,6 +567,7 @@ impl Args {
             print_command_list,
             no_manifest_path,
             include_features: include_features.into_iter().map(Into::into).collect(),
+            at_least_one_of,
             include_deps_features,
             version_range,
             version_step,
@@ -586,6 +587,28 @@ impl Args {
     }
 }
 
+fn parse_grouped_features(
+    group_features: &[String],
+    option_name: &str,
+) -> Result<Vec<Feature>, anyhow::Error> {
+    let group_features =
+        group_features.iter().try_fold(Vec::with_capacity(group_features.len()), |mut v, g| {
+            let g = if g.contains(',') {
+                g.split(',')
+            } else if g.contains(' ') {
+                g.split(' ')
+            } else {
+                bail!(
+                    "--{option_name} requires a list of two or more features separated by space \
+                         or comma"
+                );
+            };
+            v.push(Feature::group(g));
+            Ok(v)
+        })?;
+    Ok(group_features)
+}
+
 fn has_z_flag(args: &[String], name: &str) -> bool {
     let mut iter = args.iter().map(String::as_str);
     while let Some(mut arg) = iter.next() {
@@ -668,6 +691,11 @@ const HELP: &[HelpText<'_>] = &[
          --group-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`",
+        "This flag can only be used together with --feature-powerset flag.",
+    ]),
     (
         "",
         "--include-features",
diff --git a/src/features.rs b/src/features.rs
index 935f7251..640b2649 100644
--- a/src/features.rs
+++ b/src/features.rs
@@ -178,9 +178,12 @@ impl AsRef<str> for Feature {
 pub(crate) fn feature_powerset<'a>(
     features: impl IntoIterator<Item = &'a Feature>,
     depth: Option<usize>,
-    map: &BTreeMap<String, Vec<String>>,
+    at_least_one_of: &[Feature],
+    package_features: &BTreeMap<String, Vec<String>>,
 ) -> Vec<Vec<&'a Feature>> {
-    let deps_map = feature_deps(map);
+    let deps_map = feature_deps(package_features);
+    let at_least_one_of = at_least_one_of_for_package(at_least_one_of, &deps_map);
+
     powerset(features, depth)
         .into_iter()
         .skip(1) // The first element of a powerset is `[]` so it should be skipped.
@@ -191,6 +194,15 @@ pub(crate) fn feature_powerset<'a>(
                 })
             })
         })
+        .filter(move |fs| {
+            // all() returns true if at_least_one_of is empty
+            at_least_one_of.iter().all(|required_set| {
+                fs
+                    .iter()
+                    .flat_map(|f| f.as_group())
+                    .any(|f| required_set.contains(f.as_str()))
+            })
+        })
         .collect()
 }
 
@@ -237,11 +249,44 @@ fn powerset<T: Copy>(iter: impl IntoIterator<Item = T>, depth: Option<usize>) ->
     })
 }
 
+// Leave only features that are possible to enable in the package.
+pub(crate) fn at_least_one_of_for_package<'a>(
+    at_least_one_of: &[Feature],
+    package_features_flattened: &BTreeMap<&'a str, BTreeSet<&'a str>>,
+) -> Vec<BTreeSet<&'a str>> {
+    if at_least_one_of.is_empty() {
+        return vec![];
+    }
+
+    let mut all_features_enabled_by = BTreeMap::new();
+    for (&enabled_by, enables) in package_features_flattened {
+        all_features_enabled_by.entry(enabled_by).or_insert_with(BTreeSet::new).insert(enabled_by);
+        for &enabled_feature in enables {
+            all_features_enabled_by
+                .entry(enabled_feature)
+                .or_insert_with(BTreeSet::new)
+                .insert(enabled_by);
+        }
+    }
+
+    at_least_one_of
+        .iter()
+        .map(|set| {
+            set.as_group()
+                .iter()
+                .filter_map(|f| all_features_enabled_by.get(f.as_str()))
+                .flat_map(|f| f.iter().copied())
+                .collect::<BTreeSet<_>>()
+        })
+        .filter(|set| !set.is_empty())
+        .collect::<Vec<_>>()
+}
+
 #[cfg(test)]
 mod tests {
     use std::collections::{BTreeMap, BTreeSet};
 
-    use super::{feature_deps, feature_powerset, powerset, Feature};
+    use super::{at_least_one_of_for_package, feature_deps, feature_powerset, powerset, Feature};
 
     macro_rules! v {
         ($($expr:expr),* $(,)?) => {
@@ -261,6 +306,33 @@ mod tests {
         };
     }
 
+    #[test]
+    fn at_least_one_of_for_package_filter() {
+        let map = map![("a", v![]), ("b", v!["a"]), ("c", v!["b"]), ("d", v!["a", "b"])];
+        let fd = feature_deps(&map);
+        let list: Vec<Feature> = v!["b", "x", "y", "z"];
+        let filtered = at_least_one_of_for_package(&list, &fd);
+        assert_eq!(filtered, vec![set!("b", "c", "d")]);
+    }
+
+    #[test]
+    fn powerset_with_filter() {
+        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);
+        assert_eq!(filtered, vec![vec!["a"], vec!["b"], vec!["c"], vec!["d"], vec!["c", "d"]]);
+
+        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);
+        assert_eq!(filtered, vec![vec!["c"], vec!["c", "d"]]);
+
+        let filtered = feature_powerset(&list, None, &["a".into(), "c".into()], &map);
+        assert_eq!(filtered, vec![vec!["c"], vec!["c", "d"]]);
+    }
+
     #[test]
     fn feature_deps1() {
         let map = map![("a", v![]), ("b", v!["a"]), ("c", v!["b"]), ("d", v!["a", "b"])];
@@ -291,7 +363,7 @@ mod tests {
             vec!["b", "c", "d"],
             vec!["a", "b", "c", "d"],
         ]);
-        let filtered = feature_powerset(list.iter().collect::<Vec<_>>(), None, &map);
+        let filtered = feature_powerset(list.iter().collect::<Vec<_>>(), 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 f9475845..448fa76a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -237,7 +237,8 @@ fn determine_kind<'a>(
             Kind::Each { features }
         }
     } else if cx.feature_powerset {
-        let features = features::feature_powerset(features, cx.depth, &package.features);
+        let features =
+            features::feature_powerset(features, cx.depth, &cx.at_least_one_of, &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 dbf9542f..a1a0015f 100644
--- a/tests/long-help.txt
+++ b/tests/long-help.txt
@@ -97,6 +97,15 @@ OPTIONS:
 
             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`
+
+            This flag can only be used together with --feature-powerset flag.
+
         --include-features <FEATURES>...
             Include only the specified features in the feature combinations instead of package
             features.
diff --git a/tests/short-help.txt b/tests/short-help.txt
index b364fe61..2c5ba961 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
+        --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
                                          combinations instead of package features
         --no-dev-deps                    Perform without dev-dependencies