diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f1cef..c632aee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Note: In this file, do not use the hard wrap in the middle of a sentence for com ## [Unreleased] +- Report an error for `--at-least-one-of` features absent from every selected package, unless `--ignore-unknown-features` is used. + ## [0.6.45] - 2026-05-30 - Support versioned name and package spec in `--package` and `--exclude`. ([#298](https://github.com/taiki-e/cargo-hack/pull/298)) diff --git a/README.md b/README.md index a36abe1..00b8bb3 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,9 @@ OPTIONS: To specify multiple groups, use this option multiple times: `--at-least-one-of a,b --at-least-one-of c,d` + Features absent from all selected packages cause an error, unless + --ignore-unknown-features is used. + This flag can only be used together with --feature-powerset flag. --include-features ... @@ -181,7 +184,8 @@ OPTIONS: --ignore-unknown-features Skip passing --features flag to `cargo` if that feature does not exist in the package. - This flag can be used with --features, --include-features, or --group-features. + This flag can be used with --features, --include-features, --group-features, or + --at-least-one-of. --rust-version Perform commands on `package.rust-version`. diff --git a/src/cli.rs b/src/cli.rs index 76898d9..aed4b09 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -431,11 +431,16 @@ impl Args { term::set_coloring(color.as_deref())?; if ignore_unknown_features { - if features.is_empty() && include_features.is_empty() && group_features.is_empty() { + if features.is_empty() + && include_features.is_empty() + && group_features.is_empty() + && at_least_one_of.is_empty() + { requires("--ignore-unknown-features", &[ "--features", "--include-features", "--group-features", + "--at-least-one-of", ])?; } if !include_features.is_empty() { @@ -838,6 +843,7 @@ const HELP: &[HelpText<'_>] = &[ &[ "To specify multiple groups, use this option multiple times: \ `--at-least-one-of a,b --at-least-one-of c,d`", + "Features absent from all selected packages cause an error, unless --ignore-unknown-features is used.", "This flag can only be used together with --feature-powerset flag.", ], ), @@ -869,7 +875,9 @@ const HELP: &[HelpText<'_>] = &[ "--ignore-unknown-features", "", "Skip passing --features flag to `cargo` if that feature does not exist in the package", - &["This flag can be used with --features, --include-features, or --group-features."], + &[ + "This flag can be used with --features, --include-features, --group-features, or --at-least-one-of.", + ], ), ("", "--rust-version", "", "Perform commands on `package.rust-version`", &[ "This cannot be used with --version-range.", diff --git a/src/features.rs b/src/features.rs index 7a77704..3085501 100644 --- a/src/features.rs +++ b/src/features.rs @@ -234,10 +234,7 @@ 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())) + fs.iter().flat_map(|f| f.as_group()).any(|f| required_set.contains(f.as_str())) }) }) .filter(move |fs| { @@ -259,7 +256,9 @@ pub(crate) fn feature_powerset<'a>( .collect() } -fn feature_deps(map: &BTreeMap, Box<[Box]>>) -> BTreeMap<&str, BTreeSet<&str>> { +pub(crate) fn feature_deps( + map: &BTreeMap, Box<[Box]>>, +) -> BTreeMap<&str, BTreeSet<&str>> { fn rec<'a>( map: &'a BTreeMap, Box<[Box]>>, set: &mut BTreeSet<&'a str>, diff --git a/src/main.rs b/src/main.rs index 2f126eb..3a1c857 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,22 @@ fn try_main() -> Result<()> { } let packages = determine_package_list(cx)?; + if !cx.ignore_unknown_features && !cx.at_least_one_of.is_empty() { + let feature_deps: Vec<_> = packages + .iter() + .map(|pkg| features::feature_deps(&cx.packages(pkg.id).features)) + .collect(); + for name in cx.at_least_one_of.iter().flat_map(Feature::as_group) { + if !feature_deps.iter().any(|deps| { + deps.contains_key(name.as_str()) + || deps.values().any(|features| features.contains(name.as_str())) + }) { + bail!( + "feature `{name}` specified by --at-least-one-of not found in any selected package" + ); + } + } + } let mut progress = Progress::default(); let mut keep_going = KeepGoing::default(); if let Some(range) = cx.version_range { diff --git a/tests/long-help.txt b/tests/long-help.txt index 269627a..37e2541 100644 --- a/tests/long-help.txt +++ b/tests/long-help.txt @@ -123,6 +123,9 @@ OPTIONS: To specify multiple groups, use this option multiple times: `--at-least-one-of a,b --at-least-one-of c,d` + Features absent from all selected packages cause an error, unless + --ignore-unknown-features is used. + This flag can only be used together with --feature-powerset flag. --include-features ... @@ -151,7 +154,8 @@ OPTIONS: --ignore-unknown-features Skip passing --features flag to `cargo` if that feature does not exist in the package. - This flag can be used with --features, --include-features, or --group-features. + This flag can be used with --features, --include-features, --group-features, or + --at-least-one-of. --rust-version Perform commands on `package.rust-version`. diff --git a/tests/test.rs b/tests/test.rs index 8b8d8e3..0743eb4 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -465,7 +465,7 @@ fn ignore_unknown_features_failure() { .stderr_contains( " --ignore-unknown-features can only be used together with --features, --include-features, \ - or --group-features + --group-features, or --at-least-one-of ", ); @@ -937,6 +937,59 @@ fn include_features() { ); } +#[test] +fn at_least_one_of_unknown_features() { + cargo_hack(["check", "--feature-powerset", "--at-least-one-of", "a,missing"]) + .assert_failure("real") + .stderr_contains( + "feature `missing` specified by --at-least-one-of not found in any selected package", + ) + .stderr_not_contains("running `cargo"); + + // A feature only needs to exist in one of the selected workspace packages. + cargo_hack(["check", "--feature-powerset", "--at-least-one-of", "a,f"]) + .assert_success("virtual"); + + cargo_hack(["check", "--feature-powerset", "--at-least-one-of", "a,f", "--exclude", "member2"]) + .assert_failure("virtual") + .stderr_contains( + "feature `f` specified by --at-least-one-of not found in any selected package", + ); + + cargo_hack([ + "check", + "--feature-powerset", + "--at-least-one-of", + "a,missing", + "--ignore-unknown-features", + ]) + .assert_success("real"); + + cargo_hack([ + "check", + "--feature-powerset", + "--at-least-one-of", + "missing,also-missing", + "--ignore-unknown-features", + ]) + .assert_success("real"); + + cargo_hack(["check", "--feature-powerset", "--at-least-one-of", "a,f", "-p", "member1"]) + .assert_failure("virtual") + .stderr_contains( + "feature `f` specified by --at-least-one-of not found in any selected package", + ); + + // Dependency feature references have always been accepted by the per-package filter. + cargo_hack([ + "check", + "--feature-powerset", + "--at-least-one-of", + "easytime/std,easytime?/default", + ]) + .assert_success2("weak_dep_features_namespaced", Some(60)); +} + #[test] fn exclude_features() { cargo_hack(["check", "--each-feature", "--exclude-features", "f"])