Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FEATURES>...
Expand Down Expand Up @@ -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`.
Expand Down
12 changes: 10 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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.",
],
),
Expand Down Expand Up @@ -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.",
Expand Down
9 changes: 4 additions & 5 deletions src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand All @@ -259,7 +256,9 @@ pub(crate) fn feature_powerset<'a>(
.collect()
}

fn feature_deps(map: &BTreeMap<Box<str>, Box<[Box<str>]>>) -> BTreeMap<&str, BTreeSet<&str>> {
pub(crate) fn feature_deps(
map: &BTreeMap<Box<str>, Box<[Box<str>]>>,
) -> BTreeMap<&str, BTreeSet<&str>> {
fn rec<'a>(
map: &'a BTreeMap<Box<str>, Box<[Box<str>]>>,
set: &mut BTreeSet<&'a str>,
Expand Down
16 changes: 16 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion tests/long-help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FEATURES>...
Expand Down Expand Up @@ -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`.
Expand Down
55 changes: 54 additions & 1 deletion tests/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
",
);

Expand Down Expand Up @@ -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"])
Expand Down
Loading