Skip to main content

cargo_feature_combinations/
package.rs

1//! Package-level configuration, feature combination generation, and error types.
2
3use crate::config::Config;
4use crate::print_warning;
5use crate::{DEFAULT_METADATA_KEY, find_metadata_value, pkg_metadata_section};
6use color_eyre::eyre;
7use itertools::Itertools;
8use std::collections::{BTreeMap, BTreeSet, HashSet};
9use std::fmt;
10
11const MAX_FEATURE_COMBINATIONS: u128 = 100_000;
12
13/// Errors that can occur while generating feature combinations.
14#[derive(Debug)]
15pub enum FeatureCombinationError {
16    /// The package declares too many features, which would result in more
17    /// combinations than this tool is willing to generate.
18    TooManyConfigurations {
19        /// Package name from Cargo metadata.
20        package: String,
21        /// Number of features considered for combination generation.
22        num_features: usize,
23        /// Total number of configurations implied by `num_features`, if bounded.
24        num_configurations: Option<u128>,
25        /// Maximum number of configurations allowed before failing.
26        limit: u128,
27    },
28}
29
30impl fmt::Display for FeatureCombinationError {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::TooManyConfigurations {
34                package,
35                num_features,
36                num_configurations,
37                limit,
38            } => {
39                write!(
40                    f,
41                    "too many configurations for package `{package}`: {num_features} feature(s) would produce {} combinations (limit: {limit})",
42                    num_configurations
43                        .map(|v| v.to_string())
44                        .unwrap_or_else(|| "an unbounded number of".to_string()),
45                )
46            }
47        }
48    }
49}
50
51impl std::error::Error for FeatureCombinationError {}
52
53/// Extension trait for [`cargo_metadata::Package`] used by this crate.
54pub trait Package {
55    /// Parse the configuration for this package if present.
56    ///
57    /// If the Cargo.toml manifest contains a configuration section,
58    /// the latter is parsed.
59    /// Otherwise, a default configuration is used.
60    ///
61    /// # Errors
62    ///
63    /// If the configuration in the manifest can not be parsed,
64    /// an error is returned.
65    ///
66    fn config(&self) -> eyre::Result<Config>;
67    /// Compute all feature combinations for this package based on the
68    /// provided [`Config`].
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if feature combinations can not be computed, e.g. when
73    /// the package declares too many features.
74    fn feature_combinations<'a>(&'a self, config: &'a Config)
75    -> eyre::Result<Vec<Vec<&'a String>>>;
76    /// Convert [`Package::feature_combinations`] into a list of comma-separated
77    /// feature strings suitable for passing to `cargo --features`.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if [`Package::feature_combinations`] fails.
82    fn feature_matrix(&self, config: &Config) -> eyre::Result<Vec<String>>;
83}
84
85impl Package for cargo_metadata::Package {
86    fn config(&self) -> eyre::Result<Config> {
87        let (mut config, key) = match find_metadata_value(&self.metadata) {
88            Some((value, key)) => (serde_json::from_value(value.clone())?, key),
89            None => (Config::default(), DEFAULT_METADATA_KEY),
90        };
91
92        let section = pkg_metadata_section(key);
93
94        if !config.deprecated.skip_feature_sets.is_empty() {
95            print_warning!(
96                "{section}.skip_feature_sets in package `{}` is deprecated; use exclude_feature_sets instead",
97                self.name,
98            );
99        }
100
101        if !config.deprecated.denylist.is_empty() {
102            print_warning!(
103                "{section}.denylist in package `{}` is deprecated; use exclude_features instead",
104                self.name,
105            );
106        }
107
108        if !config.deprecated.exact_combinations.is_empty() {
109            print_warning!(
110                "{section}.exact_combinations in package `{}` is deprecated; use include_feature_sets instead",
111                self.name,
112            );
113        }
114
115        // Handle deprecated config values
116        config
117            .exclude_feature_sets
118            .append(&mut config.deprecated.skip_feature_sets);
119        config
120            .exclude_features
121            .extend(config.deprecated.denylist.drain());
122        config
123            .include_feature_sets
124            .append(&mut config.deprecated.exact_combinations);
125
126        Ok(config)
127    }
128
129    fn feature_combinations<'a>(
130        &'a self,
131        config: &'a Config,
132    ) -> eyre::Result<Vec<Vec<&'a String>>> {
133        // Short-circuit: if an explicit allowlist of feature sets is configured,
134        // interpret it as the complete matrix.
135        //
136        // This is intentionally *not* combined with the normal powerset-based
137        // generation and its filters: the user is declaring the exact sets they
138        // care about (e.g. SSR vs hydrate), and we should not implicitly add
139        // `[]` or any other combinations.
140        if !config.allow_feature_sets.is_empty() {
141            let mut allowed = config
142                .allow_feature_sets
143                .iter()
144                .map(|proposed_allowed_set| {
145                    // Normalize to this package by dropping unknown feature
146                    // names and switching to references into `self.features`.
147                    proposed_allowed_set
148                        .iter()
149                        .filter_map(|maybe_feature| {
150                            self.features.get_key_value(maybe_feature).map(|(k, _v)| k)
151                        })
152                        .collect::<BTreeSet<_>>()
153                })
154                .collect::<BTreeSet<_>>();
155
156            if config.no_empty_feature_set {
157                // In exact-matrix mode, `[]` is only included if explicitly
158                // listed. This option makes it easy to forbid `[]` entirely.
159                allowed.retain(|set| !set.is_empty());
160            }
161
162            return Ok(allowed
163                .into_iter()
164                .map(|set| set.into_iter().sorted().collect::<Vec<_>>())
165                .sorted()
166                .collect::<Vec<_>>());
167        }
168
169        // Derive the effective exclude set for this package.
170        //
171        // When `skip_optional_dependencies` is enabled, extend the configured
172        // `exclude_features` with implicit features that correspond to optional
173        // dependencies for this package.
174        //
175        // This mirrors the behaviour in `cargo-all-features`: only the
176        // *implicit* features generated by Cargo for optional dependencies are
177        // skipped, i.e. features of the form
178        //
179        //   foo = ["dep:foo"]
180        //
181        // that are not also referenced via `dep:foo` in any other feature.
182        let mut effective_exclude_features = config.exclude_features.clone();
183
184        if config.skip_optional_dependencies {
185            let mut implicit_features: HashSet<String> = HashSet::new();
186            let mut optional_dep_used_with_dep_syntax_outside: HashSet<String> = HashSet::new();
187
188            // Classify implicit optional-dependency features and track optional
189            // dependencies that are referenced via `dep:NAME` in other
190            // features, following the logic from cargo-all-features'
191            // features_finder.rs.
192            for (feature_name, implied) in &self.features {
193                for value in implied.iter().filter(|v| v.starts_with("dep:")) {
194                    let dep_name = value.trim_start_matches("dep:");
195                    if implied.len() == 1 && dep_name == feature_name {
196                        // Feature of the shape `foo = ["dep:foo"]`.
197                        implicit_features.insert(feature_name.clone());
198                    } else {
199                        // The dep is used with `dep:` syntax in another
200                        // feature, so Cargo will not generate an implicit
201                        // feature for it.
202                        optional_dep_used_with_dep_syntax_outside.insert(dep_name.to_string());
203                    }
204                }
205            }
206
207            // If the dep is used with `dep:` syntax in another feature, it is
208            // not an implicit feature and should not be skipped purely because
209            // it is an optional dependency.
210            for dep_name in &optional_dep_used_with_dep_syntax_outside {
211                implicit_features.remove(dep_name);
212            }
213
214            // Extend the effective exclude list with the remaining implicit
215            // optional-dependency features.
216            effective_exclude_features.extend(implicit_features);
217        }
218
219        // Generate the base powerset from
220        // - all features
221        // - or from isolated sets, minus excluded features
222        let base_powerset = if config.isolated_feature_sets.is_empty() {
223            generate_global_base_powerset(
224                &self.name,
225                &self.features,
226                &effective_exclude_features,
227                &config.include_features,
228                &config.only_features,
229            )?
230        } else {
231            generate_isolated_base_powerset(
232                &self.name,
233                &self.features,
234                &config.isolated_feature_sets,
235                &effective_exclude_features,
236                &config.include_features,
237                &config.only_features,
238            )?
239        };
240
241        // Filter out feature sets that contain skip sets
242        let mut filtered_powerset = base_powerset
243            .into_iter()
244            .filter(|feature_set| {
245                !config.exclude_feature_sets.iter().any(|skip_set| {
246                    if skip_set.is_empty() {
247                        // Special-case: an empty skip set means "exclude only the empty
248                        // feature set".
249                        //
250                        // Without this, the usual "all()" subset test would treat an empty
251                        // set as contained in every feature set (vacuously true), and thus
252                        // exclude *everything*.
253                        feature_set.is_empty()
254                    } else {
255                        // Remove feature sets containing any of the skip sets
256                        skip_set
257                            .iter()
258                            // Skip set is contained when all its features are contained
259                            .all(|skip_feature| feature_set.contains(skip_feature))
260                    }
261                })
262            })
263            .collect::<BTreeSet<_>>();
264
265        // Add back exact combinations
266        for proposed_exact_combination in &config.include_feature_sets {
267            // Remove non-existent features and switch reference to that pointing to `self`
268            let exact_combination = proposed_exact_combination
269                .iter()
270                .filter_map(|maybe_feature| {
271                    self.features.get_key_value(maybe_feature).map(|(k, _v)| k)
272                })
273                .collect::<BTreeSet<_>>();
274
275            // This exact combination may now be empty, but empty combination is always added anyway
276            filtered_powerset.insert(exact_combination);
277        }
278
279        if config.no_empty_feature_set {
280            // When enabled, drop the empty feature set (`[]`) from the final matrix.
281            filtered_powerset.retain(|set| !set.is_empty());
282        }
283
284        // Re-collect everything into a vector of vectors
285        Ok(filtered_powerset
286            .into_iter()
287            .map(|set| set.into_iter().sorted().collect::<Vec<_>>())
288            .sorted()
289            .collect::<Vec<_>>())
290    }
291
292    fn feature_matrix(&self, config: &Config) -> eyre::Result<Vec<String>> {
293        Ok(self
294            .feature_combinations(config)?
295            .into_iter()
296            .map(|features| features.iter().join(","))
297            .collect())
298    }
299}
300
301fn checked_num_combinations(num_features: usize) -> Option<u128> {
302    if num_features >= u128::BITS as usize {
303        return None;
304    }
305    let shift: u32 = num_features.try_into().ok()?;
306    Some(1u128 << shift)
307}
308
309fn ensure_within_combination_limit(
310    package_name: &str,
311    num_features: usize,
312) -> Result<(), FeatureCombinationError> {
313    let num_configurations = checked_num_combinations(num_features);
314    let exceeds = match num_configurations {
315        Some(n) => n > MAX_FEATURE_COMBINATIONS,
316        None => true,
317    };
318
319    if exceeds {
320        return Err(FeatureCombinationError::TooManyConfigurations {
321            package: package_name.to_string(),
322            num_features,
323            num_configurations,
324            limit: MAX_FEATURE_COMBINATIONS,
325        });
326    }
327
328    Ok(())
329}
330
331/// Generates the **global** base [powerset](Itertools::powerset) of features.
332/// Global features are all features that are defined in the package, except the
333/// features from the provided denylist.
334///
335/// The returned powerset is a two-level [`BTreeSet`], with the strings pointing
336/// back to the `package_features`.
337fn generate_global_base_powerset<'a>(
338    package_name: &str,
339    package_features: &'a BTreeMap<String, Vec<String>>,
340    exclude_features: &HashSet<String>,
341    include_features: &'a HashSet<String>,
342    only_features: &HashSet<String>,
343) -> Result<BTreeSet<BTreeSet<&'a String>>, FeatureCombinationError> {
344    let features = package_features
345        .keys()
346        .collect::<BTreeSet<_>>()
347        .into_iter()
348        .filter(|ft| !exclude_features.contains(*ft))
349        .filter(|ft| only_features.is_empty() || only_features.contains(*ft))
350        .collect::<BTreeSet<_>>();
351
352    ensure_within_combination_limit(package_name, features.len())?;
353
354    Ok(features
355        .into_iter()
356        .powerset()
357        .map(|combination| {
358            combination
359                .into_iter()
360                .chain(include_features)
361                .collect::<BTreeSet<&'a String>>()
362        })
363        .collect())
364}
365
366/// Generates the **isolated** base [powerset](Itertools::powerset) of features.
367/// Isolated features are features from the provided isolated feature sets,
368/// except non-existent features and except the features from the provided
369/// denylist.
370///
371/// The returned powerset is a two-level [`BTreeSet`], with the strings pointing
372/// back to the `package_features`.
373fn generate_isolated_base_powerset<'a>(
374    package_name: &str,
375    package_features: &'a BTreeMap<String, Vec<String>>,
376    isolated_feature_sets: &[HashSet<String>],
377    exclude_features: &HashSet<String>,
378    include_features: &'a HashSet<String>,
379    only_features: &HashSet<String>,
380) -> Result<BTreeSet<BTreeSet<&'a String>>, FeatureCombinationError> {
381    // Collect known package features for easy querying
382    let known_features = package_features.keys().collect::<HashSet<_>>();
383
384    let mut worst_case_total: u128 = 0;
385    for isolated_feature_set in isolated_feature_sets {
386        let num_features = isolated_feature_set
387            .iter()
388            .filter(|ft| known_features.contains(*ft))
389            .filter(|ft| !exclude_features.contains(*ft))
390            .filter(|ft| only_features.is_empty() || only_features.contains(*ft))
391            .count();
392
393        let Some(n) = checked_num_combinations(num_features) else {
394            return Err(FeatureCombinationError::TooManyConfigurations {
395                package: package_name.to_string(),
396                num_features,
397                num_configurations: None,
398                limit: MAX_FEATURE_COMBINATIONS,
399            });
400        };
401
402        worst_case_total = worst_case_total.saturating_add(n);
403        if worst_case_total > MAX_FEATURE_COMBINATIONS {
404            return Err(FeatureCombinationError::TooManyConfigurations {
405                package: package_name.to_string(),
406                num_features,
407                num_configurations: Some(worst_case_total),
408                limit: MAX_FEATURE_COMBINATIONS,
409            });
410        }
411    }
412
413    Ok(isolated_feature_sets
414        .iter()
415        .flat_map(|isolated_feature_set| {
416            isolated_feature_set
417                .iter()
418                .filter(|ft| known_features.contains(*ft)) // remove non-existent features
419                .filter(|ft| !exclude_features.contains(*ft)) // remove features from denylist
420                .filter(|ft| only_features.is_empty() || only_features.contains(*ft))
421                .powerset()
422                .map(|combination| {
423                    combination
424                        .into_iter()
425                        .filter_map(|feature| known_features.get(feature).copied())
426                        .chain(include_features)
427                        .collect::<BTreeSet<_>>()
428                })
429        })
430        .collect())
431}
432
433#[cfg(test)]
434pub(crate) mod test {
435    use super::{FeatureCombinationError, Package};
436    use crate::config::Config;
437    use color_eyre::eyre;
438    use similar_asserts::assert_eq as sim_assert_eq;
439    use std::collections::HashSet;
440
441    static INIT: std::sync::Once = std::sync::Once::new();
442
443    fn init() {
444        INIT.call_once(|| {
445            color_eyre::install().ok();
446        });
447    }
448
449    pub(crate) fn package_with_features(
450        features: &[&str],
451    ) -> eyre::Result<cargo_metadata::Package> {
452        use cargo_metadata::{PackageBuilder, PackageId, PackageName};
453        use semver::Version;
454        use std::str::FromStr as _;
455
456        let mut package = PackageBuilder::new(
457            PackageName::from_str("test")?,
458            Version::parse("0.1.0")?,
459            PackageId {
460                repr: "test".to_string(),
461            },
462            "",
463        )
464        .build()?;
465        package.features = features
466            .iter()
467            .map(|feature| ((*feature).to_string(), vec![]))
468            .collect();
469        Ok(package)
470    }
471
472    #[test]
473    fn combinations() -> eyre::Result<()> {
474        init();
475        let package = package_with_features(&["foo-c", "foo-a", "foo-b"])?;
476        let config = Config::default();
477        let want = vec![
478            vec![],
479            vec!["foo-a"],
480            vec!["foo-a", "foo-b"],
481            vec!["foo-a", "foo-b", "foo-c"],
482            vec!["foo-a", "foo-c"],
483            vec!["foo-b"],
484            vec!["foo-b", "foo-c"],
485            vec!["foo-c"],
486        ];
487        let have = package.feature_combinations(&config)?;
488
489        sim_assert_eq!(have: have, want: want);
490        Ok(())
491    }
492
493    #[test]
494    fn combinations_only_features() -> eyre::Result<()> {
495        init();
496        let package = package_with_features(&["foo", "bar", "baz"])?;
497        let config = Config {
498            exclude_features: HashSet::from(["default".to_string()]),
499            only_features: HashSet::from(["foo".to_string(), "bar".to_string()]),
500            ..Default::default()
501        };
502
503        let want = vec![vec![], vec!["bar"], vec!["bar", "foo"], vec!["foo"]];
504        let have = package.feature_combinations(&config)?;
505
506        sim_assert_eq!(have: have, want: want);
507        Ok(())
508    }
509
510    #[test]
511    fn combinations_isolated() -> eyre::Result<()> {
512        init();
513        let package =
514            package_with_features(&["foo-a", "foo-b", "bar-b", "bar-a", "car-b", "car-a"])?;
515        let config = Config {
516            isolated_feature_sets: vec![
517                HashSet::from(["foo-a".to_string(), "foo-b".to_string()]),
518                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
519            ],
520            ..Default::default()
521        };
522        let want = vec![
523            vec![],
524            vec!["bar-a"],
525            vec!["bar-a", "bar-b"],
526            vec!["bar-b"],
527            vec!["foo-a"],
528            vec!["foo-a", "foo-b"],
529            vec!["foo-b"],
530        ];
531        let have = package.feature_combinations(&config)?;
532
533        sim_assert_eq!(have: have, want: want);
534        Ok(())
535    }
536
537    #[test]
538    fn combinations_isolated_non_existent() -> eyre::Result<()> {
539        init();
540        let package =
541            package_with_features(&["foo-a", "foo-b", "bar-a", "bar-b", "car-a", "car-b"])?;
542        let config = Config {
543            isolated_feature_sets: vec![
544                HashSet::from(["foo-a".to_string(), "non-existent".to_string()]),
545                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
546            ],
547            ..Default::default()
548        };
549        let want = vec![
550            vec![],
551            vec!["bar-a"],
552            vec!["bar-a", "bar-b"],
553            vec!["bar-b"],
554            vec!["foo-a"],
555        ];
556        let have = package.feature_combinations(&config)?;
557
558        sim_assert_eq!(have: have, want: want);
559        Ok(())
560    }
561
562    #[test]
563    fn combinations_isolated_denylist() -> eyre::Result<()> {
564        init();
565        let package =
566            package_with_features(&["foo-a", "foo-b", "bar-b", "bar-a", "car-a", "car-b"])?;
567        let config = Config {
568            isolated_feature_sets: vec![
569                HashSet::from(["foo-a".to_string(), "foo-b".to_string()]),
570                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
571            ],
572            exclude_features: HashSet::from(["bar-a".to_string()]),
573            ..Default::default()
574        };
575        let want = vec![
576            vec![],
577            vec!["bar-b"],
578            vec!["foo-a"],
579            vec!["foo-a", "foo-b"],
580            vec!["foo-b"],
581        ];
582        let have = package.feature_combinations(&config)?;
583
584        sim_assert_eq!(have: have, want: want);
585        Ok(())
586    }
587
588    #[test]
589    fn combinations_isolated_non_existent_denylist() -> eyre::Result<()> {
590        init();
591        let package =
592            package_with_features(&["foo-b", "foo-a", "bar-a", "bar-b", "car-a", "car-b"])?;
593        let config = Config {
594            isolated_feature_sets: vec![
595                HashSet::from(["foo-a".to_string(), "non-existent".to_string()]),
596                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
597            ],
598            exclude_features: HashSet::from(["bar-a".to_string()]),
599            ..Default::default()
600        };
601        let want = vec![vec![], vec!["bar-b"], vec!["foo-a"]];
602        let have = package.feature_combinations(&config)?;
603
604        sim_assert_eq!(have: have, want: want);
605        Ok(())
606    }
607
608    #[test]
609    fn combinations_isolated_non_existent_denylist_exact() -> eyre::Result<()> {
610        init();
611        let package =
612            package_with_features(&["foo-a", "foo-b", "bar-a", "bar-b", "car-a", "car-b"])?;
613        let config = Config {
614            isolated_feature_sets: vec![
615                HashSet::from(["foo-a".to_string(), "non-existent".to_string()]),
616                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
617            ],
618            exclude_features: HashSet::from(["bar-a".to_string()]),
619            include_feature_sets: vec![HashSet::from([
620                "car-a".to_string(),
621                "bar-a".to_string(),
622                "non-existent".to_string(),
623            ])],
624            ..Default::default()
625        };
626        let want = vec![vec![], vec!["bar-a", "car-a"], vec!["bar-b"], vec!["foo-a"]];
627        let have = package.feature_combinations(&config)?;
628
629        sim_assert_eq!(have: have, want: want);
630        Ok(())
631    }
632
633    #[test]
634    fn combinations_allow_feature_sets_exact() -> eyre::Result<()> {
635        init();
636        let package = package_with_features(&["hydrate", "ssr", "other"])?;
637        let config = Config {
638            allow_feature_sets: vec![
639                HashSet::from(["ssr".to_string()]),
640                HashSet::from(["hydrate".to_string()]),
641            ],
642            ..Default::default()
643        };
644
645        let want = vec![vec!["hydrate"], vec!["ssr"]];
646        let have = package.feature_combinations(&config)?;
647
648        sim_assert_eq!(have: have, want: want);
649        Ok(())
650    }
651
652    #[test]
653    fn combinations_allow_feature_sets_ignores_other_options() -> eyre::Result<()> {
654        init();
655        let package = package_with_features(&["hydrate", "ssr"])?;
656        let config = Config {
657            allow_feature_sets: vec![HashSet::from(["hydrate".to_string()])],
658            exclude_features: HashSet::from(["hydrate".to_string()]),
659            exclude_feature_sets: vec![HashSet::from(["hydrate".to_string()])],
660            include_feature_sets: vec![HashSet::from(["ssr".to_string()])],
661            only_features: HashSet::from(["ssr".to_string()]),
662            ..Default::default()
663        };
664
665        let want = vec![vec!["hydrate"]];
666        let have = package.feature_combinations(&config)?;
667
668        sim_assert_eq!(have: have, want: want);
669        Ok(())
670    }
671
672    #[test]
673    fn combinations_no_empty_feature_set_filters_generated_empty() -> eyre::Result<()> {
674        init();
675        let package = package_with_features(&["foo", "bar"])?;
676        let config = Config {
677            no_empty_feature_set: true,
678            ..Default::default()
679        };
680
681        let want = vec![vec!["bar"], vec!["bar", "foo"], vec!["foo"]];
682        let have = package.feature_combinations(&config)?;
683
684        sim_assert_eq!(have: have, want: want);
685        Ok(())
686    }
687
688    #[test]
689    fn combinations_no_empty_feature_set_filters_included_empty() -> eyre::Result<()> {
690        init();
691        let package = package_with_features(&["foo"])?;
692        let config = Config {
693            include_feature_sets: vec![HashSet::new()],
694            no_empty_feature_set: true,
695            ..Default::default()
696        };
697
698        let want = vec![vec!["foo"]];
699        let have = package.feature_combinations(&config)?;
700
701        sim_assert_eq!(have: have, want: want);
702        Ok(())
703    }
704
705    #[test]
706    fn combinations_exclude_empty_feature_set_only() -> eyre::Result<()> {
707        init();
708        let package = package_with_features(&["foo", "bar"])?;
709        let config = Config {
710            exclude_feature_sets: vec![HashSet::new()],
711            ..Default::default()
712        };
713
714        let want = vec![vec!["bar"], vec!["bar", "foo"], vec!["foo"]];
715        let have = package.feature_combinations(&config)?;
716
717        sim_assert_eq!(have: have, want: want);
718        Ok(())
719    }
720
721    #[test]
722    fn too_many_feature_configurations() -> eyre::Result<()> {
723        init();
724        let features: Vec<String> = (0..25).map(|i| format!("f{i}")).collect();
725        let feature_refs: Vec<&str> = features.iter().map(String::as_str).collect();
726        let package = package_with_features(&feature_refs)?;
727
728        let config = Config::default();
729        let Err(err) = package.feature_combinations(&config) else {
730            eyre::bail!("expected too-many-configurations error");
731        };
732        let Some(err) = err.downcast_ref::<FeatureCombinationError>() else {
733            eyre::bail!("expected FeatureCombinationError");
734        };
735        assert!(
736            err.to_string().contains("too many configurations"),
737            "expected 'too many configurations' error, got: {err}"
738        );
739        Ok(())
740    }
741
742    /// Build a package whose metadata contains a config under the given alias.
743    pub(crate) fn package_with_metadata(
744        features: &[&str],
745        metadata_key: &str,
746        config: &serde_json::Value,
747    ) -> eyre::Result<cargo_metadata::Package> {
748        let mut package = package_with_features(features)?;
749        package.metadata = serde_json::json!({ metadata_key: config });
750        Ok(package)
751    }
752
753    #[test]
754    fn config_from_cargo_fc_alias() -> eyre::Result<()> {
755        init();
756        let package = package_with_metadata(
757            &["foo", "bar"],
758            "cargo-fc",
759            &serde_json::json!({ "exclude_features": ["foo"] }),
760        )?;
761        let config = package.config()?;
762        assert!(config.exclude_features.contains("foo"));
763        assert!(!config.exclude_features.contains("bar"));
764        Ok(())
765    }
766
767    #[test]
768    fn config_from_fc_alias() -> eyre::Result<()> {
769        init();
770        let package = package_with_metadata(
771            &["foo", "bar"],
772            "fc",
773            &serde_json::json!({ "exclude_features": ["bar"] }),
774        )?;
775        let config = package.config()?;
776        assert!(config.exclude_features.contains("bar"));
777        assert!(!config.exclude_features.contains("foo"));
778        Ok(())
779    }
780
781    #[test]
782    fn config_from_feature_combinations_alias() -> eyre::Result<()> {
783        init();
784        let package = package_with_metadata(
785            &["a", "b"],
786            "feature-combinations",
787            &serde_json::json!({ "no_empty_feature_set": true }),
788        )?;
789        let config = package.config()?;
790        assert!(config.no_empty_feature_set);
791        Ok(())
792    }
793
794    #[test]
795    fn config_from_cargo_feature_combinations_alias() -> eyre::Result<()> {
796        init();
797        let package = package_with_metadata(
798            &["a", "b"],
799            "cargo-feature-combinations",
800            &serde_json::json!({ "exclude_features": ["a"] }),
801        )?;
802        let config = package.config()?;
803        assert!(config.exclude_features.contains("a"));
804        Ok(())
805    }
806
807    #[test]
808    fn config_default_when_no_metadata() -> eyre::Result<()> {
809        init();
810        let package = package_with_features(&["foo"])?;
811        let config = package.config()?;
812        assert!(config.exclude_features.is_empty());
813        assert!(!config.no_empty_feature_set);
814        Ok(())
815    }
816
817    #[test]
818    fn config_alias_affects_feature_matrix() -> eyre::Result<()> {
819        init();
820        let package = package_with_metadata(
821            &["foo", "bar"],
822            "cargo-fc",
823            &serde_json::json!({ "exclude_features": ["foo"] }),
824        )?;
825        let config = package.config()?;
826        let matrix = package.feature_combinations(&config)?;
827
828        // "foo" is excluded, so no combination should contain it
829        assert!(
830            !matrix.iter().any(|combo| combo.iter().any(|f| *f == "foo")),
831            "expected no combination to contain 'foo', got: {matrix:?}"
832        );
833        // "bar" should still appear
834        assert!(
835            matrix.iter().any(|combo| combo.iter().any(|f| *f == "bar")),
836            "expected 'bar' in at least one combination, got: {matrix:?}"
837        );
838        Ok(())
839    }
840}