Skip to main content

cargo_feature_combinations/
runner.rs

1//! Cargo command execution, output parsing, summary printing, and matrix output.
2
3use crate::DEFAULT_PKG_METADATA_SECTION;
4use crate::cli::{CargoSubcommand, Options, cargo_subcommand};
5use crate::package::{FeatureCombinationError, Package};
6use crate::print_warning;
7use crate::target::TargetTriple;
8
9use color_eyre::eyre;
10use itertools::Itertools;
11use regex::Regex;
12use std::collections::HashSet;
13use std::io::{self, Write};
14use std::process;
15use std::sync::LazyLock;
16use std::time::{Duration, Instant};
17use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
18
19static CYAN: LazyLock<ColorSpec> = LazyLock::new(|| color_spec(Color::Cyan, true));
20static RED: LazyLock<ColorSpec> = LazyLock::new(|| color_spec(Color::Red, true));
21static YELLOW: LazyLock<ColorSpec> = LazyLock::new(|| color_spec(Color::Yellow, true));
22static GREEN: LazyLock<ColorSpec> = LazyLock::new(|| color_spec(Color::Green, true));
23static DIMMED: LazyLock<ColorSpec> = LazyLock::new(|| {
24    let mut spec = ColorSpec::new();
25    spec.set_dimmed(true);
26    spec
27});
28
29/// An optional process exit code.
30///
31/// `None` means success (exit 0), `Some(code)` means the process should exit
32/// with the given code.
33pub type ExitCode = Option<i32>;
34
35/// Build a [`ColorSpec`] with the given foreground color and bold setting.
36#[must_use]
37pub fn color_spec(color: Color, bold: bool) -> ColorSpec {
38    let mut spec = ColorSpec::new();
39    spec.set_fg(Some(color));
40    spec.set_bold(bold);
41    spec
42}
43
44/// Force colored output on a subprocess.
45///
46/// Subprocesses see a pipe (not a TTY) on stderr because we capture their
47/// output, so most tools auto-disable color. We counteract this in three ways:
48///
49/// - `CARGO_TERM_COLOR=always` — respected by cargo itself.
50/// - `FORCE_COLOR=1` — widely adopted convention (Node.js, Python, Ruby, many
51///   Rust crates via `anstream`).
52/// - `--color always` is additionally injected into the cargo argument list by
53///   the caller for the direct subcommand.
54///
55/// A more universal fix would be to allocate a pseudo-TTY (e.g. via
56/// `portable-pty`) so that `isatty()` returns true in the subprocess, but the
57/// env-var approach covers the vast majority of real-world cases.
58fn force_color(cmd: &mut process::Command) {
59    cmd.env("CARGO_TERM_COLOR", "always");
60    cmd.env("FORCE_COLOR", "1");
61}
62
63/// Summary of the outcome for running (or pruning) a single feature set.
64#[derive(Debug, Clone)]
65pub struct Summary {
66    package_name: String,
67    features: Vec<String>,
68    exit_code: Option<i32>,
69    pedantic_success: bool,
70    num_warnings: usize,
71    num_errors: usize,
72    num_suppressed: usize,
73    /// If this combination was pruned, the features of the equivalent combo.
74    equivalent_to: Option<Vec<String>>,
75}
76
77impl Summary {
78    fn is_pruned(&self) -> bool {
79        self.equivalent_to.is_some()
80    }
81}
82
83/// Extract per-crate warning counts from cargo output.
84///
85/// The iterator yields the number of warnings for each compiled crate that
86/// matches the summary line produced by cargo.
87pub fn warning_counts(output: &str) -> impl Iterator<Item = usize> + '_ {
88    static WARNING_REGEX: LazyLock<Regex> = LazyLock::new(|| {
89        #[allow(
90            clippy::expect_used,
91            reason = "hard-coded regex pattern is expected to be valid"
92        )]
93        Regex::new(r"warning: .* generated (\d+) warnings?").expect("valid warning regex")
94    });
95    WARNING_REGEX
96        .captures_iter(output)
97        .filter_map(|cap| cap.get(1))
98        .map(|m| m.as_str().parse::<usize>().unwrap_or(0))
99}
100
101/// Extract per-crate error counts from cargo output.
102///
103/// The iterator yields the number of errors for each compiled crate that
104/// matches the summary line produced by cargo.
105pub fn error_counts(output: &str) -> impl Iterator<Item = usize> + '_ {
106    static ERROR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
107        #[allow(
108            clippy::expect_used,
109            reason = "hard-coded regex pattern is expected to be valid"
110        )]
111        Regex::new(r"error: could not compile `[^`]*`.*due to\s*(\d*)\s*previous errors?")
112            .expect("valid error regex")
113    });
114    ERROR_REGEX
115        .captures_iter(output)
116        .filter_map(|cap| cap.get(1))
117        .map(|m| m.as_str().parse::<usize>().unwrap_or(1))
118}
119
120/// Result of processing cargo output for a single feature combination.
121pub(crate) struct ProcessResult {
122    pub num_warnings: usize,
123    pub num_errors: usize,
124    pub num_suppressed: usize,
125    pub output: Vec<u8>,
126}
127
128/// Capture cargo stderr, optionally tee-ing it to the terminal.
129///
130/// In summary-only mode the output is buffered only; otherwise it is streamed
131/// to `stdout` while also being captured for later analysis.
132fn capture_stderr(
133    child: &mut process::Child,
134    summary_only: bool,
135    stdout: &mut StandardStream,
136) -> io::Result<ProcessResult> {
137    let output_buffer = Vec::<u8>::new();
138    let mut output_cursor = io::Cursor::new(output_buffer);
139
140    if let Some(proc_stderr) = child.stderr.take() {
141        let mut proc_reader = io::BufReader::new(proc_stderr);
142        if summary_only {
143            io::copy(&mut proc_reader, &mut output_cursor)?;
144        } else {
145            let mut tee_reader = crate::tee::Reader::new(proc_reader, stdout, true);
146            io::copy(&mut tee_reader, &mut output_cursor)?;
147        }
148    } else {
149        eprintln!("ERROR: failed to redirect stderr");
150    }
151
152    let stripped = strip_ansi_escapes::strip(output_cursor.get_ref());
153    let stripped = String::from_utf8_lossy(&stripped);
154    let num_warnings = warning_counts(&stripped).sum::<usize>();
155    let num_errors = error_counts(&stripped).sum::<usize>();
156
157    Ok(ProcessResult {
158        num_warnings,
159        num_errors,
160        num_suppressed: 0,
161        output: output_cursor.into_inner(),
162    })
163}
164
165pub(crate) fn print_feature_combination_error(err: &FeatureCombinationError) {
166    let mut stderr = StandardStream::stderr(ColorChoice::Auto);
167
168    let _ = stderr.set_color(&RED);
169    let _ = write!(&mut stderr, "error");
170    let _ = stderr.reset();
171    let _ = writeln!(&mut stderr, ": feature matrix generation failed");
172
173    match err {
174        FeatureCombinationError::TooManyConfigurations {
175            package,
176            num_features,
177            num_configurations,
178            limit,
179        } => {
180            let _ = stderr.set_color(&YELLOW);
181            let _ = writeln!(&mut stderr, "  reason: too many configurations");
182            let _ = stderr.reset();
183
184            let _ = stderr.set_color(&CYAN);
185            let _ = write!(&mut stderr, "  package:");
186            let _ = stderr.reset();
187            let _ = writeln!(&mut stderr, " {package}");
188
189            let _ = stderr.set_color(&CYAN);
190            let _ = write!(&mut stderr, "  features considered:");
191            let _ = stderr.reset();
192            let _ = writeln!(&mut stderr, " {num_features}");
193
194            let _ = stderr.set_color(&CYAN);
195            let _ = write!(&mut stderr, "  combinations:");
196            let _ = stderr.reset();
197            let _ = writeln!(
198                &mut stderr,
199                " {}",
200                num_configurations
201                    .map(|v| v.to_string())
202                    .unwrap_or_else(|| "unbounded".to_string())
203            );
204
205            let _ = stderr.set_color(&CYAN);
206            let _ = write!(&mut stderr, "  limit:");
207            let _ = stderr.reset();
208            let _ = writeln!(&mut stderr, " {limit}");
209
210            let _ = stderr.set_color(&GREEN);
211            let _ = writeln!(&mut stderr, "  hint:");
212            let _ = stderr.reset();
213            let _ = writeln!(
214                &mut stderr,
215                "    Consider restricting the matrix using {DEFAULT_PKG_METADATA_SECTION}.only_features",
216            );
217            let _ = writeln!(
218                &mut stderr,
219                "    or splitting features into isolated_feature_sets, or excluding features via exclude_features."
220            );
221        }
222    }
223}
224
225/// Print an aggregated summary for all executed feature combinations.
226///
227/// Returns the [`ExitCode`] of the first failing feature combination, or
228/// `None` if all combinations succeeded.
229///
230/// This function is used by [`run_cargo_command_for_target`] after all packages and
231/// feature sets have been processed.
232#[must_use]
233pub fn print_summary(
234    summary: &[Summary],
235    show_pruned: bool,
236    mut stdout: termcolor::StandardStream,
237    elapsed: Duration,
238) -> ExitCode {
239    let num_packages = summary
240        .iter()
241        .map(|s| &s.package_name)
242        .collect::<HashSet<_>>()
243        .len();
244    let num_total = summary
245        .iter()
246        .map(|s| (&s.package_name, s.features.iter().collect::<Vec<_>>()))
247        .collect::<HashSet<_>>()
248        .len();
249    let num_pruned = summary.iter().filter(|s| s.is_pruned()).count();
250    let num_executed = num_total - num_pruned;
251
252    println!();
253    stdout.set_color(&CYAN).ok();
254    print!("    Finished ");
255    stdout.reset().ok();
256    if num_pruned > 0 {
257        print!(
258            "{num_executed} of {num_total} feature combination{} for {num_packages} package{} in {:.2}s",
259            if num_total > 1 { "s" } else { "" },
260            if num_packages > 1 { "s" } else { "" },
261            elapsed.as_secs_f64(),
262        );
263        stdout.set_color(&DIMMED).ok();
264        print!(" ({num_pruned} pruned)");
265        stdout.reset().ok();
266    } else {
267        print!(
268            "{num_total} feature combination{} for {num_packages} package{} in {:.2}s",
269            if num_total > 1 { "s" } else { "" },
270            if num_packages > 1 { "s" } else { "" },
271            elapsed.as_secs_f64(),
272        );
273    }
274    println!();
275    println!();
276
277    let max_errors = summary.iter().map(|s| s.num_errors).max().unwrap_or(0);
278    let max_warnings = summary.iter().map(|s| s.num_warnings).max().unwrap_or(0);
279    let max_suppressed = summary.iter().map(|s| s.num_suppressed).max().unwrap_or(0);
280    let show_suppressed = max_suppressed > 0;
281    let errors_width = max_errors.to_string().len();
282    let warnings_width = max_warnings.to_string().len();
283    let suppressed_width = max_suppressed.to_string().len();
284
285    let mut first_bad_exit_code: Option<i32> = None;
286
287    for s in summary {
288        if !show_pruned && s.is_pruned() {
289            continue;
290        }
291        let fmt = SummaryFormat {
292            show_suppressed,
293            errors_width,
294            warnings_width,
295            suppressed_width,
296        };
297        print_summary_entry(s, &mut stdout, &fmt);
298        if !s.pedantic_success {
299            first_bad_exit_code = first_bad_exit_code.or(s.exit_code);
300        }
301    }
302    println!();
303
304    first_bad_exit_code
305}
306
307/// Column widths and display flags for summary entry formatting.
308struct SummaryFormat {
309    show_suppressed: bool,
310    errors_width: usize,
311    warnings_width: usize,
312    suppressed_width: usize,
313}
314
315fn print_summary_entry(s: &Summary, stdout: &mut termcolor::StandardStream, fmt: &SummaryFormat) {
316    if s.is_pruned() {
317        stdout.set_color(&DIMMED).ok();
318        print!("        SKIP ");
319        stdout.reset().ok();
320    } else if !s.pedantic_success {
321        stdout.set_color(&RED).ok();
322        print!("        FAIL ");
323    } else if s.num_warnings > 0 {
324        stdout.set_color(&YELLOW).ok();
325        print!("        WARN ");
326    } else {
327        stdout.set_color(&GREEN).ok();
328        print!("        PASS ");
329    }
330    stdout.reset().ok();
331
332    let feat = s.features.iter().join(", ");
333    let ew = fmt.errors_width;
334    let ww = fmt.warnings_width;
335    let sw = fmt.suppressed_width;
336    let ne = s.num_errors;
337    let nw = s.num_warnings;
338    let ns = s.num_suppressed;
339    if fmt.show_suppressed {
340        print!(
341            "{} ( {ne:>ew$} errors, {nw:>ww$} warnings, {ns:>sw$} suppressed, features = [{feat}] )",
342            s.package_name,
343        );
344    } else {
345        print!(
346            "{} ( {ne:>ew$} errors, {nw:>ww$} warnings, features = [{feat}] )",
347            s.package_name,
348        );
349    }
350
351    if let Some(equiv) = &s.equivalent_to {
352        let equiv = equiv.iter().join(", ");
353        stdout.set_color(&DIMMED).ok();
354        println!(" \u{2190} equivalent to [{equiv}]");
355        stdout.reset().ok();
356    } else {
357        println!();
358    }
359}
360
361fn print_package_cmd(
362    package: &cargo_metadata::Package,
363    features: &[&String],
364    cargo_args: &[&str],
365    all_args: &[&str],
366    options: &Options,
367    stdout: &mut StandardStream,
368) {
369    let compact = options.summary_only || options.diagnostics_only;
370    if !compact {
371        println!();
372    }
373    let subcommand = cargo_subcommand(cargo_args);
374    stdout.set_color(&CYAN).ok();
375    match subcommand {
376        CargoSubcommand::Test => {
377            print!("     Testing ");
378        }
379        CargoSubcommand::Doc => {
380            print!("     Documenting ");
381        }
382        CargoSubcommand::Check => {
383            print!("     Checking ");
384        }
385        CargoSubcommand::Run => {
386            print!("     Running ");
387        }
388        CargoSubcommand::Build => {
389            print!("     Building ");
390        }
391        CargoSubcommand::Other => {
392            print!("     ");
393        }
394    }
395    // For known subcommands, only the verb is colored. For unknown
396    // subcommands (Other) we keep cyan for the entire line so the header
397    // remains visually distinct.
398    if subcommand != CargoSubcommand::Other {
399        stdout.reset().ok();
400    }
401    print!(
402        "{} ( features = [{}] )",
403        package.name,
404        features.as_ref().iter().join(", ")
405    );
406    if options.verbose {
407        print!(" [cargo {}]", all_args.join(" "));
408    }
409    stdout.reset().ok();
410    println!();
411    if !compact {
412        println!();
413    }
414}
415
416/// Options for [`print_feature_matrix_for_target`].
417#[derive(Debug, Default)]
418pub struct MatrixOptions {
419    /// Whether to pretty-print the JSON output.
420    pub pretty: bool,
421    /// Whether to emit only one `"default"` entry per package instead of the
422    /// full feature combination matrix.
423    pub packages_only: bool,
424    /// Whether to disable automatic pruning of implied feature combinations.
425    pub no_prune_implied: bool,
426}
427
428/// Print a JSON feature matrix for the given packages to stdout.
429///
430/// The matrix is a JSON array of objects produced from each package's
431/// configuration and the feature combinations returned by
432/// [`Package::feature_matrix`].
433///
434/// # Errors
435///
436/// Returns an error if any configuration can not be parsed or serialization
437/// of the JSON matrix fails.
438pub fn print_feature_matrix_for_target(
439    packages: &[&cargo_metadata::Package],
440    target: &TargetTriple,
441    evaluator: &mut impl crate::cfg_eval::CfgEvaluator,
442    options: &MatrixOptions,
443) -> eyre::Result<ExitCode> {
444    let per_package_features = packages
445        .iter()
446        .map(|pkg| {
447            let base_config = pkg.config()?;
448            let config = crate::config::resolve::resolve_config(&base_config, target, evaluator)?;
449            let features = if options.packages_only {
450                vec!["default".to_string()]
451            } else {
452                let combos = pkg.feature_combinations(&config)?;
453                let combos = crate::implication::maybe_prune(
454                    combos,
455                    &pkg.features,
456                    &config,
457                    options.no_prune_implied,
458                );
459                combos
460                    .keep
461                    .into_iter()
462                    .map(|combo| combo.into_iter().join(","))
463                    .collect()
464            };
465            Ok::<_, eyre::Report>((pkg.name.clone(), config, features))
466        })
467        .collect::<Result<Vec<_>, _>>()?;
468
469    let matrix: Vec<serde_json::Value> = per_package_features
470        .into_iter()
471        .flat_map(|(name, config, features)| {
472            features.into_iter().map(move |ft| {
473                use serde_json_merge::{iter::dfs::Dfs, merge::Merge};
474
475                let mut out = serde_json::json!(config.matrix);
476                out.merge::<Dfs>(&serde_json::json!({
477                    "name": name,
478                    "features": ft,
479                }));
480                out
481            })
482        })
483        .collect();
484
485    let matrix = if options.pretty {
486        serde_json::to_string_pretty(&matrix)
487    } else {
488        serde_json::to_string(&matrix)
489    }?;
490    println!("{matrix}");
491    Ok(None)
492}
493
494/// Pre-computed state shared across all feature combinations in a single
495/// [`run_cargo_command_for_target`] invocation.
496struct RunContext<'a> {
497    cargo_args: &'a [&'a str],
498    extra_args: &'a [&'a str],
499    missing_arguments: bool,
500    use_diagnostics_only: bool,
501    options: &'a Options,
502}
503
504/// Result of [`run_single_combination`] for one feature combination.
505struct CombinationResult {
506    summary: Summary,
507    /// Raw (colored) output buffer for potential `--fail-fast` dumping.
508    colored_output: Vec<u8>,
509}
510
511/// Run a single cargo invocation for one feature combination and collect
512/// its output into a [`Summary`].
513fn run_single_combination(
514    package: &cargo_metadata::Package,
515    features: &[&String],
516    ctx: &RunContext<'_>,
517    seen_diagnostics: &mut HashSet<String>,
518    stdout: &mut StandardStream,
519) -> eyre::Result<CombinationResult> {
520    // We set the command working dir to the package manifest parent dir.
521    // This works well for now, but one could also consider `--manifest-path` or `-p`
522    let Some(working_dir) = package.manifest_path.parent() else {
523        eyre::bail!(
524            "could not find parent dir of package {}",
525            package.manifest_path.to_string()
526        )
527    };
528
529    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
530    let mut cmd = process::Command::new(&cargo);
531    force_color(&mut cmd);
532
533    if ctx.options.errors_only {
534        cmd.env(
535            "RUSTFLAGS",
536            format!(
537                "-Awarnings {}", // allows all warnings
538                std::env::var("RUSTFLAGS").unwrap_or_default()
539            ),
540        );
541    }
542
543    let mut args = ctx.cargo_args.to_vec();
544    if ctx.use_diagnostics_only {
545        args.push(crate::diagnostics_only::MESSAGE_FORMAT);
546    }
547    let features_flag = format!("--features={}", &features.iter().join(","));
548    if !ctx.missing_arguments {
549        args.push("--no-default-features");
550        args.push(&features_flag);
551    }
552    args.extend_from_slice(ctx.extra_args);
553    print_package_cmd(
554        package,
555        features,
556        ctx.cargo_args,
557        &args,
558        ctx.options,
559        stdout,
560    );
561
562    cmd.args(&args).current_dir(working_dir);
563    if ctx.use_diagnostics_only {
564        cmd.stdout(process::Stdio::piped());
565    }
566    cmd.stderr(process::Stdio::piped());
567    let mut child = cmd.spawn()?;
568
569    let mut result = if ctx.use_diagnostics_only {
570        crate::diagnostics_only::process_output(
571            &mut child,
572            ctx.options.summary_only,
573            ctx.options.dedupe,
574            seen_diagnostics,
575            stdout,
576        )?
577    } else {
578        capture_stderr(&mut child, ctx.options.summary_only, stdout)?
579    };
580
581    let exit_status = child.wait()?;
582
583    // Print per-combination dedup note after diagnostics
584    if result.num_suppressed > 0 && !ctx.options.summary_only {
585        stdout.set_color(&CYAN).ok();
586        print!("       Note ");
587        stdout.reset().ok();
588        println!(
589            "{} duplicate diagnostic{} suppressed",
590            result.num_suppressed,
591            if result.num_suppressed > 1 { "s" } else { "" },
592        );
593    }
594
595    let fail = !exit_status.success();
596
597    // In diagnostics-only mode, cargo-level failures (bad CLI arguments,
598    // dependency resolution errors, …) produce no JSON diagnostics — so the
599    // user would only see "FAIL … 0 errors, 0 warnings" with no explanation.
600    // When that happens the output buffer holds the captured stderr which is
601    // the only clue about what went wrong. Print it unconditionally (even in
602    // --summary-only mode) so the failure is never silent.
603    if ctx.use_diagnostics_only && fail && result.num_errors == 0 && !result.output.is_empty() {
604        stdout.write_all(&result.output)?;
605        stdout.flush().ok();
606        // Clear the buffer so the --fail-fast dump does not print it a
607        // second time.
608        result.output.clear();
609    }
610
611    let pedantic_fail = ctx.options.pedantic && (result.num_errors > 0 || result.num_warnings > 0);
612
613    let summary = Summary {
614        features: features.iter().map(|s| (*s).clone()).collect(),
615        num_errors: result.num_errors,
616        num_warnings: result.num_warnings,
617        num_suppressed: result.num_suppressed,
618        package_name: package.name.to_string(),
619        exit_code: exit_status.code(),
620        pedantic_success: !(fail || pedantic_fail),
621        equivalent_to: None,
622    };
623
624    Ok(CombinationResult {
625        summary,
626        colored_output: result.output,
627    })
628}
629
630/// Run a cargo command for all requested packages and feature combinations.
631///
632/// This is useful for library consumers that want to control target
633/// resolution themselves, e.g. when cross-compiling.
634///
635/// # Errors
636///
637/// Returns an error if a cargo process can not be spawned or if IO operations
638/// fail while reading cargo's output.
639pub fn run_cargo_command_for_target(
640    packages: &[&cargo_metadata::Package],
641    mut cargo_args: Vec<&str>,
642    options: &Options,
643    target: &TargetTriple,
644    evaluator: &mut impl crate::cfg_eval::CfgEvaluator,
645) -> eyre::Result<ExitCode> {
646    let start = Instant::now();
647
648    // split into cargo and extra arguments after --
649    let extra_args_idx = cargo_args
650        .iter()
651        .position(|arg| *arg == "--")
652        .unwrap_or(cargo_args.len());
653    let extra_args = cargo_args.split_off(extra_args_idx);
654
655    let missing_arguments = cargo_args.is_empty() && extra_args.is_empty();
656
657    if !cargo_args.contains(&"--color") {
658        // force colored output
659        cargo_args.extend(["--color", "always"]);
660    }
661
662    // Determine whether we can use diagnostics-only (JSON) mode.
663    let user_has_message_format = cargo_args.iter().any(|a| a.starts_with("--message-format"));
664    let use_diagnostics_only = options.diagnostics_only && !user_has_message_format;
665
666    if options.diagnostics_only && user_has_message_format {
667        print_warning!("--diagnostics-only is ignored when --message-format is already specified");
668    }
669
670    let ctx = RunContext {
671        cargo_args: &cargo_args,
672        extra_args: &extra_args,
673        missing_arguments,
674        use_diagnostics_only,
675        options,
676    };
677
678    let mut stdout = StandardStream::stdout(ColorChoice::Auto);
679    let mut summary: Vec<Summary> = Vec::new();
680    let mut seen_diagnostics: HashSet<String> = HashSet::new();
681
682    // show_pruned is a display concern for the global summary, so if any
683    // package enables it via config we show pruned entries for all packages.
684    let mut show_pruned = options.show_pruned;
685
686    for package in packages {
687        let base_config = package.config()?;
688        let config = crate::config::resolve::resolve_config(&base_config, target, evaluator)?;
689        show_pruned = show_pruned || config.show_pruned;
690
691        let all_combos = package.feature_combinations(&config)?;
692        let prune_result = crate::implication::maybe_prune(
693            all_combos,
694            &package.features,
695            &config,
696            options.no_prune_implied,
697        );
698
699        let pkg_start = summary.len();
700        for features in &prune_result.keep {
701            let result = run_single_combination(
702                package,
703                features,
704                &ctx,
705                &mut seen_diagnostics,
706                &mut stdout,
707            )?;
708            let should_stop = options.fail_fast && !result.summary.pedantic_success;
709            let exit_code = result.summary.exit_code;
710            summary.push(result.summary);
711            if should_stop {
712                if options.summary_only {
713                    io::copy(&mut io::Cursor::new(result.colored_output), &mut stdout)?;
714                    stdout.flush().ok();
715                }
716                let code = print_summary(&summary, show_pruned, stdout, start.elapsed())
717                    .or(exit_code)
718                    .unwrap_or(1);
719                return Ok(Some(code));
720            }
721        }
722
723        append_pruned_summaries(
724            &mut summary,
725            pkg_start,
726            package.name.as_ref(),
727            prune_result.pruned,
728        );
729    }
730
731    Ok(print_summary(
732        &summary,
733        show_pruned,
734        stdout,
735        start.elapsed(),
736    ))
737}
738
739/// Append pruned summaries for a single package, looking up the equivalent
740/// combo's error/warning counts from already-executed summaries, then sort
741/// all entries for this package by features for interleaved display.
742fn append_pruned_summaries(
743    summary: &mut Vec<Summary>,
744    pkg_start: usize,
745    package_name: &str,
746    pruned: Vec<crate::implication::PrunedCombination>,
747) {
748    let executed: std::collections::HashMap<Vec<String>, Summary> = summary
749        .get(pkg_start..)
750        .unwrap_or_default()
751        .iter()
752        .filter(|s| !s.is_pruned())
753        .map(|s| (s.features.clone(), s.clone()))
754        .collect();
755
756    for p in pruned {
757        let Some(equiv) = executed.get(&p.equivalent_to) else {
758            continue;
759        };
760        summary.push(Summary {
761            package_name: package_name.to_string(),
762            features: p.features,
763            equivalent_to: Some(p.equivalent_to),
764            num_errors: equiv.num_errors,
765            num_warnings: equiv.num_warnings,
766            num_suppressed: equiv.num_suppressed,
767            exit_code: None,
768            pedantic_success: true,
769        });
770    }
771
772    if let Some(slice) = summary.get_mut(pkg_start..) {
773        slice.sort_by(|a, b| a.features.cmp(&b.features));
774    }
775}
776
777#[cfg(test)]
778mod test {
779    use super::{error_counts, warning_counts};
780    use similar_asserts::assert_eq as sim_assert_eq;
781
782    #[test]
783    fn error_regex_single_mod_multiple_errors() {
784        let stderr = include_str!("../test-data/single_mod_multiple_errors_stderr.txt");
785        let errors: Vec<_> = error_counts(stderr).collect();
786        sim_assert_eq!(&errors, &vec![2]);
787    }
788
789    #[test]
790    fn error_regex_with_target_kind() {
791        let stderr =
792            "error: could not compile `docparser-paddleocr-vl` (lib) due to 24 previous errors";
793        let errors: Vec<_> = error_counts(stderr).collect();
794        sim_assert_eq!(&errors, &vec![24]);
795    }
796
797    #[test]
798    fn error_regex_with_target_kind_bin() {
799        let stderr =
800            "error: could not compile `my-crate` (bin \"my-crate\") due to 3 previous errors";
801        let errors: Vec<_> = error_counts(stderr).collect();
802        sim_assert_eq!(&errors, &vec![3]);
803    }
804
805    #[test]
806    fn warning_regex_two_mod_multiple_warnings() {
807        let stderr = include_str!("../test-data/two_mods_warnings_stderr.txt");
808        let warnings: Vec<_> = warning_counts(stderr).collect();
809        sim_assert_eq!(&warnings, &vec![6, 7]);
810    }
811}