cargo_feature_combinations/
lib.rs

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