Skip to main content

sbom_tools/cli/
multi.rs

1//! Multi-SBOM command handlers.
2//!
3//! Implements the `diff-multi`, `timeline`, and `matrix` subcommands.
4//! Uses the pipeline module for parsing and enrichment (shared with `diff`).
5
6use crate::config::{
7    FilterConfig, GraphAwareDiffConfig, MatchingRulesPathConfig, MatrixConfig, MultiDiffConfig,
8    TimelineConfig,
9};
10use crate::diff::{DiffResult, MultiDiffEngine};
11use crate::matching::{FuzzyMatchConfig, MatchingRulesConfig};
12use crate::model::NormalizedSbom;
13use crate::pipeline::{
14    OutputTarget, apply_post_diff_filters, auto_detect_format, enrich_sbom_full, enrich_sboms,
15    exit_codes, graph_diff_config_from, parse_sbom_with_context, validate_post_diff_filters,
16    write_output,
17};
18use crate::reports::ReportFormat;
19use crate::tui::{App, run_tui};
20use anyhow::{Result, bail};
21use std::path::{Path, PathBuf};
22
23/// How a multi-SBOM command should emit its result.
24enum MultiOutput {
25    /// Launch the interactive TUI.
26    Tui,
27    /// Serialize to pretty JSON and write to the resolved target.
28    Json(OutputTarget),
29}
30
31/// Resolve and validate the output mode for a multi-SBOM command.
32///
33/// Multi-SBOM results only have two real renderers: the interactive TUI and
34/// pretty JSON. `auto` resolves to TUI on a terminal and JSON when piped/redirected.
35/// Any other explicitly requested format (summary, table, markdown, sarif, …) has
36/// no multi-SBOM renderer, so it is rejected with a clear error rather than
37/// silently emitting JSON.
38fn resolve_multi_output(output: &crate::config::OutputConfig) -> Result<MultiOutput> {
39    let target = OutputTarget::from_option(output.file.clone());
40    match output.format {
41        ReportFormat::Tui => Ok(MultiOutput::Tui),
42        ReportFormat::Json => Ok(MultiOutput::Json(target)),
43        ReportFormat::Auto => match auto_detect_format(ReportFormat::Auto, &target) {
44            ReportFormat::Tui => Ok(MultiOutput::Tui),
45            // Off-TTY `auto` resolves to summary in single-diff; multi has no
46            // summary renderer, so default the piped case to JSON.
47            _ => Ok(MultiOutput::Json(target)),
48        },
49        other => bail!(
50            "output format '{other}' is not supported for multi-SBOM commands \
51             (diff-multi/timeline/matrix); supported formats: tui, json"
52        ),
53    }
54}
55
56/// Load custom matching rules from a path config, mirroring the single-diff
57/// pipeline: a missing/invalid file is logged and skipped rather than aborting,
58/// and dry-run mode parses-but-skips application.
59fn load_multi_rules(rules: &MatchingRulesPathConfig) -> Option<MatchingRulesConfig> {
60    let path = rules.rules_file.as_ref()?;
61    match MatchingRulesConfig::from_file(path) {
62        Ok(loaded) => {
63            if rules.dry_run {
64                tracing::info!("Dry-run mode: matching rules parsed but not applied");
65                None
66            } else {
67                Some(loaded)
68            }
69        }
70        Err(e) => {
71            tracing::warn!("Failed to load matching rules: {e}");
72            None
73        }
74    }
75}
76
77/// Build a `MultiDiffEngine` with graph diffing and matching rules wired in
78/// from CLI configuration.
79fn build_multi_engine(
80    fuzzy_config: FuzzyMatchConfig,
81    include_unchanged: bool,
82    graph: &GraphAwareDiffConfig,
83    rules: &MatchingRulesPathConfig,
84) -> MultiDiffEngine {
85    let mut engine = MultiDiffEngine::new()
86        .with_fuzzy_config(fuzzy_config)
87        .include_unchanged(include_unchanged);
88    if graph.enabled {
89        engine = engine.with_graph_diff(graph_diff_config_from(graph));
90    }
91    if let Some(loaded) = load_multi_rules(rules) {
92        engine = engine.with_matching_rules(loaded);
93    }
94    engine
95}
96
97/// Run the diff-multi command (1:N comparison), returning the desired exit code.
98#[allow(clippy::needless_pass_by_value)]
99pub fn run_diff_multi(config: MultiDiffConfig) -> Result<i32> {
100    let quiet = config.behavior.quiet;
101
102    // Validate output mode and filter values before doing work (parsing,
103    // enrichment) so unsupported formats and unknown filter labels fail fast,
104    // matching timeline/matrix.
105    let output_mode = resolve_multi_output(&config.output)?;
106    validate_post_diff_filters(&config.filtering, &config.graph_diff)?;
107
108    // Parse baseline
109    let mut baseline_parsed = parse_sbom_with_context(&config.baseline, quiet)?;
110    // Parse and optionally enrich targets
111    let (target_sboms, target_stats) =
112        parse_and_enrich_sboms(&config.targets, &config.enrichment, quiet)?;
113
114    // Enrich baseline
115    let baseline_stats = enrich_sbom_full(baseline_parsed.sbom_mut(), &config.enrichment, quiet);
116
117    tracing::info!(
118        "Comparing baseline ({} components) against {} targets",
119        baseline_parsed.sbom().component_count(),
120        target_sboms.len()
121    );
122
123    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
124
125    // Prepare display names, unique across the baseline AND all targets —
126    // the engine keys spreads and the vulnerability matrix by these names.
127    let mut all_paths = vec![config.baseline.clone()];
128    all_paths.extend(config.targets.iter().cloned());
129    let mut all_names = unique_sbom_names(&all_paths);
130    let baseline_name = all_names.remove(0);
131
132    let targets: Vec<(&NormalizedSbom, String, String)> = target_sboms
133        .iter()
134        .zip(all_names)
135        .zip(config.targets.iter())
136        .map(|((sbom, name), path)| (sbom, name, path.to_string_lossy().to_string()))
137        .collect();
138    let target_refs: Vec<_> = targets
139        .iter()
140        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
141        .collect();
142
143    // Run multi-diff
144    let mut engine = build_multi_engine(
145        fuzzy_config,
146        config.matching.include_unchanged,
147        &config.graph_diff,
148        &config.rules,
149    );
150
151    let mut result = engine.diff_multi(
152        baseline_parsed.sbom(),
153        &baseline_name,
154        &config.baseline.to_string_lossy(),
155        &target_refs,
156    )?;
157
158    // Apply severity/VEX/graph-impact post-processing to each pairwise diff.
159    for comparison in &mut result.comparisons {
160        apply_post_diff_filters(&mut comparison.diff, &config.filtering, &config.graph_diff);
161    }
162
163    tracing::info!(
164        "Multi-diff complete: {} comparisons, max deviation: {:.1}%",
165        result.comparisons.len(),
166        result.summary.max_deviation * 100.0
167    );
168
169    // Determine exit code
170    let exit_code = determine_multi_exit_code(
171        &config.behavior,
172        &config.filtering,
173        result.comparisons.iter().map(|c| &c.diff),
174        PairDirection::Ordered,
175    );
176
177    if let MultiOutput::Json(ref output_target) = output_mode {
178        let json = serde_json::to_string_pretty(&result)?;
179        write_output(&json, output_target, quiet)?;
180    } else {
181        let mut app = App::new_multi_diff(result);
182        app.export_template = config.output.export_template.clone();
183
184        // Show enrichment warnings if any
185        let all_warnings: Vec<_> = std::iter::once(&baseline_stats)
186            .chain(target_stats.iter())
187            .flat_map(|s| s.warnings.iter())
188            .collect();
189        if !all_warnings.is_empty() {
190            app.set_status_message(format!(
191                "Warning: {}",
192                all_warnings
193                    .iter()
194                    .map(|s| s.as_str())
195                    .collect::<Vec<_>>()
196                    .join(", ")
197            ));
198            app.status_sticky = true;
199        }
200
201        run_tui(&mut app, config.output.no_color)?;
202    }
203
204    Ok(exit_code)
205}
206
207/// Run the timeline command, returning the desired exit code.
208#[allow(clippy::needless_pass_by_value)]
209pub fn run_timeline(config: TimelineConfig) -> Result<i32> {
210    let quiet = config.behavior.quiet;
211
212    if config.sbom_paths.len() < 2 {
213        bail!("Timeline analysis requires at least 2 SBOMs");
214    }
215
216    // Validate output mode and filter values before doing work so unsupported
217    // formats and unknown filter labels fail fast.
218    let output_mode = resolve_multi_output(&config.output)?;
219    validate_post_diff_filters(&config.filtering, &config.graph_diff)?;
220
221    let (sboms, _enrich_stats) =
222        parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
223
224    tracing::info!("Analyzing timeline of {} SBOMs", sboms.len());
225
226    // Chronological argv order (oldest first) is a documented precondition;
227    // a shuffled invocation silently inverts every Initial/Removed/Downgrade
228    // classification, so at least warn when document timestamps disagree.
229    if !quiet {
230        let out_of_order: Vec<usize> = sboms
231            .windows(2)
232            .enumerate()
233            .filter(|(_, w)| w[1].document.created < w[0].document.created)
234            .map(|(i, _)| i + 1)
235            .collect();
236        if !out_of_order.is_empty() {
237            eprintln!(
238                "Warning: SBOM document timestamps are not in chronological order \
239                 (position{} {}); timeline analysis assumes oldest-first argument order",
240                if out_of_order.len() == 1 { "" } else { "s" },
241                out_of_order
242                    .iter()
243                    .map(std::string::ToString::to_string)
244                    .collect::<Vec<_>>()
245                    .join(", ")
246            );
247        }
248    }
249
250    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
251
252    // Prepare SBOM references with names
253    let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
254    let sbom_refs: Vec<_> = sbom_data
255        .iter()
256        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
257        .collect();
258
259    // Run timeline analysis
260    let mut engine = build_multi_engine(
261        fuzzy_config,
262        config.matching.include_unchanged,
263        &config.graph_diff,
264        &config.rules,
265    );
266    let mut result = engine.timeline(&sbom_refs)?;
267
268    // Apply severity/VEX/graph-impact post-processing to each incremental diff.
269    for diff in result
270        .incremental_diffs
271        .iter_mut()
272        .chain(result.cumulative_from_first.iter_mut())
273    {
274        apply_post_diff_filters(diff, &config.filtering, &config.graph_diff);
275    }
276
277    tracing::info!(
278        "Timeline analysis complete: {} incremental diffs",
279        result.incremental_diffs.len()
280    );
281
282    // Determine exit code
283    let exit_code = determine_multi_exit_code(
284        &config.behavior,
285        &config.filtering,
286        result.incremental_diffs.iter(),
287        PairDirection::Ordered,
288    );
289
290    if let MultiOutput::Json(ref output_target) = output_mode {
291        let json = serde_json::to_string_pretty(&result)?;
292        write_output(&json, output_target, quiet)?;
293    } else {
294        let mut app = App::new_timeline(result);
295        run_tui(&mut app, config.output.no_color)?;
296    }
297
298    Ok(exit_code)
299}
300
301/// Run the matrix command (N×N comparison), returning the desired exit code.
302#[allow(clippy::needless_pass_by_value)]
303pub fn run_matrix(config: MatrixConfig) -> Result<i32> {
304    let quiet = config.behavior.quiet;
305
306    if config.sbom_paths.len() < 2 {
307        bail!("Matrix comparison requires at least 2 SBOMs");
308    }
309
310    // Validate output mode and filter values before doing work so unsupported
311    // formats and unknown filter labels fail fast.
312    let output_mode = resolve_multi_output(&config.output)?;
313    validate_post_diff_filters(&config.filtering, &config.graph_diff)?;
314
315    let (sboms, _enrich_stats) =
316        parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
317
318    tracing::info!(
319        "Computing {}x{} comparison matrix",
320        sboms.len(),
321        sboms.len()
322    );
323
324    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
325
326    // Prepare SBOM references with names
327    let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
328    let sbom_refs: Vec<_> = sbom_data
329        .iter()
330        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
331        .collect();
332
333    // Run matrix comparison
334    let mut engine = build_multi_engine(
335        fuzzy_config,
336        config.matching.include_unchanged,
337        &config.graph_diff,
338        &config.rules,
339    );
340    let mut result = engine.matrix(&sbom_refs, Some(config.cluster_threshold))?;
341
342    // Apply severity/VEX/graph-impact post-processing to each pairwise diff.
343    for diff in result.diffs.iter_mut().flatten() {
344        apply_post_diff_filters(diff, &config.filtering, &config.graph_diff);
345    }
346
347    tracing::info!(
348        "Matrix comparison complete: {} pairs computed",
349        result.num_pairs()
350    );
351
352    if let Some(ref clustering) = result.clustering {
353        tracing::info!(
354            "Found {} clusters, {} outliers",
355            clustering.clusters.len(),
356            clustering.outliers.len()
357        );
358    }
359
360    // Determine exit code
361    let exit_code = determine_multi_exit_code(
362        &config.behavior,
363        &config.filtering,
364        result.diffs.iter().flatten(),
365        PairDirection::Unordered,
366    );
367
368    if let MultiOutput::Json(ref output_target) = output_mode {
369        let json = serde_json::to_string_pretty(&result)?;
370        write_output(&json, output_target, quiet)?;
371    } else {
372        let mut app = App::new_matrix(result);
373        run_tui(&mut app, config.output.no_color)?;
374    }
375
376    Ok(exit_code)
377}
378
379/// Parse and optionally enrich multiple SBOMs.
380fn parse_and_enrich_sboms(
381    paths: &[PathBuf],
382    enrichment: &crate::config::EnrichmentConfig,
383    quiet: bool,
384) -> Result<(
385    Vec<NormalizedSbom>,
386    Vec<crate::pipeline::AggregatedEnrichmentStats>,
387)> {
388    let mut sboms = Vec::with_capacity(paths.len());
389    for path in paths {
390        let parsed = parse_sbom_with_context(path, quiet)?;
391        sboms.push(parsed.into_sbom());
392    }
393    let stats = enrich_sboms(&mut sboms, enrichment, quiet);
394    Ok((sboms, stats))
395}
396
397/// Parse multiple SBOMs without enrichment.
398///
399/// Used by the query command where enrichment is handled separately.
400pub(crate) fn parse_multiple_sboms(paths: &[PathBuf]) -> Result<Vec<NormalizedSbom>> {
401    let mut sboms = Vec::with_capacity(paths.len());
402    for path in paths {
403        let parsed = parse_sbom_with_context(path, false)?;
404        sboms.push(parsed.into_sbom());
405    }
406    Ok(sboms)
407}
408
409/// How pairwise diffs should be interpreted when aggregating gate counts.
410#[derive(Clone, Copy, PartialEq, Eq)]
411enum PairDirection {
412    /// Pairs have a meaningful old→new orientation (diff-multi: baseline vs
413    /// target; timeline: chronological order). Only vulnerabilities
414    /// introduced in that direction count.
415    Ordered,
416    /// Pairs are unordered (matrix: the engine computes only the i<j upper
417    /// triangle, so which side is "old" depends on argv order). A vuln
418    /// present in exactly one side of a pair counts regardless of direction:
419    /// `resolved` in diff(a,b) is `introduced` in diff(b,a), so both are
420    /// counted to make `--fail-on-vuln` symmetric — `matrix a b` and
421    /// `matrix b a` must agree.
422    Unordered,
423}
424
425/// Determine the exit code for a multi-SBOM command from its pairwise diffs.
426///
427/// Aggregates VEX gaps, introduced vulnerabilities, and total changes across
428/// every pairwise [`DiffResult`] the command produced. Priority mirrors the
429/// single-SBOM `diff` gate (highest code wins): VEX gaps (4) > vulns (2) >
430/// changes (1). `--fail-on-vex-gap` is checked first because it is the most
431/// specific signal a user can ask for.
432fn determine_multi_exit_code<'a, I>(
433    behavior: &crate::config::BehaviorConfig,
434    filtering: &FilterConfig,
435    diffs: I,
436    direction: PairDirection,
437) -> i32
438where
439    I: IntoIterator<Item = &'a DiffResult>,
440{
441    let mut total_introduced = 0usize;
442    let mut total_changes = 0usize;
443    let mut total_gaps = 0usize;
444    let mut introduced_gaps = 0usize;
445    let mut persistent_gaps = 0usize;
446
447    for diff in diffs {
448        total_introduced += diff.summary.vulnerabilities_introduced;
449        if direction == PairDirection::Unordered {
450            // Reverse-direction introductions surface as "resolved" in the
451            // single computed orientation of an unordered pair.
452            total_introduced += diff.summary.vulnerabilities_resolved;
453        }
454        total_changes += diff.summary.total_changes;
455        if filtering.fail_on_vex_gap {
456            let vex = diff.vulnerabilities.vex_summary();
457            introduced_gaps += vex.introduced_without_vex;
458            persistent_gaps += vex.persistent_without_vex;
459            total_gaps += vex.introduced_without_vex + vex.persistent_without_vex;
460        }
461    }
462
463    if filtering.fail_on_vex_gap && total_gaps > 0 {
464        eprintln!(
465            "VEX gap: {total_gaps} vulnerability(ies) lack VEX statements \
466             ({introduced_gaps} introduced, {persistent_gaps} persistent)",
467        );
468        return exit_codes::VEX_GAPS_FOUND;
469    }
470    if behavior.fail_on_vuln && total_introduced > 0 {
471        return exit_codes::VULNS_INTRODUCED;
472    }
473    if behavior.fail_on_change && total_changes > 0 {
474        return exit_codes::CHANGES_DETECTED;
475    }
476    exit_codes::SUCCESS
477}
478
479/// Get fuzzy matching config from preset name
480fn get_fuzzy_config(preset: &crate::config::FuzzyPreset) -> FuzzyMatchConfig {
481    FuzzyMatchConfig::from_preset(preset.as_str()).unwrap_or_else(|| {
482        // Enum guarantees valid preset, but from_preset may not know all variants
483        FuzzyMatchConfig::balanced()
484    })
485}
486
487/// Get SBOM name from path
488pub(crate) fn get_sbom_name(path: &Path) -> String {
489    path.file_stem().map_or_else(
490        || "unknown".to_string(),
491        |s| s.to_string_lossy().to_string(),
492    )
493}
494
495/// Prepare SBOM references with names and paths
496fn prepare_sbom_refs<'a>(
497    sboms: &'a [NormalizedSbom],
498    paths: &[PathBuf],
499) -> Vec<(&'a NormalizedSbom, String, String)> {
500    let names = unique_sbom_names(paths);
501    sboms
502        .iter()
503        .zip(names)
504        .zip(paths.iter())
505        .map(|((sbom, name), path)| {
506            let path_str = path.to_string_lossy().to_string();
507            (sbom, name, path_str)
508        })
509        .collect()
510}
511
512/// Derive display names from paths, guaranteed unique.
513///
514/// The multi-diff engine keys version spreads and the vulnerability matrix by
515/// display name, so duplicate names (the natural `v1/app.json v2/app.json`
516/// layout collapses to one "app" key) silently erase spreads and corrupt the
517/// matrix. Colliding stems get their parent directory prepended; anything
518/// still colliding gets a positional ordinal.
519pub(crate) fn unique_sbom_names(paths: &[PathBuf]) -> Vec<String> {
520    use std::collections::HashMap;
521
522    let stems: Vec<String> = paths.iter().map(|p| get_sbom_name(p)).collect();
523    let mut counts: HashMap<&str, usize> = HashMap::new();
524    for stem in &stems {
525        *counts.entry(stem.as_str()).or_default() += 1;
526    }
527
528    let mut names: Vec<String> = stems
529        .iter()
530        .zip(paths.iter())
531        .map(|(stem, path)| {
532            if counts[stem.as_str()] > 1 {
533                match path.parent().and_then(|p| p.file_name()) {
534                    Some(parent) => format!("{}/{}", parent.to_string_lossy(), stem),
535                    None => stem.clone(),
536                }
537            } else {
538                stem.clone()
539            }
540        })
541        .collect();
542
543    // Ordinal fallback for anything still colliding (same parent dir name).
544    // Ordinals are bumped until the generated name is unused ANYWHERE in the
545    // final set — a plain " (1)" suffix can collide with a literal stem like
546    // "app (1).json", silently re-collapsing the name-keyed maps this
547    // function exists to protect.
548    let mut totals: HashMap<String, usize> = HashMap::new();
549    for name in &names {
550        *totals.entry(name.clone()).or_default() += 1;
551    }
552    let mut taken: std::collections::HashSet<String> = names
553        .iter()
554        .filter(|n| totals[n.as_str()] == 1)
555        .cloned()
556        .collect();
557    for name in &mut names {
558        if totals[name.as_str()] > 1 {
559            let mut ordinal = 1;
560            while taken.contains(&format!("{name} ({ordinal})")) {
561                ordinal += 1;
562            }
563            *name = format!("{name} ({ordinal})");
564            taken.insert(name.clone());
565        }
566    }
567    names
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn test_get_fuzzy_config_valid_presets() {
576        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Strict);
577        assert!(config.threshold > 0.8);
578
579        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Balanced);
580        assert!(config.threshold >= 0.7 && config.threshold <= 0.85);
581
582        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Permissive);
583        assert!(config.threshold <= 0.70);
584    }
585
586    #[test]
587    fn test_get_sbom_name() {
588        let path = PathBuf::from("/path/to/my-sbom.cdx.json");
589        assert_eq!(get_sbom_name(&path), "my-sbom.cdx");
590
591        let path = PathBuf::from("simple.json");
592        assert_eq!(get_sbom_name(&path), "simple");
593    }
594
595    /// Duplicate file stems (v1/app.json v2/app.json — the natural way to
596    /// snapshot one application) previously collapsed to one HashMap key in
597    /// the engine, erasing version spreads and corrupting the vulnerability
598    /// matrix.
599    #[test]
600    fn test_unique_sbom_names_disambiguates_duplicates() {
601        let paths = vec![
602            PathBuf::from("v1/app.json"),
603            PathBuf::from("v2/app.json"),
604            PathBuf::from("v3/app.json"),
605        ];
606        assert_eq!(
607            unique_sbom_names(&paths),
608            vec!["v1/app", "v2/app", "v3/app"]
609        );
610
611        // Same parent directory: ordinal fallback
612        let paths = vec![PathBuf::from("a/x.json"), PathBuf::from("a/x.json")];
613        assert_eq!(unique_sbom_names(&paths), vec!["a/x (1)", "a/x (2)"]);
614
615        // Distinct stems stay untouched
616        let paths = vec![PathBuf::from("old.json"), PathBuf::from("new.json")];
617        assert_eq!(unique_sbom_names(&paths), vec!["old", "new"]);
618
619        // Audit regression: an ordinal-suffixed name must not collide with a
620        // literal stem — "app (1).json" is a real browser-download name.
621        let paths = vec![
622            PathBuf::from("app.json"),
623            PathBuf::from("app.xml"),
624            PathBuf::from("app (1).json"),
625        ];
626        let names = unique_sbom_names(&paths);
627        let unique: std::collections::HashSet<_> = names.iter().collect();
628        assert_eq!(
629            unique.len(),
630            names.len(),
631            "names must be unique even against literal ordinal-style stems: {names:?}"
632        );
633    }
634
635    #[test]
636    fn test_prepare_sbom_refs() {
637        let sbom1 = NormalizedSbom::default();
638        let sbom2 = NormalizedSbom::default();
639        let sboms = vec![sbom1, sbom2];
640        let paths = vec![PathBuf::from("first.json"), PathBuf::from("second.json")];
641
642        let refs = prepare_sbom_refs(&sboms, &paths);
643        assert_eq!(refs.len(), 2);
644        assert_eq!(refs[0].1, "first");
645        assert_eq!(refs[1].1, "second");
646    }
647
648    fn output_config(format: ReportFormat, file: Option<PathBuf>) -> crate::config::OutputConfig {
649        crate::config::OutputConfig {
650            format,
651            file,
652            ..Default::default()
653        }
654    }
655
656    #[test]
657    fn resolve_multi_output_accepts_tui_and_json() {
658        assert!(matches!(
659            resolve_multi_output(&output_config(ReportFormat::Tui, None)).unwrap(),
660            MultiOutput::Tui
661        ));
662        assert!(matches!(
663            resolve_multi_output(&output_config(ReportFormat::Json, None)).unwrap(),
664            MultiOutput::Json(_)
665        ));
666    }
667
668    #[test]
669    fn resolve_multi_output_auto_to_file_is_json() {
670        // Writing to a file is never a TTY, so `auto` must resolve to JSON.
671        let cfg = output_config(ReportFormat::Auto, Some(PathBuf::from("/tmp/out.json")));
672        assert!(matches!(
673            resolve_multi_output(&cfg).unwrap(),
674            MultiOutput::Json(_)
675        ));
676    }
677
678    #[test]
679    fn resolve_multi_output_rejects_unsupported_formats() {
680        for fmt in [
681            ReportFormat::Table,
682            ReportFormat::Markdown,
683            ReportFormat::Summary,
684            ReportFormat::Sarif,
685            ReportFormat::Html,
686            ReportFormat::Csv,
687            ReportFormat::SideBySide,
688        ] {
689            let result = resolve_multi_output(&output_config(fmt, None));
690            let msg = match result {
691                Ok(_) => panic!("format {fmt} must be rejected"),
692                Err(e) => e.to_string(),
693            };
694            assert!(
695                msg.contains("not supported for multi-SBOM commands"),
696                "{msg}"
697            );
698            assert!(msg.contains("tui, json"), "{msg}");
699        }
700    }
701
702    #[test]
703    fn determine_multi_exit_code_change_gate() {
704        let mut diff = DiffResult::new();
705        diff.summary.total_changes = 3;
706        let behavior = crate::config::BehaviorConfig {
707            fail_on_change: true,
708            ..Default::default()
709        };
710        let filtering = FilterConfig::default();
711        assert_eq!(
712            determine_multi_exit_code(
713                &behavior,
714                &filtering,
715                std::iter::once(&diff),
716                PairDirection::Ordered
717            ),
718            exit_codes::CHANGES_DETECTED
719        );
720
721        // Without the gate flag, the same diff is success.
722        let behavior = crate::config::BehaviorConfig::default();
723        assert_eq!(
724            determine_multi_exit_code(
725                &behavior,
726                &filtering,
727                std::iter::once(&diff),
728                PairDirection::Ordered
729            ),
730            exit_codes::SUCCESS
731        );
732    }
733
734    /// Matrix pairs are unordered (the engine only computes the i<j upper
735    /// triangle), so `--fail-on-vuln` must fire when a vuln exists in either
736    /// side of a pair but not the other — otherwise `matrix a b` and
737    /// `matrix b a` disagree on the exit code.
738    #[test]
739    fn determine_multi_exit_code_vuln_gate_symmetric_for_unordered_pairs() {
740        let behavior = crate::config::BehaviorConfig {
741            fail_on_vuln: true,
742            ..Default::default()
743        };
744        let filtering = FilterConfig::default();
745
746        // `matrix a vuln.json` computes diff(a, vuln): vulns are "introduced".
747        let mut forward = DiffResult::new();
748        forward.summary.vulnerabilities_introduced = 1;
749        // `matrix vuln.json a` computes diff(vuln, a): same vulns are "resolved".
750        let mut reverse = DiffResult::new();
751        reverse.summary.vulnerabilities_resolved = 1;
752
753        for diff in [&forward, &reverse] {
754            assert_eq!(
755                determine_multi_exit_code(
756                    &behavior,
757                    &filtering,
758                    std::iter::once(diff),
759                    PairDirection::Unordered
760                ),
761                exit_codes::VULNS_INTRODUCED,
762                "matrix vuln gate must be argument-order independent"
763            );
764        }
765
766        // Ordered commands (diff-multi/timeline) keep directional semantics:
767        // a resolved vuln is progress, not a gate failure.
768        assert_eq!(
769            determine_multi_exit_code(
770                &behavior,
771                &filtering,
772                std::iter::once(&reverse),
773                PairDirection::Ordered
774            ),
775            exit_codes::SUCCESS
776        );
777    }
778}