1mod 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#[derive(Debug)]
34pub enum FeatureCombinationError {
35 TooManyConfigurations {
38 package: String,
40 num_features: usize,
42 num_configurations: Option<u128>,
44 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#[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#[derive(Debug)]
148pub enum Command {
149 FeatureMatrix {
154 pretty: bool,
156 },
157 Version,
159 Help,
161}
162
163#[derive(Debug, Default)]
168#[allow(clippy::struct_excessive_bools)]
169pub struct Options {
170 pub manifest_path: Option<PathBuf>,
172 pub packages: HashSet<String>,
174 pub exclude_packages: HashSet<String>,
176 pub command: Option<Command>,
178 pub only_packages_with_lib_target: bool,
180 pub silent: bool,
182 pub verbose: bool,
184 pub pedantic: bool,
186 pub errors_only: bool,
188 pub packages_only: bool,
190 pub fail_fast: bool,
192}
193
194pub trait ArgumentParser {
196 fn contains(&self, arg: &str) -> bool;
199 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
240pub trait Workspace {
242 fn workspace_config(&self) -> eyre::Result<WorkspaceConfig>;
249
250 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 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 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 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 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 packages.retain(|p| !workspace_config.exclude_packages.contains(p.name.as_str()));
334
335 if let Some(config) = root_config {
336 packages.retain(|p| !config.exclude_packages.contains(p.name.as_str()));
338 }
339
340 Ok(packages)
341 }
342}
343
344pub trait Package {
346 fn config(&self) -> eyre::Result<Config>;
358 fn feature_combinations<'a>(&'a self, config: &'a Config)
366 -> eyre::Result<Vec<Vec<&'a String>>>;
367 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 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 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 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 implicit_features.insert(feature_name.clone());
453 } else {
454 optional_dep_used_with_dep_syntax_outside.insert(dep_name.to_string());
458 }
459 }
460 }
461
462 for dep_name in &optional_dep_used_with_dep_syntax_outside {
466 implicit_features.remove(dep_name);
467 }
468
469 effective_exclude_features.extend(implicit_features);
472 }
473
474 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 let mut filtered_powerset = base_powerset
498 .into_iter()
499 .filter(|feature_set| {
500 !config.exclude_feature_sets.iter().any(|skip_set| {
501 skip_set
503 .iter()
504 .all(|skip_feature| feature_set.contains(skip_feature))
506 })
507 })
508 .collect::<BTreeSet<_>>();
509
510 for proposed_exact_combination in &config.include_feature_sets {
512 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 filtered_powerset.insert(exact_combination);
522 }
523
524 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
571fn 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
606fn 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 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)) .filter(|ft| !exclude_features.contains(*ft)) .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
673pub 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#[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
735pub 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
755pub 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
774pub 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
890pub 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 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 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 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 {}", 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 let output_buffer = Vec::<u8>::new();
965 let mut colored_output = io::Cursor::new(output_buffer);
966
967 {
968 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
1132fn 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
1150pub fn parse_arguments(bin_name: &str) -> eyre::Result<(Options, Vec<String>)> {
1160 let mut args: Vec<String> = std::env::args_os()
1161 .skip(1)
1163 .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 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 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 for (span, _) in args.get_all("matrix", false) {
1211 options.command = Some(Command::FeatureMatrix { pretty: false });
1212 args.drain(span);
1213 }
1214 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 for (span, _) in args.get_all("--help", false) {
1224 options.command = Some(Command::Help);
1225 args.drain(span);
1226 }
1227
1228 for (span, _) in args.get_all("--version", false) {
1230 options.command = Some(Command::Version);
1231 args.drain(span);
1232 }
1233
1234 for (span, _) in args.get_all("version", false) {
1236 options.command = Some(Command::Version);
1237 args.drain(span);
1238 }
1239
1240 for (span, _) in args.get_all("--pedantic", false) {
1242 options.pedantic = true;
1243 args.drain(span);
1244 }
1245
1246 for (span, _) in args.get_all("--errors-only", false) {
1248 options.errors_only = true;
1249 args.drain(span);
1250 }
1251
1252 for (span, _) in args.get_all("--packages-only", false) {
1254 options.packages_only = true;
1255 args.drain(span);
1256 }
1257
1258 for (span, _) in args.get_all("--silent", false) {
1260 options.silent = true;
1261 args.drain(span);
1262 }
1263
1264 for (span, _) in args.get_all("--fail-fast", false) {
1266 options.fail_fast = true;
1267 args.drain(span);
1268 }
1269
1270 for (span, _) in args.get_all("--workspace", false) {
1277 args.drain(span);
1278 }
1279
1280 Ok((options, args))
1281}
1282
1283pub 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 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 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 packages.retain(|p| !options.exclude_packages.contains(p.name.as_str()));
1327
1328 if options.only_packages_with_lib_target {
1329 packages.retain(|p| {
1331 p.targets
1332 .iter()
1333 .any(|t| t.kind.contains(&cargo_metadata::TargetKind::Lib))
1334 });
1335 }
1336
1337 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 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}