1#![allow(clippy::missing_errors_doc)]
2#![warn(missing_docs)]
3
4mod 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#[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#[derive(Debug)]
45pub enum Command {
46 FeatureMatrix {
51 pretty: bool,
53 },
54 Version,
56 Help,
58}
59
60#[derive(Debug, Default)]
65#[allow(clippy::struct_excessive_bools)]
66pub struct Options {
67 pub manifest_path: Option<PathBuf>,
69 pub packages: HashSet<String>,
71 pub exclude_packages: HashSet<String>,
73 pub command: Option<Command>,
75 pub only_packages_with_lib_target: bool,
77 pub silent: bool,
79 pub verbose: bool,
81 pub pedantic: bool,
83 pub errors_only: bool,
85 pub packages_only: bool,
87 pub fail_fast: bool,
89}
90
91pub trait ArgumentParser {
93 fn contains(&self, arg: &str) -> bool;
96 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
137pub trait Workspace {
139 fn workspace_config(&self) -> eyre::Result<WorkspaceConfig>;
141
142 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 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 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 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 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 packages.retain(|p| !workspace_config.exclude_packages.contains(p.name.as_str()));
222
223 if let Some(config) = root_config {
224 packages.retain(|p| !config.exclude_packages.contains(p.name.as_str()));
226 }
227
228 Ok(packages)
229 }
230}
231
232pub trait Package {
234 fn config(&self) -> eyre::Result<Config>;
246 fn feature_combinations<'a>(&'a self, config: &'a Config) -> Vec<Vec<&'a String>>;
249 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 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 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 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 implicit_features.insert(feature_name.clone());
328 } else {
329 optional_dep_used_with_dep_syntax_outside.insert(dep_name.to_string());
333 }
334 }
335 }
336
337 for dep_name in &optional_dep_used_with_dep_syntax_outside {
341 implicit_features.remove(dep_name);
342 }
343
344 effective_exclude_features.extend(implicit_features);
347 }
348
349 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 let mut filtered_powerset = base_powerset
369 .into_iter()
370 .filter(|feature_set| {
371 !config.exclude_feature_sets.iter().any(|skip_set| {
372 skip_set
374 .iter()
375 .all(|skip_feature| feature_set.contains(skip_feature))
377 })
378 })
379 .collect::<BTreeSet<_>>();
380
381 for proposed_exact_combination in &config.include_feature_sets {
383 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 filtered_powerset.insert(exact_combination);
393 }
394
395 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
411fn 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
437fn 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 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)) .filter(|ft| !exclude_features.contains(*ft)) .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
472pub 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#[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
534pub 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
547pub 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
561pub 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
677pub 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 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 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 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 {}", 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 let output_buffer = Vec::<u8>::new();
752 let mut colored_output = io::Cursor::new(output_buffer);
753
754 {
755 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
905fn 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
923pub fn parse_arguments(bin_name: &str) -> eyre::Result<(Options, Vec<String>)> {
933 let mut args: Vec<String> = std::env::args_os()
934 .skip(1)
936 .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 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 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 for (span, _) in args.get_all("matrix", false) {
984 options.command = Some(Command::FeatureMatrix { pretty: false });
985 args.drain(span);
986 }
987 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 for (span, _) in args.get_all("--help", false) {
997 options.command = Some(Command::Help);
998 args.drain(span);
999 }
1000
1001 for (span, _) in args.get_all("--version", false) {
1003 options.command = Some(Command::Version);
1004 args.drain(span);
1005 }
1006
1007 for (span, _) in args.get_all("version", false) {
1009 options.command = Some(Command::Version);
1010 args.drain(span);
1011 }
1012
1013 for (span, _) in args.get_all("--pedantic", false) {
1015 options.pedantic = true;
1016 args.drain(span);
1017 }
1018
1019 for (span, _) in args.get_all("--errors-only", false) {
1021 options.errors_only = true;
1022 args.drain(span);
1023 }
1024
1025 for (span, _) in args.get_all("--packages-only", false) {
1027 options.packages_only = true;
1028 args.drain(span);
1029 }
1030
1031 for (span, _) in args.get_all("--silent", false) {
1033 options.silent = true;
1034 args.drain(span);
1035 }
1036
1037 for (span, _) in args.get_all("--fail-fast", false) {
1039 options.fail_fast = true;
1040 args.drain(span);
1041 }
1042
1043 for (span, _) in args.get_all("--workspace", false) {
1050 args.drain(span);
1051 }
1052
1053 Ok((options, args))
1054}
1055
1056pub 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 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 packages.retain(|p| !options.exclude_packages.contains(p.name.as_str()));
1089
1090 if options.only_packages_with_lib_target {
1091 packages.retain(|p| {
1093 p.targets
1094 .iter()
1095 .any(|t| t.kind.contains(&cargo_metadata::TargetKind::Lib))
1096 });
1097 }
1098
1099 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 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}