Skip to main content

cargo_feature_combinations/
lib.rs

1//! Run cargo commands for all feature combinations across a workspace.
2//!
3//! This crate powers the `cargo-fc` and `cargo-feature-combinations` binaries.
4//! The main entry point for consumers is [`run`], which parses CLI arguments
5//! and dispatches the requested command.
6
7mod config;
8mod tee;
9
10use crate::config::{Config, WorkspaceConfig};
11use color_eyre::eyre::{self, WrapErr};
12use itertools::Itertools;
13use regex::Regex;
14use std::collections::{BTreeMap, BTreeSet, HashSet};
15use std::fmt;
16use std::io::{self, Write};
17use std::path::PathBuf;
18use std::process;
19use std::sync::LazyLock;
20use std::time::{Duration, Instant};
21use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
22
23const METADATA_KEY: &str = "cargo-feature-combinations";
24
25static CYAN: LazyLock<ColorSpec> = LazyLock::new(|| color_spec(Color::Cyan, true));
26static RED: LazyLock<ColorSpec> = LazyLock::new(|| color_spec(Color::Red, true));
27static YELLOW: LazyLock<ColorSpec> = LazyLock::new(|| color_spec(Color::Yellow, true));
28static GREEN: LazyLock<ColorSpec> = LazyLock::new(|| color_spec(Color::Green, true));
29
30const MAX_FEATURE_COMBINATIONS: u128 = 100_000;
31
32/// Errors that can occur while generating feature combinations.
33#[derive(Debug)]
34pub enum FeatureCombinationError {
35    /// The package declares too many features, which would result in more
36    /// combinations than this tool is willing to generate.
37    TooManyConfigurations {
38        /// Package name from Cargo metadata.
39        package: String,
40        /// Number of features considered for combination generation.
41        num_features: usize,
42        /// Total number of configurations implied by `num_features`, if bounded.
43        num_configurations: Option<u128>,
44        /// Maximum number of configurations allowed before failing.
45        limit: u128,
46    },
47}
48
49impl fmt::Display for FeatureCombinationError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::TooManyConfigurations {
53                package,
54                num_features,
55                num_configurations,
56                limit,
57            } => {
58                write!(
59                    f,
60                    "too many configurations for package `{}`: {} feature(s) would produce {} combinations (limit: {})",
61                    package,
62                    num_features,
63                    num_configurations
64                        .map(|v| v.to_string())
65                        .unwrap_or_else(|| "an unbounded number of".to_string()),
66                    limit
67                )
68            }
69        }
70    }
71}
72
73impl std::error::Error for FeatureCombinationError {}
74
75fn print_feature_combination_error(err: &FeatureCombinationError) {
76    let mut stderr = StandardStream::stderr(ColorChoice::Auto);
77
78    let _ = stderr.set_color(&RED);
79    let _ = write!(&mut stderr, "error");
80    let _ = stderr.reset();
81    let _ = writeln!(&mut stderr, ": feature matrix generation failed");
82
83    match err {
84        FeatureCombinationError::TooManyConfigurations {
85            package,
86            num_features,
87            num_configurations,
88            limit,
89        } => {
90            let _ = stderr.set_color(&YELLOW);
91            let _ = writeln!(&mut stderr, "  reason: too many configurations");
92            let _ = stderr.reset();
93
94            let _ = stderr.set_color(&CYAN);
95            let _ = write!(&mut stderr, "  package:");
96            let _ = stderr.reset();
97            let _ = writeln!(&mut stderr, " {package}");
98
99            let _ = stderr.set_color(&CYAN);
100            let _ = write!(&mut stderr, "  features considered:");
101            let _ = stderr.reset();
102            let _ = writeln!(&mut stderr, " {num_features}");
103
104            let _ = stderr.set_color(&CYAN);
105            let _ = write!(&mut stderr, "  combinations:");
106            let _ = stderr.reset();
107            let _ = writeln!(
108                &mut stderr,
109                " {}",
110                num_configurations
111                    .map(|v| v.to_string())
112                    .unwrap_or_else(|| "unbounded".to_string())
113            );
114
115            let _ = stderr.set_color(&CYAN);
116            let _ = write!(&mut stderr, "  limit:");
117            let _ = stderr.reset();
118            let _ = writeln!(&mut stderr, " {limit}");
119
120            let _ = stderr.set_color(&GREEN);
121            let _ = writeln!(&mut stderr, "  hint:");
122            let _ = stderr.reset();
123            let _ = writeln!(
124                &mut stderr,
125                "    Consider restricting the matrix using [package.metadata.cargo-feature-combinations].only_features"
126            );
127            let _ = writeln!(
128                &mut stderr,
129                "    or splitting features into isolated_feature_sets, or excluding features via exclude_features."
130            );
131        }
132    }
133}
134
135/// Summary of the outcome for running a cargo command on a single feature set.
136#[derive(Debug)]
137pub struct Summary {
138    package_name: String,
139    features: Vec<String>,
140    exit_code: Option<i32>,
141    pedantic_success: bool,
142    num_warnings: usize,
143    num_errors: usize,
144}
145
146/// High-level command requested by the user.
147#[derive(Debug)]
148pub enum Command {
149    /// Print a JSON feature matrix to stdout.
150    ///
151    /// The matrix is produced by combining [`Package::feature_matrix`] for all
152    /// selected packages into a single JSON array.
153    FeatureMatrix {
154        /// Whether to pretty-print the JSON feature matrix.
155        pretty: bool,
156    },
157    /// Print the tool version and exit.
158    Version,
159    /// Print help text and exit.
160    Help,
161}
162
163/// Command-line options recognized by this crate.
164///
165/// Instances of this type are produced by [`parse_arguments`] and consumed by
166/// [`run`] to drive command selection and filtering.
167#[derive(Debug, Default)]
168#[allow(clippy::struct_excessive_bools)]
169pub struct Options {
170    /// Optional path to the Cargo manifest that should be inspected.
171    pub manifest_path: Option<PathBuf>,
172    /// Explicit list of package names to include.
173    pub packages: HashSet<String>,
174    /// List of package names to exclude.
175    pub exclude_packages: HashSet<String>,
176    /// High-level command to execute.
177    pub command: Option<Command>,
178    /// Whether to restrict processing to packages with a library target.
179    pub only_packages_with_lib_target: bool,
180    /// Whether to hide cargo output and only show the final summary.
181    pub silent: bool,
182    /// Whether to print more verbose information such as the full cargo command.
183    pub verbose: bool,
184    /// Whether to treat warnings like errors for the summary and `--fail-fast`.
185    pub pedantic: bool,
186    /// Whether to silence warnings from rustc and only show errors.
187    pub errors_only: bool,
188    /// Whether to only list packages instead of all feature combinations.
189    pub packages_only: bool,
190    /// Whether to stop processing after the first failing feature combination.
191    pub fail_fast: bool,
192}
193
194/// Helper trait to provide simple argument parsing over `Vec<String>`.
195pub trait ArgumentParser {
196    /// Check whether an argument flag exists, either as a standalone flag or
197    /// in `--flag=value` form.
198    fn contains(&self, arg: &str) -> bool;
199    /// Extract all occurrences of an argument and their values.
200    ///
201    /// When `has_value` is `true`, this matches `--flag value` and
202    /// `--flag=value` forms and returns the value part. When `has_value` is
203    /// `false`, it matches bare flags like `--flag`.
204    fn get_all(&self, arg: &str, has_value: bool)
205    -> Vec<(std::ops::RangeInclusive<usize>, String)>;
206}
207
208impl ArgumentParser for Vec<String> {
209    fn contains(&self, arg: &str) -> bool {
210        self.iter()
211            .any(|a| a == arg || a.starts_with(&format!("{arg}=")))
212    }
213
214    fn get_all(
215        &self,
216        arg: &str,
217        has_value: bool,
218    ) -> Vec<(std::ops::RangeInclusive<usize>, String)> {
219        let mut matched = Vec::new();
220        for (idx, a) in self.iter().enumerate() {
221            match (a, self.get(idx + 1)) {
222                (key, Some(value)) if key == arg && has_value => {
223                    matched.push((idx..=idx + 1, value.clone()));
224                }
225                (key, _) if key == arg && !has_value => {
226                    matched.push((idx..=idx, key.clone()));
227                }
228                (key, _) if key.starts_with(&format!("{arg}=")) => {
229                    let value = key.trim_start_matches(&format!("{arg}="));
230                    matched.push((idx..=idx, value.to_string()));
231                }
232                _ => {}
233            }
234        }
235        matched.reverse();
236        matched
237    }
238}
239
240/// Abstraction over a Cargo workspace used by this crate.
241pub trait Workspace {
242    /// Return the workspace configuration section for feature combinations.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the workspace metadata configuration can not be
247    /// deserialized.
248    fn workspace_config(&self) -> eyre::Result<WorkspaceConfig>;
249
250    /// Return the packages that should be considered for feature combinations.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if per-package configuration can not be parsed.
255    fn packages_for_fc(&self) -> eyre::Result<Vec<&cargo_metadata::Package>>;
256}
257
258impl Workspace for cargo_metadata::Metadata {
259    fn workspace_config(&self) -> eyre::Result<WorkspaceConfig> {
260        let config: WorkspaceConfig = match self.workspace_metadata.get(METADATA_KEY) {
261            Some(config) => serde_json::from_value(config.clone())?,
262            None => WorkspaceConfig::default(),
263        };
264        Ok(config)
265    }
266
267    fn packages_for_fc(&self) -> eyre::Result<Vec<&cargo_metadata::Package>> {
268        let mut packages = self.workspace_packages();
269
270        let workspace_config = self.workspace_config()?;
271
272        // Determine the workspace root package (if any) and load its config so we can both
273        // apply filtering and emit deprecation warnings for legacy configuration.
274        let mut root_config: Option<Config> = None;
275        let mut root_id: Option<cargo_metadata::PackageId> = None;
276
277        if let Some(root_package) = self.root_package() {
278            let config = root_package.config()?;
279
280            if !config.exclude_packages.is_empty() {
281                eprintln!(
282                    "warning: [package.metadata.cargo-feature-combinations].exclude_packages in the workspace root package is deprecated; use [workspace.metadata.cargo-feature-combinations].exclude_packages instead",
283                );
284            }
285
286            root_id = Some(root_package.id.clone());
287            root_config = Some(config);
288        }
289
290        // For non-root workspace members, using exclude_packages is a no-op. Emit warnings for
291        // such configurations so users are aware that these fields are ignored.
292        if root_id.is_some() {
293            for package in &self.packages {
294                if Some(&package.id) == root_id.as_ref() {
295                    continue;
296                }
297
298                // [package.metadata.cargo-feature-combinations].exclude_packages
299                if let Some(raw) = package.metadata.get(METADATA_KEY)
300                    && let Ok(config) = serde_json::from_value::<Config>(raw.clone())
301                    && !config.exclude_packages.is_empty()
302                {
303                    eprintln!(
304                        "warning: [package.metadata.cargo-feature-combinations].exclude_packages in package `{}` has no effect; this field is only read from the workspace root Cargo.toml",
305                        package.name,
306                    );
307                }
308
309                // [workspace.metadata.cargo-feature-combinations].exclude_packages specified in
310                // non-root manifests is also a no-op. Detect the likely JSON shape produced by
311                // cargo metadata and warn if present.
312                if let Some(workspace) = package.metadata.get("workspace")
313                    && let Some(tool) = workspace.get(METADATA_KEY)
314                    && let Some(exclude_packages) = tool.get("exclude_packages")
315                {
316                    let has_values = match exclude_packages {
317                        serde_json::Value::Array(values) => !values.is_empty(),
318                        serde_json::Value::Null => false,
319                        _ => true,
320                    };
321
322                    if has_values {
323                        eprintln!(
324                            "warning: [workspace.metadata.cargo-feature-combinations].exclude_packages in package `{}` has no effect; workspace metadata is only read from the workspace root Cargo.toml",
325                            package.name,
326                        );
327                    }
328                }
329            }
330        }
331
332        // Filter packages based on workspace metadata configuration
333        packages.retain(|p| !workspace_config.exclude_packages.contains(p.name.as_str()));
334
335        if let Some(config) = root_config {
336            // Filter packages based on root package Cargo.toml configuration
337            packages.retain(|p| !config.exclude_packages.contains(p.name.as_str()));
338        }
339
340        Ok(packages)
341    }
342}
343
344/// Extension trait for [`cargo_metadata::Package`] used by this crate.
345pub trait Package {
346    /// Parse the configuration for this package if present.
347    ///
348    /// If the Cargo.toml manifest contains a configuration section,
349    /// the latter is parsed.
350    /// Otherwise, a default configuration is used.
351    ///
352    /// # Errors
353    ///
354    /// If the configuration in the manifest can not be parsed,
355    /// an error is returned.
356    ///
357    fn config(&self) -> eyre::Result<Config>;
358    /// Compute all feature combinations for this package based on the
359    /// provided [`Config`].
360    ///
361    /// # Errors
362    ///
363    /// Returns an error if feature combinations can not be computed, e.g. when
364    /// the package declares too many features.
365    fn feature_combinations<'a>(&'a self, config: &'a Config)
366    -> eyre::Result<Vec<Vec<&'a String>>>;
367    /// Convert [`Package::feature_combinations`] into a list of comma-separated
368    /// feature strings suitable for passing to `cargo --features`.
369    ///
370    /// # Errors
371    ///
372    /// Returns an error if [`Package::feature_combinations`] fails.
373    fn feature_matrix(&self, config: &Config) -> eyre::Result<Vec<String>>;
374}
375
376impl Package for cargo_metadata::Package {
377    fn config(&self) -> eyre::Result<Config> {
378        let mut config: Config = match self.metadata.get(METADATA_KEY) {
379            Some(config) => serde_json::from_value(config.clone())?,
380            None => Config::default(),
381        };
382
383        if !config.deprecated.skip_feature_sets.is_empty() {
384            eprintln!(
385                "warning: [package.metadata.cargo-feature-combinations].skip_feature_sets in package `{}` is deprecated; use exclude_feature_sets instead",
386                self.name,
387            );
388        }
389
390        if !config.deprecated.denylist.is_empty() {
391            eprintln!(
392                "warning: [package.metadata.cargo-feature-combinations].denylist in package `{}` is deprecated; use exclude_features instead",
393                self.name,
394            );
395        }
396
397        if !config.deprecated.exact_combinations.is_empty() {
398            eprintln!(
399                "warning: [package.metadata.cargo-feature-combinations].exact_combinations in package `{}` is deprecated; use include_feature_sets instead",
400                self.name,
401            );
402        }
403
404        // Handle deprecated config values
405        config
406            .exclude_feature_sets
407            .append(&mut config.deprecated.skip_feature_sets);
408        config
409            .exclude_features
410            .extend(config.deprecated.denylist.drain());
411        config
412            .include_feature_sets
413            .append(&mut config.deprecated.exact_combinations);
414
415        Ok(config)
416    }
417
418    fn feature_combinations<'a>(
419        &'a self,
420        config: &'a Config,
421    ) -> eyre::Result<Vec<Vec<&'a String>>> {
422        // Derive the effective exclude set for this package.
423        //
424        // When `skip_optional_dependencies` is enabled, extend the configured
425        // `exclude_features` with implicit features that correspond to optional
426        // dependencies for this package.
427        //
428        // This mirrors the behaviour in `cargo-all-features`: only the
429        // *implicit* features generated by Cargo for optional dependencies are
430        // skipped, i.e. features of the form
431        //
432        //   foo = ["dep:foo"]
433        //
434        // that are not also referenced via `dep:foo` in any other feature.
435        let mut effective_exclude_features = config.exclude_features.clone();
436
437        if config.skip_optional_dependencies {
438            use std::collections::HashSet;
439
440            let mut implicit_features: HashSet<String> = HashSet::new();
441            let mut optional_dep_used_with_dep_syntax_outside: HashSet<String> = HashSet::new();
442
443            // Classify implicit optional-dependency features and track optional
444            // dependencies that are referenced via `dep:NAME` in other
445            // features, following the logic from cargo-all-features'
446            // features_finder.rs.
447            for (feature_name, implied) in &self.features {
448                for value in implied.iter().filter(|v| v.starts_with("dep:")) {
449                    let dep_name = value.trim_start_matches("dep:");
450                    if implied.len() == 1 && dep_name == feature_name {
451                        // Feature of the shape `foo = ["dep:foo"]`.
452                        implicit_features.insert(feature_name.clone());
453                    } else {
454                        // The dep is used with `dep:` syntax in another
455                        // feature, so Cargo will not generate an implicit
456                        // feature for it.
457                        optional_dep_used_with_dep_syntax_outside.insert(dep_name.to_string());
458                    }
459                }
460            }
461
462            // If the dep is used with `dep:` syntax in another feature, it is
463            // not an implicit feature and should not be skipped purely because
464            // it is an optional dependency.
465            for dep_name in &optional_dep_used_with_dep_syntax_outside {
466                implicit_features.remove(dep_name);
467            }
468
469            // Extend the effective exclude list with the remaining implicit
470            // optional-dependency features.
471            effective_exclude_features.extend(implicit_features);
472        }
473
474        // Generate the base powerset from
475        // - all features
476        // - or from isolated sets, minus excluded features
477        let base_powerset = if config.isolated_feature_sets.is_empty() {
478            generate_global_base_powerset(
479                &self.name,
480                &self.features,
481                &effective_exclude_features,
482                &config.include_features,
483                &config.only_features,
484            )?
485        } else {
486            generate_isolated_base_powerset(
487                &self.name,
488                &self.features,
489                &config.isolated_feature_sets,
490                &effective_exclude_features,
491                &config.include_features,
492                &config.only_features,
493            )?
494        };
495
496        // Filter out feature sets that contain skip sets
497        let mut filtered_powerset = base_powerset
498            .into_iter()
499            .filter(|feature_set| {
500                !config.exclude_feature_sets.iter().any(|skip_set| {
501                    // Remove feature sets containing any of the skip sets
502                    skip_set
503                        .iter()
504                        // Skip set is contained when all its features are contained
505                        .all(|skip_feature| feature_set.contains(skip_feature))
506                })
507            })
508            .collect::<BTreeSet<_>>();
509
510        // Add back exact combinations
511        for proposed_exact_combination in &config.include_feature_sets {
512            // Remove non-existent features and switch reference to that pointing to `self`
513            let exact_combination = proposed_exact_combination
514                .iter()
515                .filter_map(|maybe_feature| {
516                    self.features.get_key_value(maybe_feature).map(|(k, _v)| k)
517                })
518                .collect::<BTreeSet<_>>();
519
520            // This exact combination may now be empty, but empty combination is always added anyway
521            filtered_powerset.insert(exact_combination);
522        }
523
524        // Re-collect everything into a vector of vectors
525        Ok(filtered_powerset
526            .into_iter()
527            .map(|set| set.into_iter().sorted().collect::<Vec<_>>())
528            .sorted()
529            .collect::<Vec<_>>())
530    }
531
532    fn feature_matrix(&self, config: &Config) -> eyre::Result<Vec<String>> {
533        Ok(self
534            .feature_combinations(config)?
535            .into_iter()
536            .map(|features| features.iter().join(","))
537            .collect())
538    }
539}
540
541fn checked_num_combinations(num_features: usize) -> Option<u128> {
542    if num_features >= u128::BITS as usize {
543        return None;
544    }
545    let shift: u32 = num_features.try_into().ok()?;
546    Some(1u128 << shift)
547}
548
549fn ensure_within_combination_limit(
550    package_name: &str,
551    num_features: usize,
552) -> Result<(), FeatureCombinationError> {
553    let num_configurations = checked_num_combinations(num_features);
554    let exceeds = match num_configurations {
555        Some(n) => n > MAX_FEATURE_COMBINATIONS,
556        None => true,
557    };
558
559    if exceeds {
560        return Err(FeatureCombinationError::TooManyConfigurations {
561            package: package_name.to_string(),
562            num_features,
563            num_configurations,
564            limit: MAX_FEATURE_COMBINATIONS,
565        });
566    }
567
568    Ok(())
569}
570
571/// Generates the **global** base [powerset](Itertools::powerset) of features.
572/// Global features are all features that are defined in the package, except the
573/// features from the provided denylist.
574///
575/// The returned powerset is a two-level [`BTreeSet`], with the strings pointing
576/// pack to the `package_features`.
577fn generate_global_base_powerset<'a>(
578    package_name: &str,
579    package_features: &'a BTreeMap<String, Vec<String>>,
580    exclude_features: &HashSet<String>,
581    include_features: &'a HashSet<String>,
582    only_features: &HashSet<String>,
583) -> Result<BTreeSet<BTreeSet<&'a String>>, FeatureCombinationError> {
584    let features = package_features
585        .keys()
586        .collect::<BTreeSet<_>>()
587        .into_iter()
588        .filter(|ft| !exclude_features.contains(*ft))
589        .filter(|ft| only_features.is_empty() || only_features.contains(*ft))
590        .collect::<BTreeSet<_>>();
591
592    ensure_within_combination_limit(package_name, features.len())?;
593
594    Ok(features
595        .into_iter()
596        .powerset()
597        .map(|combination| {
598            combination
599                .into_iter()
600                .chain(include_features)
601                .collect::<BTreeSet<&'a String>>()
602        })
603        .collect())
604}
605
606/// Generates the **isolated** base [powerset](Itertools::powerset) of features.
607/// Isolated features are features from the provided isolated feature sets,
608/// except non-existent features and except the features from the provided
609/// denylist.
610///
611/// The returned powerset is a two-level [`BTreeSet`], with the strings pointing
612/// pack to the `package_features`.
613fn generate_isolated_base_powerset<'a>(
614    package_name: &str,
615    package_features: &'a BTreeMap<String, Vec<String>>,
616    isolated_feature_sets: &[HashSet<String>],
617    exclude_features: &HashSet<String>,
618    include_features: &'a HashSet<String>,
619    only_features: &HashSet<String>,
620) -> Result<BTreeSet<BTreeSet<&'a String>>, FeatureCombinationError> {
621    // Collect known package features for easy querying
622    let known_features = package_features.keys().collect::<HashSet<_>>();
623
624    let mut worst_case_total: u128 = 0;
625    for isolated_feature_set in isolated_feature_sets {
626        let num_features = isolated_feature_set
627            .iter()
628            .filter(|ft| known_features.contains(*ft))
629            .filter(|ft| !exclude_features.contains(*ft))
630            .filter(|ft| only_features.is_empty() || only_features.contains(*ft))
631            .count();
632
633        let Some(n) = checked_num_combinations(num_features) else {
634            return Err(FeatureCombinationError::TooManyConfigurations {
635                package: package_name.to_string(),
636                num_features,
637                num_configurations: None,
638                limit: MAX_FEATURE_COMBINATIONS,
639            });
640        };
641
642        worst_case_total = worst_case_total.saturating_add(n);
643        if worst_case_total > MAX_FEATURE_COMBINATIONS {
644            return Err(FeatureCombinationError::TooManyConfigurations {
645                package: package_name.to_string(),
646                num_features,
647                num_configurations: Some(worst_case_total),
648                limit: MAX_FEATURE_COMBINATIONS,
649            });
650        }
651    }
652
653    Ok(isolated_feature_sets
654        .iter()
655        .flat_map(|isolated_feature_set| {
656            isolated_feature_set
657                .iter()
658                .filter(|ft| known_features.contains(*ft)) // remove non-existent features
659                .filter(|ft| !exclude_features.contains(*ft)) // remove features from denylist
660                .filter(|ft| only_features.is_empty() || only_features.contains(*ft))
661                .powerset()
662                .map(|combination| {
663                    combination
664                        .into_iter()
665                        .filter_map(|feature| known_features.get(feature).copied())
666                        .chain(include_features)
667                        .collect::<BTreeSet<_>>()
668                })
669        })
670        .collect())
671}
672
673/// Print a JSON feature matrix for the given packages to stdout.
674///
675/// The matrix is a JSON array of objects produced from each package's
676/// configuration and the feature combinations returned by
677/// [`Package::feature_matrix`].
678///
679/// # Errors
680///
681/// Returns an error if any configuration can not be parsed or serialization
682/// of the JSON matrix fails.
683pub fn print_feature_matrix(
684    packages: &[&cargo_metadata::Package],
685    pretty: bool,
686    packages_only: bool,
687) -> eyre::Result<()> {
688    let per_package_features = packages
689        .iter()
690        .map(|pkg| {
691            let config = pkg.config()?;
692            let features = if packages_only {
693                vec!["default".to_string()]
694            } else {
695                pkg.feature_matrix(&config)?
696            };
697            Ok::<_, eyre::Report>((pkg.name.clone(), config, features))
698        })
699        .collect::<Result<Vec<_>, _>>()?;
700
701    let matrix: Vec<serde_json::Value> = per_package_features
702        .into_iter()
703        .flat_map(|(name, config, features)| {
704            features.into_iter().map(move |ft| {
705                use serde_json_merge::{iter::dfs::Dfs, merge::Merge};
706
707                let mut out = serde_json::json!(config.matrix);
708                out.merge::<Dfs>(&serde_json::json!({
709                    "name": name,
710                    "features": ft,
711                }));
712                out
713            })
714        })
715        .collect();
716
717    let matrix = if pretty {
718        serde_json::to_string_pretty(&matrix)
719    } else {
720        serde_json::to_string(&matrix)
721    }?;
722    println!("{matrix}");
723    Ok(())
724}
725
726/// Build a [`ColorSpec`] with the given foreground color and bold setting.
727#[must_use]
728pub fn color_spec(color: Color, bold: bool) -> ColorSpec {
729    let mut spec = ColorSpec::new();
730    spec.set_fg(Some(color));
731    spec.set_bold(bold);
732    spec
733}
734
735/// Extract per-crate warning counts from cargo output.
736///
737/// The iterator yields the number of warnings for each compiled crate that
738/// matches the summary line produced by cargo.
739pub fn warning_counts(output: &str) -> impl Iterator<Item = usize> + '_ {
740    static WARNING_REGEX: LazyLock<Regex> =
741        LazyLock::new(|| {
742            #[allow(
743                clippy::expect_used,
744                reason = "hard-coded regex pattern is expected to be valid"
745            )]
746            Regex::new(r"warning: .* generated (\d+) warnings?")
747                .expect("valid warning regex")
748        });
749    WARNING_REGEX
750        .captures_iter(output)
751        .filter_map(|cap| cap.get(1))
752        .map(|m| m.as_str().parse::<usize>().unwrap_or(0))
753}
754
755/// Extract per-crate error counts from cargo output.
756///
757/// The iterator yields the number of errors for each compiled crate that
758/// matches the summary line produced by cargo.
759pub fn error_counts(output: &str) -> impl Iterator<Item = usize> + '_ {
760    static ERROR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
761        #[allow(
762            clippy::expect_used,
763            reason = "hard-coded regex pattern is expected to be valid"
764        )]
765        Regex::new(r"error: could not compile `.*` due to\s*(\d*)\s*previous errors?")
766            .expect("valid error regex")
767    });
768    ERROR_REGEX
769        .captures_iter(output)
770        .filter_map(|cap| cap.get(1))
771        .map(|m| m.as_str().parse::<usize>().unwrap_or(1))
772}
773
774/// Print an aggregated summary for all executed feature combinations.
775///
776/// This function is used by [`run_cargo_command`] after all packages and
777/// feature sets have been processed.
778pub fn print_summary(
779    summary: Vec<Summary>,
780    mut stdout: termcolor::StandardStream,
781    elapsed: Duration,
782) {
783    let num_packages = summary
784        .iter()
785        .map(|s| &s.package_name)
786        .collect::<HashSet<_>>()
787        .len();
788    let num_feature_sets = summary
789        .iter()
790        .map(|s| (&s.package_name, s.features.iter().collect::<Vec<_>>()))
791        .collect::<HashSet<_>>()
792        .len();
793
794    println!();
795    stdout.set_color(&CYAN).ok();
796    print!("    Finished ");
797    stdout.reset().ok();
798    println!(
799        "{num_feature_sets} total feature combination{} for {num_packages} package{} in {elapsed:?}",
800        if num_feature_sets > 1 { "s" } else { "" },
801        if num_packages > 1 { "s" } else { "" },
802    );
803    println!();
804
805    let mut first_bad_exit_code: Option<i32> = None;
806    let most_errors = summary.iter().map(|s| s.num_errors).max().unwrap_or(0);
807    let most_warnings = summary.iter().map(|s| s.num_warnings).max().unwrap_or(0);
808    let errors_width = most_errors.to_string().len();
809    let warnings_width = most_warnings.to_string().len();
810
811    for s in summary {
812        if !s.pedantic_success {
813            stdout.set_color(&RED).ok();
814            print!("        FAIL ");
815            if first_bad_exit_code.is_none() {
816                first_bad_exit_code = s.exit_code;
817            }
818        } else if s.num_warnings > 0 {
819            stdout.set_color(&YELLOW).ok();
820            print!("        WARN ");
821        } else {
822            stdout.set_color(&GREEN).ok();
823            print!("        PASS ");
824        }
825        stdout.reset().ok();
826        println!(
827            "{} ( {:ew$} errors, {:ww$} warnings, features = [{}] )",
828            s.package_name,
829            s.num_errors.to_string(),
830            s.num_warnings.to_string(),
831            s.features.iter().join(", "),
832            ew = errors_width,
833            ww = warnings_width,
834        );
835    }
836    println!();
837
838    if let Some(exit_code) = first_bad_exit_code {
839        std::process::exit(exit_code);
840    }
841}
842
843fn print_package_cmd(
844    package: &cargo_metadata::Package,
845    features: &[&String],
846    cargo_args: &[&str],
847    all_args: &[&str],
848    options: &Options,
849    stdout: &mut StandardStream,
850) {
851    if !options.silent {
852        println!();
853    }
854    stdout.set_color(&CYAN).ok();
855    match cargo_subcommand(cargo_args) {
856        CargoSubcommand::Test => {
857            print!("     Testing ");
858        }
859        CargoSubcommand::Doc => {
860            print!("     Documenting ");
861        }
862        CargoSubcommand::Check => {
863            print!("     Checking ");
864        }
865        CargoSubcommand::Run => {
866            print!("     Running ");
867        }
868        CargoSubcommand::Build => {
869            print!("     Building ");
870        }
871        CargoSubcommand::Other => {
872            print!("     ");
873        }
874    }
875    stdout.reset().ok();
876    print!(
877        "{} ( features = [{}] )",
878        package.name,
879        features.as_ref().iter().join(", ")
880    );
881    if options.verbose {
882        print!(" [cargo {}]", all_args.join(" "));
883    }
884    println!();
885    if !options.silent {
886        println!();
887    }
888}
889
890/// Run a cargo command for all requested packages and feature combinations.
891///
892/// This function drives the main execution loop by spawning cargo for each
893/// feature set and collecting a [`Summary`] for every run.
894///
895/// # Errors
896///
897/// Returns an error if a cargo process can not be spawned or if IO operations
898/// fail while reading cargo's output.
899pub fn run_cargo_command(
900    packages: &[&cargo_metadata::Package],
901    mut cargo_args: Vec<&str>,
902    options: &Options,
903) -> eyre::Result<()> {
904    let start = Instant::now();
905
906    // split into cargo and extra arguments after --
907    let extra_args_idx = cargo_args
908        .iter()
909        .position(|arg| *arg == "--")
910        .unwrap_or(cargo_args.len());
911    let extra_args = cargo_args.split_off(extra_args_idx);
912
913    let missing_arguments = cargo_args.is_empty() && extra_args.is_empty();
914
915    if !cargo_args.contains(&"--color") {
916        // force colored output
917        cargo_args.extend(["--color", "always"]);
918    }
919
920    let mut stdout = StandardStream::stdout(ColorChoice::Auto);
921    let mut summary: Vec<Summary> = Vec::new();
922
923    for package in packages {
924        let config = package.config()?;
925
926        for features in package.feature_combinations(&config)? {
927            // We set the command working dir to the package manifest parent dir.
928            // This works well for now, but one could also consider `--manifest-path` or `-p`
929            let Some(working_dir) = package.manifest_path.parent() else {
930                eyre::bail!(
931                    "could not find parent dir of package {}",
932                    package.manifest_path.to_string()
933                )
934            };
935
936            let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
937            let mut cmd = process::Command::new(&cargo);
938
939            if options.errors_only {
940                cmd.env(
941                    "RUSTFLAGS",
942                    format!(
943                        "-Awarnings {}", // allows all warnings
944                        std::env::var("RUSTFLAGS").unwrap_or_default()
945                    ),
946                );
947            }
948
949            let mut args = cargo_args.clone();
950            let features_flag = format!("--features={}", &features.iter().join(","));
951            if !missing_arguments {
952                args.push("--no-default-features");
953                args.push(&features_flag);
954            }
955            args.extend(extra_args.clone());
956            print_package_cmd(package, &features, &cargo_args, &args, options, &mut stdout);
957
958            cmd.args(args)
959                .current_dir(working_dir)
960                .stderr(process::Stdio::piped());
961            let mut process = cmd.spawn()?;
962
963            // build an output writer buffer
964            let output_buffer = Vec::<u8>::new();
965            let mut colored_output = io::Cursor::new(output_buffer);
966
967            {
968                // tee write to buffer and stdout
969                if let Some(proc_stderr) = process.stderr.take() {
970                    let mut proc_reader = io::BufReader::new(proc_stderr);
971                    if options.silent {
972                        io::copy(&mut proc_reader, &mut colored_output)?;
973                    } else {
974                        let mut tee_reader =
975                            crate::tee::Reader::new(proc_reader, &mut stdout, true);
976                        io::copy(&mut tee_reader, &mut colored_output)?;
977                    }
978                } else {
979                    eprintln!("ERROR: failed to redirect stderr");
980                }
981            }
982
983            let exit_status = process.wait()?;
984            let output = strip_ansi_escapes::strip(colored_output.get_ref());
985            let output = String::from_utf8_lossy(&output);
986
987            let num_warnings = warning_counts(&output).sum::<usize>();
988            let num_errors = error_counts(&output).sum::<usize>();
989            let has_errors = num_errors > 0;
990            let has_warnings = num_warnings > 0;
991
992            let fail = !exit_status.success();
993
994            let pedantic_fail = options.pedantic && (has_errors || has_warnings);
995            let pedantic_success = !(fail || pedantic_fail);
996
997            summary.push(Summary {
998                features: features.into_iter().cloned().collect(),
999                num_errors,
1000                num_warnings,
1001                package_name: package.name.to_string(),
1002                exit_code: exit_status.code(),
1003                pedantic_success,
1004            });
1005
1006            if options.fail_fast && !pedantic_success {
1007                if options.silent {
1008                    io::copy(
1009                        &mut io::Cursor::new(colored_output.into_inner()),
1010                        &mut stdout,
1011                    )?;
1012                    stdout.flush().ok();
1013                }
1014                print_summary(summary, stdout, start.elapsed());
1015                std::process::exit(exit_status.code().unwrap_or(1));
1016            }
1017        }
1018    }
1019
1020    print_summary(summary, stdout, start.elapsed());
1021    Ok(())
1022}
1023
1024fn print_help() {
1025    let help = r#"Run cargo commands for all feature combinations
1026
1027USAGE:
1028    cargo [+toolchain] [SUBCOMMAND] [SUBCOMMAND_OPTIONS]
1029    cargo [+toolchain] [OPTIONS] [CARGO_OPTIONS] [CARGO_SUBCOMMAND]
1030
1031SUBCOMMAND:
1032    matrix                  Print JSON feature combination matrix to stdout
1033        --pretty            Print pretty JSON
1034
1035OPTIONS:
1036    --help                  Print help information
1037    --silent                Hide cargo output and only show summary
1038    --fail-fast             Fail fast on the first bad feature combination
1039    --errors-only           Allow all warnings, show errors only (-Awarnings)
1040    --exclude-package       Exclude a package from feature combinations 
1041    --only-packages-with-lib-target
1042                            Only consider packages with a library target
1043    --pedantic              Treat warnings like errors in summary and
1044                            when using --fail-fast
1045
1046Feature sets can be configured in your Cargo.toml configuration.
1047For example:
1048
1049```toml
1050[package.metadata.cargo-feature-combinations]
1051# When at least one isolated feature set is configured, stop taking all project
1052# features as a whole, and instead take them in these isolated sets. Build a
1053# sub-matrix for each isolated set, then merge sub-matrices into the overall
1054# feature matrix. If any two isolated sets produce an identical feature
1055# combination, such combination will be included in the overall matrix only once.
1056#
1057# This feature is intended for projects with large number of features, sub-sets
1058# of which are completely independent, and thus don’t need cross-play.
1059#
1060# Other configuration options are still respected.
1061isolated_feature_sets = [
1062    ["foo-a", "foo-b", "foo-c"],
1063    ["bar-a", "bar-b"],
1064    ["other-a", "other-b", "other-c"],
1065]
1066
1067# Exclude groupings of features that are incompatible or do not make sense
1068exclude_feature_sets = [ ["foo", "bar"], ] # formerly "skip_feature_sets"
1069
1070# Exclude features from the feature combination matrix
1071exclude_features = ["default", "full"] # formerly "denylist"
1072
1073# Include features in the feature combination matrix
1074#
1075# These features will be added to every generated feature combination.
1076# This does not restrict which features are varied for the combinatorial
1077# matrix. To restrict the matrix to a specific allowlist of features, use
1078# `only_features`.
1079include_features = ["feature-that-must-always-be-set"]
1080
1081# Only consider these features when generating the combinatorial matrix.
1082#
1083# When set, features not listed here are ignored for the combinatorial matrix.
1084# When empty, all package features are considered.
1085only_features = ["default", "full"]
1086
1087# Skip implicit features that correspond to optional dependencies from the
1088# matrix.
1089#
1090# When enabled, the implicit features that Cargo generates for optional
1091# dependencies (of the form `foo = ["dep:foo"]` in the feature graph) are
1092# removed from the combinatorial matrix. This mirrors the behaviour of the
1093# `skip_optional_dependencies` flag in the `cargo-all-features` crate.
1094skip_optional_dependencies = true
1095
1096# In the end, always add these exact combinations to the overall feature matrix, 
1097# unless one is already present there.
1098#
1099# Non-existent features are ignored. Other configuration options are ignored.
1100include_feature_sets = [
1101    ["foo-a", "bar-a", "other-a"],
1102] # formerly "exact_combinations"
1103```
1104
1105When using a cargo workspace, you can also exclude packages in your workspace `Cargo.toml`:
1106
1107```toml
1108[workspace.metadata.cargo-feature-combinations]
1109# Exclude packages in the workspace metadata, or the metadata of the *root* package.
1110exclude_packages = ["package-a", "package-b"]
1111```
1112
1113For more information, see 'https://github.com/romnn/cargo-feature-combinations'.
1114
1115See 'cargo help <command>' for more information on a specific command.
1116    "#;
1117    println!("{help}");
1118}
1119
1120static VALID_BOOLS: [&str; 4] = ["yes", "true", "y", "t"];
1121
1122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1123enum CargoSubcommand {
1124    Build,
1125    Check,
1126    Test,
1127    Doc,
1128    Run,
1129    Other,
1130}
1131
1132/// Determine the cargo subcommand implied by the argument list.
1133fn cargo_subcommand(args: &[impl AsRef<str>]) -> CargoSubcommand {
1134    let args: HashSet<&str> = args.iter().map(AsRef::as_ref).collect();
1135    if args.contains("build") || args.contains("b") {
1136        CargoSubcommand::Build
1137    } else if args.contains("check") || args.contains("c") || args.contains("clippy") {
1138        CargoSubcommand::Check
1139    } else if args.contains("test") || args.contains("t") {
1140        CargoSubcommand::Test
1141    } else if args.contains("doc") || args.contains("d") {
1142        CargoSubcommand::Doc
1143    } else if args.contains("run") || args.contains("r") {
1144        CargoSubcommand::Run
1145    } else {
1146        CargoSubcommand::Other
1147    }
1148}
1149
1150/// Parse command-line arguments for the `cargo-*` binary.
1151///
1152/// The returned [`Options`] drives workspace discovery and filtering, while
1153/// the remaining `Vec<String>` contains the raw cargo arguments.
1154///
1155/// # Errors
1156///
1157/// Returns an error if the manifest path passed via `--manifest-path` does
1158/// not exist or can not be canonicalized.
1159pub fn parse_arguments(bin_name: &str) -> eyre::Result<(Options, Vec<String>)> {
1160    let mut args: Vec<String> = std::env::args_os()
1161        // Skip executable name
1162        .skip(1)
1163        // Skip our own cargo-* command name
1164        .skip_while(|arg| {
1165            let arg = arg.as_os_str();
1166            arg == bin_name || arg == "cargo"
1167        })
1168        .map(|s| s.to_string_lossy().to_string())
1169        .collect();
1170
1171    let mut options = Options {
1172        verbose: VALID_BOOLS.contains(
1173            &std::env::var("VERBOSE")
1174                .unwrap_or_default()
1175                .to_lowercase()
1176                .as_str(),
1177        ),
1178        ..Options::default()
1179    };
1180
1181    // Extract path to manifest to operate on
1182    for (span, manifest_path) in args.get_all("--manifest-path", true) {
1183        let manifest_path = PathBuf::from(manifest_path);
1184        let manifest_path = manifest_path
1185            .canonicalize()
1186            .wrap_err_with(|| format!("manifest {} does not exist", manifest_path.display()))?;
1187        options.manifest_path = Some(manifest_path);
1188        args.drain(span);
1189    }
1190
1191    // Extract packages to operate on
1192    for flag in ["--package", "-p"] {
1193        for (span, package) in args.get_all(flag, true) {
1194            options.packages.insert(package);
1195            args.drain(span);
1196        }
1197    }
1198
1199    for (span, package) in args.get_all("--exclude-package", true) {
1200        options.exclude_packages.insert(package.trim().to_string());
1201        args.drain(span);
1202    }
1203
1204    for (span, _) in args.get_all("--only-packages-with-lib-target", false) {
1205        options.only_packages_with_lib_target = true;
1206        args.drain(span);
1207    }
1208
1209    // Check for matrix command
1210    for (span, _) in args.get_all("matrix", false) {
1211        options.command = Some(Command::FeatureMatrix { pretty: false });
1212        args.drain(span);
1213    }
1214    // Check for pretty matrix option
1215    for (span, _) in args.get_all("--pretty", false) {
1216        if let Some(Command::FeatureMatrix { ref mut pretty }) = options.command {
1217            *pretty = true;
1218        }
1219        args.drain(span);
1220    }
1221
1222    // Check for help command
1223    for (span, _) in args.get_all("--help", false) {
1224        options.command = Some(Command::Help);
1225        args.drain(span);
1226    }
1227
1228    // Check for version flag
1229    for (span, _) in args.get_all("--version", false) {
1230        options.command = Some(Command::Version);
1231        args.drain(span);
1232    }
1233
1234    // Check for version command
1235    for (span, _) in args.get_all("version", false) {
1236        options.command = Some(Command::Version);
1237        args.drain(span);
1238    }
1239
1240    // Check for pedantic flag
1241    for (span, _) in args.get_all("--pedantic", false) {
1242        options.pedantic = true;
1243        args.drain(span);
1244    }
1245
1246    // Check for errors only
1247    for (span, _) in args.get_all("--errors-only", false) {
1248        options.errors_only = true;
1249        args.drain(span);
1250    }
1251
1252    // Packages only
1253    for (span, _) in args.get_all("--packages-only", false) {
1254        options.packages_only = true;
1255        args.drain(span);
1256    }
1257
1258    // Check for silent flag
1259    for (span, _) in args.get_all("--silent", false) {
1260        options.silent = true;
1261        args.drain(span);
1262    }
1263
1264    // Check for fail fast flag
1265    for (span, _) in args.get_all("--fail-fast", false) {
1266        options.fail_fast = true;
1267        args.drain(span);
1268    }
1269
1270    // Ignore `--workspace`. This tool already discovers the relevant workspace
1271    // packages via `cargo metadata` and then runs cargo separately in each
1272    // package's directory. Forwarding `--workspace` to those per-package
1273    // invocations would re-enable workspace-level feature application and can
1274    // cause spurious errors when some workspace members do not define a
1275    // particular feature.
1276    for (span, _) in args.get_all("--workspace", false) {
1277        args.drain(span);
1278    }
1279
1280    Ok((options, args))
1281}
1282
1283/// Run the cargo subcommand for all relevant feature combinations.
1284///
1285/// This is the main entry point used by the binaries in this crate.
1286///
1287/// # Errors
1288///
1289/// Returns an error if argument parsing fails or `cargo metadata` can not be
1290/// executed successfully.
1291pub fn run(bin_name: &str) -> eyre::Result<()> {
1292    color_eyre::install()?;
1293
1294    let (options, cargo_args) = parse_arguments(bin_name)?;
1295
1296    if let Some(Command::Help) = options.command {
1297        print_help();
1298        return Ok(());
1299    }
1300
1301    if let Some(Command::Version) = options.command {
1302        println!("cargo-{bin_name} v{}", env!("CARGO_PKG_VERSION"));
1303        return Ok(());
1304    }
1305
1306    // Get metadata for cargo package
1307    let mut cmd = cargo_metadata::MetadataCommand::new();
1308    if let Some(ref manifest_path) = options.manifest_path {
1309        cmd.manifest_path(manifest_path);
1310    }
1311    let metadata = cmd.exec()?;
1312    let mut packages = metadata.packages_for_fc()?;
1313
1314    // When `--manifest-path` points to a workspace member, `cargo metadata`
1315    // still returns the entire workspace. Unless the user explicitly selected
1316    // packages via `-p/--package`, default to only processing the root package
1317    // resolved by Cargo for the given manifest.
1318    if options.manifest_path.is_some()
1319        && options.packages.is_empty()
1320        && let Some(root) = metadata.root_package()
1321    {
1322        packages.retain(|p| p.id == root.id);
1323    }
1324
1325    // Filter excluded packages via CLI arguments
1326    packages.retain(|p| !options.exclude_packages.contains(p.name.as_str()));
1327
1328    if options.only_packages_with_lib_target {
1329        // Filter only packages with a library target
1330        packages.retain(|p| {
1331            p.targets
1332                .iter()
1333                .any(|t| t.kind.contains(&cargo_metadata::TargetKind::Lib))
1334        });
1335    }
1336
1337    // Filter packages based on CLI options
1338    if !options.packages.is_empty() {
1339        packages.retain(|p| options.packages.contains(p.name.as_str()));
1340    }
1341
1342    let cargo_args: Vec<&str> = cargo_args.iter().map(String::as_str).collect();
1343    match options.command {
1344        Some(Command::Help | Command::Version) => Ok(()),
1345        Some(Command::FeatureMatrix { pretty }) => {
1346            match print_feature_matrix(&packages, pretty, options.packages_only) {
1347                Ok(()) => Ok(()),
1348                Err(err) => {
1349                    if let Some(e) = err.downcast_ref::<FeatureCombinationError>() {
1350                        print_feature_combination_error(e);
1351                        process::exit(2);
1352                    }
1353                    Err(err)
1354                }
1355            }
1356        }
1357        None => {
1358            if cargo_subcommand(cargo_args.as_slice()) == CargoSubcommand::Other {
1359                eprintln!(
1360                    "warning: `cargo {bin_name}` only supports cargo's `build`, `test`, `run`, `check`, `doc`, and `clippy` subcommands",
1361                );
1362            }
1363            match run_cargo_command(&packages, cargo_args, &options) {
1364                Ok(()) => Ok(()),
1365                Err(err) => {
1366                    if let Some(e) = err.downcast_ref::<FeatureCombinationError>() {
1367                        print_feature_combination_error(e);
1368                        process::exit(2);
1369                    }
1370                    Err(err)
1371                }
1372            }
1373        }
1374    }
1375}
1376
1377#[cfg(test)]
1378mod test {
1379    use super::{Config, Package, Workspace, error_counts, warning_counts};
1380    use color_eyre::eyre;
1381    use serde_json::json;
1382    use similar_asserts::assert_eq as sim_assert_eq;
1383    use std::collections::HashSet;
1384
1385    static INIT: std::sync::Once = std::sync::Once::new();
1386
1387    /// Initialize test
1388    ///
1389    /// This ensures `color_eyre` is setup once.
1390    pub(crate) fn init() {
1391        INIT.call_once(|| {
1392            color_eyre::install().ok();
1393        });
1394    }
1395
1396    #[test]
1397    fn error_regex_single_mod_multiple_errors() {
1398        let stderr = include_str!("../test-data/single_mod_multiple_errors_stderr.txt");
1399        let errors: Vec<_> = error_counts(stderr).collect();
1400        sim_assert_eq!(&errors, &vec![2]);
1401    }
1402
1403    #[test]
1404    fn warning_regex_two_mod_multiple_warnings() {
1405        let stderr = include_str!("../test-data/two_mods_warnings_stderr.txt");
1406        let warnings: Vec<_> = warning_counts(stderr).collect();
1407        sim_assert_eq!(&warnings, &vec![6, 7]);
1408    }
1409
1410    #[test]
1411    fn combinations() -> eyre::Result<()> {
1412        init();
1413        let package = package_with_features(&["foo-c", "foo-a", "foo-b"])?;
1414        let config = Config::default();
1415        let want = vec![
1416            vec![],
1417            vec!["foo-a"],
1418            vec!["foo-a", "foo-b"],
1419            vec!["foo-a", "foo-b", "foo-c"],
1420            vec!["foo-a", "foo-c"],
1421            vec!["foo-b"],
1422            vec!["foo-b", "foo-c"],
1423            vec!["foo-c"],
1424        ];
1425        let have = package.feature_combinations(&config)?;
1426
1427        sim_assert_eq!(have: have, want: want);
1428        Ok(())
1429    }
1430
1431    #[test]
1432    fn combinations_only_features() -> eyre::Result<()> {
1433        init();
1434        let package = package_with_features(&["foo", "bar", "baz"])?;
1435        let config = Config {
1436            exclude_features: HashSet::from(["default".to_string()]),
1437            only_features: HashSet::from(["foo".to_string(), "bar".to_string()]),
1438            ..Default::default()
1439        };
1440
1441        let want = vec![vec![], vec!["bar"], vec!["bar", "foo"], vec!["foo"]];
1442        let have = package.feature_combinations(&config)?;
1443
1444        sim_assert_eq!(have: have, want: want);
1445        Ok(())
1446    }
1447
1448    #[test]
1449    fn combinations_isolated() -> eyre::Result<()> {
1450        init();
1451        let package =
1452            package_with_features(&["foo-a", "foo-b", "bar-b", "bar-a", "car-b", "car-a"])?;
1453        let config = Config {
1454            isolated_feature_sets: vec![
1455                HashSet::from(["foo-a".to_string(), "foo-b".to_string()]),
1456                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
1457            ],
1458            ..Default::default()
1459        };
1460        let want = vec![
1461            vec![],
1462            vec!["bar-a"],
1463            vec!["bar-a", "bar-b"],
1464            vec!["bar-b"],
1465            vec!["foo-a"],
1466            vec!["foo-a", "foo-b"],
1467            vec!["foo-b"],
1468        ];
1469        let have = package.feature_combinations(&config)?;
1470
1471        sim_assert_eq!(have: have, want: want);
1472        Ok(())
1473    }
1474
1475    #[test]
1476    fn combinations_isolated_non_existent() -> eyre::Result<()> {
1477        init();
1478        let package =
1479            package_with_features(&["foo-a", "foo-b", "bar-a", "bar-b", "car-a", "car-b"])?;
1480        let config = Config {
1481            isolated_feature_sets: vec![
1482                HashSet::from(["foo-a".to_string(), "non-existent".to_string()]),
1483                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
1484            ],
1485            ..Default::default()
1486        };
1487        let want = vec![
1488            vec![],
1489            vec!["bar-a"],
1490            vec!["bar-a", "bar-b"],
1491            vec!["bar-b"],
1492            vec!["foo-a"],
1493        ];
1494        let have = package.feature_combinations(&config)?;
1495
1496        sim_assert_eq!(have: have, want: want);
1497        Ok(())
1498    }
1499
1500    #[test]
1501    fn combinations_isolated_denylist() -> eyre::Result<()> {
1502        init();
1503        let package =
1504            package_with_features(&["foo-a", "foo-b", "bar-b", "bar-a", "car-a", "car-b"])?;
1505        let config = Config {
1506            isolated_feature_sets: vec![
1507                HashSet::from(["foo-a".to_string(), "foo-b".to_string()]),
1508                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
1509            ],
1510            exclude_features: HashSet::from(["bar-a".to_string()]),
1511            ..Default::default()
1512        };
1513        let want = vec![
1514            vec![],
1515            vec!["bar-b"],
1516            vec!["foo-a"],
1517            vec!["foo-a", "foo-b"],
1518            vec!["foo-b"],
1519        ];
1520        let have = package.feature_combinations(&config)?;
1521
1522        sim_assert_eq!(have: have, want: want);
1523        Ok(())
1524    }
1525
1526    #[test]
1527    fn combinations_isolated_non_existent_denylist() -> eyre::Result<()> {
1528        init();
1529        let package =
1530            package_with_features(&["foo-b", "foo-a", "bar-a", "bar-b", "car-a", "car-b"])?;
1531        let config = Config {
1532            isolated_feature_sets: vec![
1533                HashSet::from(["foo-a".to_string(), "non-existent".to_string()]),
1534                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
1535            ],
1536            exclude_features: HashSet::from(["bar-a".to_string()]),
1537            ..Default::default()
1538        };
1539        let want = vec![vec![], vec!["bar-b"], vec!["foo-a"]];
1540        let have = package.feature_combinations(&config)?;
1541
1542        sim_assert_eq!(have: have, want: want);
1543        Ok(())
1544    }
1545
1546    #[test]
1547    fn combinations_isolated_non_existent_denylist_exact() -> eyre::Result<()> {
1548        init();
1549        let package =
1550            package_with_features(&["foo-a", "foo-b", "bar-a", "bar-b", "car-a", "car-b"])?;
1551        let config = Config {
1552            isolated_feature_sets: vec![
1553                HashSet::from(["foo-a".to_string(), "non-existent".to_string()]),
1554                HashSet::from(["bar-a".to_string(), "bar-b".to_string()]),
1555            ],
1556            exclude_features: HashSet::from(["bar-a".to_string()]),
1557            include_feature_sets: vec![HashSet::from([
1558                "car-a".to_string(),
1559                "bar-a".to_string(),
1560                "non-existent".to_string(),
1561            ])],
1562            ..Default::default()
1563        };
1564        let want = vec![vec![], vec!["bar-a", "car-a"], vec!["bar-b"], vec!["foo-a"]];
1565        let have = package.feature_combinations(&config)?;
1566
1567        sim_assert_eq!(have: have, want: want);
1568        Ok(())
1569    }
1570
1571    #[test]
1572    fn workspace_with_package() -> eyre::Result<()> {
1573        init();
1574
1575        let package = package_with_features(&[])?;
1576        let metadata = workspace_builder()
1577            .packages(vec![package.clone()])
1578            .workspace_members(vec![package.id.clone()])
1579            .build()?;
1580
1581        let have = metadata.packages_for_fc()?;
1582        sim_assert_eq!(have: have, want: vec![&package]);
1583        Ok(())
1584    }
1585
1586    #[test]
1587    fn workspace_with_excluded_package() -> eyre::Result<()> {
1588        init();
1589
1590        let package = package_with_features(&[])?;
1591        let metadata = workspace_builder()
1592            .packages(vec![package.clone()])
1593            .workspace_members(vec![package.id.clone()])
1594            .workspace_metadata(json!({
1595                "cargo-feature-combinations": {
1596                    "exclude_packages": [package.name]
1597                }
1598            }))
1599            .build()?;
1600
1601        let have = metadata.packages_for_fc()?;
1602        assert!(have.is_empty(), "expected no packages after exclusion");
1603        Ok(())
1604    }
1605
1606    fn package_with_features(features: &[&str]) -> eyre::Result<cargo_metadata::Package> {
1607        use cargo_metadata::{PackageBuilder, PackageId, PackageName};
1608        use semver::Version;
1609        use std::str::FromStr as _;
1610
1611        let mut package = PackageBuilder::new(
1612            PackageName::from_str("test")?,
1613            Version::parse("0.1.0")?,
1614            PackageId {
1615                repr: "test".to_string(),
1616            },
1617            "",
1618        )
1619        .build()?;
1620        package.features = features
1621            .iter()
1622            .map(|feature| ((*feature).to_string(), vec![]))
1623            .collect();
1624        Ok(package)
1625    }
1626
1627    fn workspace_builder() -> cargo_metadata::MetadataBuilder {
1628        use cargo_metadata::{MetadataBuilder, WorkspaceDefaultMembers};
1629
1630        MetadataBuilder::default()
1631            .version(1u8)
1632            .workspace_default_members(WorkspaceDefaultMembers::default())
1633            .resolve(None)
1634            .workspace_root("")
1635            .workspace_metadata(json!({}))
1636            .build_directory(None)
1637            .target_directory("")
1638    }
1639}