1use 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, write_output,
16};
17use crate::reports::ReportFormat;
18use crate::tui::{App, run_tui};
19use anyhow::{Result, bail};
20use std::path::{Path, PathBuf};
21
22enum MultiOutput {
24 Tui,
26 Json(OutputTarget),
28}
29
30fn resolve_multi_output(output: &crate::config::OutputConfig) -> Result<MultiOutput> {
38 let target = OutputTarget::from_option(output.file.clone());
39 match output.format {
40 ReportFormat::Tui => Ok(MultiOutput::Tui),
41 ReportFormat::Json => Ok(MultiOutput::Json(target)),
42 ReportFormat::Auto => match auto_detect_format(ReportFormat::Auto, &target) {
43 ReportFormat::Tui => Ok(MultiOutput::Tui),
44 _ => Ok(MultiOutput::Json(target)),
47 },
48 other => bail!(
49 "output format '{other}' is not supported for multi-SBOM commands \
50 (diff-multi/timeline/matrix); supported formats: tui, json"
51 ),
52 }
53}
54
55fn load_multi_rules(rules: &MatchingRulesPathConfig) -> Option<MatchingRulesConfig> {
59 let path = rules.rules_file.as_ref()?;
60 match MatchingRulesConfig::from_file(path) {
61 Ok(loaded) => {
62 if rules.dry_run {
63 tracing::info!("Dry-run mode: matching rules parsed but not applied");
64 None
65 } else {
66 Some(loaded)
67 }
68 }
69 Err(e) => {
70 tracing::warn!("Failed to load matching rules: {e}");
71 None
72 }
73 }
74}
75
76fn build_multi_engine(
79 fuzzy_config: FuzzyMatchConfig,
80 include_unchanged: bool,
81 graph: &GraphAwareDiffConfig,
82 rules: &MatchingRulesPathConfig,
83) -> MultiDiffEngine {
84 let mut engine = MultiDiffEngine::new()
85 .with_fuzzy_config(fuzzy_config)
86 .include_unchanged(include_unchanged);
87 if graph.enabled {
88 engine = engine.with_graph_diff(graph_diff_config_from(graph));
89 }
90 if let Some(loaded) = load_multi_rules(rules) {
91 engine = engine.with_matching_rules(loaded);
92 }
93 engine
94}
95
96#[allow(clippy::needless_pass_by_value)]
98pub fn run_diff_multi(config: MultiDiffConfig) -> Result<i32> {
99 let quiet = config.behavior.quiet;
100
101 let mut baseline_parsed = parse_sbom_with_context(&config.baseline, quiet)?;
103 let (target_sboms, target_stats) =
105 parse_and_enrich_sboms(&config.targets, &config.enrichment, quiet)?;
106
107 let baseline_stats = enrich_sbom_full(baseline_parsed.sbom_mut(), &config.enrichment, quiet);
109
110 tracing::info!(
111 "Comparing baseline ({} components) against {} targets",
112 baseline_parsed.sbom().component_count(),
113 target_sboms.len()
114 );
115
116 let output_mode = resolve_multi_output(&config.output)?;
118
119 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
120
121 let targets = prepare_sbom_refs(&target_sboms, &config.targets);
123 let target_refs: Vec<_> = targets
124 .iter()
125 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
126 .collect();
127
128 let mut engine = build_multi_engine(
130 fuzzy_config,
131 config.matching.include_unchanged,
132 &config.graph_diff,
133 &config.rules,
134 );
135
136 let baseline_name = get_sbom_name(&config.baseline);
137
138 let mut result = engine.diff_multi(
139 baseline_parsed.sbom(),
140 &baseline_name,
141 &config.baseline.to_string_lossy(),
142 &target_refs,
143 )?;
144
145 for comparison in &mut result.comparisons {
147 apply_post_diff_filters(&mut comparison.diff, &config.filtering, &config.graph_diff);
148 }
149
150 tracing::info!(
151 "Multi-diff complete: {} comparisons, max deviation: {:.1}%",
152 result.comparisons.len(),
153 result.summary.max_deviation * 100.0
154 );
155
156 let exit_code = determine_multi_exit_code(
158 &config.behavior,
159 &config.filtering,
160 result.comparisons.iter().map(|c| &c.diff),
161 );
162
163 if let MultiOutput::Json(ref output_target) = output_mode {
164 let json = serde_json::to_string_pretty(&result)?;
165 write_output(&json, output_target, quiet)?;
166 } else {
167 let mut app = App::new_multi_diff(result);
168 app.export_template = config.output.export_template.clone();
169
170 let all_warnings: Vec<_> = std::iter::once(&baseline_stats)
172 .chain(target_stats.iter())
173 .flat_map(|s| s.warnings.iter())
174 .collect();
175 if !all_warnings.is_empty() {
176 app.set_status_message(format!(
177 "Warning: {}",
178 all_warnings
179 .iter()
180 .map(|s| s.as_str())
181 .collect::<Vec<_>>()
182 .join(", ")
183 ));
184 app.status_sticky = true;
185 }
186
187 run_tui(&mut app)?;
188 }
189
190 Ok(exit_code)
191}
192
193#[allow(clippy::needless_pass_by_value)]
195pub fn run_timeline(config: TimelineConfig) -> Result<i32> {
196 let quiet = config.behavior.quiet;
197
198 if config.sbom_paths.len() < 2 {
199 bail!("Timeline analysis requires at least 2 SBOMs");
200 }
201
202 let output_mode = resolve_multi_output(&config.output)?;
204
205 let (sboms, _enrich_stats) =
206 parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
207
208 tracing::info!("Analyzing timeline of {} SBOMs", sboms.len());
209
210 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
211
212 let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
214 let sbom_refs: Vec<_> = sbom_data
215 .iter()
216 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
217 .collect();
218
219 let mut engine = build_multi_engine(
221 fuzzy_config,
222 config.matching.include_unchanged,
223 &config.graph_diff,
224 &config.rules,
225 );
226 let mut result = engine.timeline(&sbom_refs)?;
227
228 for diff in result
230 .incremental_diffs
231 .iter_mut()
232 .chain(result.cumulative_from_first.iter_mut())
233 {
234 apply_post_diff_filters(diff, &config.filtering, &config.graph_diff);
235 }
236
237 tracing::info!(
238 "Timeline analysis complete: {} incremental diffs",
239 result.incremental_diffs.len()
240 );
241
242 let exit_code = determine_multi_exit_code(
244 &config.behavior,
245 &config.filtering,
246 result.incremental_diffs.iter(),
247 );
248
249 if let MultiOutput::Json(ref output_target) = output_mode {
250 let json = serde_json::to_string_pretty(&result)?;
251 write_output(&json, output_target, quiet)?;
252 } else {
253 let mut app = App::new_timeline(result);
254 run_tui(&mut app)?;
255 }
256
257 Ok(exit_code)
258}
259
260#[allow(clippy::needless_pass_by_value)]
262pub fn run_matrix(config: MatrixConfig) -> Result<i32> {
263 let quiet = config.behavior.quiet;
264
265 if config.sbom_paths.len() < 2 {
266 bail!("Matrix comparison requires at least 2 SBOMs");
267 }
268
269 let output_mode = resolve_multi_output(&config.output)?;
271
272 let (sboms, _enrich_stats) =
273 parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
274
275 tracing::info!(
276 "Computing {}x{} comparison matrix",
277 sboms.len(),
278 sboms.len()
279 );
280
281 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
282
283 let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
285 let sbom_refs: Vec<_> = sbom_data
286 .iter()
287 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
288 .collect();
289
290 let mut engine = build_multi_engine(
292 fuzzy_config,
293 config.matching.include_unchanged,
294 &config.graph_diff,
295 &config.rules,
296 );
297 let mut result = engine.matrix(&sbom_refs, Some(config.cluster_threshold))?;
298
299 for diff in result.diffs.iter_mut().flatten() {
301 apply_post_diff_filters(diff, &config.filtering, &config.graph_diff);
302 }
303
304 tracing::info!(
305 "Matrix comparison complete: {} pairs computed",
306 result.num_pairs()
307 );
308
309 if let Some(ref clustering) = result.clustering {
310 tracing::info!(
311 "Found {} clusters, {} outliers",
312 clustering.clusters.len(),
313 clustering.outliers.len()
314 );
315 }
316
317 let exit_code = determine_multi_exit_code(
319 &config.behavior,
320 &config.filtering,
321 result.diffs.iter().flatten(),
322 );
323
324 if let MultiOutput::Json(ref output_target) = output_mode {
325 let json = serde_json::to_string_pretty(&result)?;
326 write_output(&json, output_target, quiet)?;
327 } else {
328 let mut app = App::new_matrix(result);
329 run_tui(&mut app)?;
330 }
331
332 Ok(exit_code)
333}
334
335fn parse_and_enrich_sboms(
337 paths: &[PathBuf],
338 enrichment: &crate::config::EnrichmentConfig,
339 quiet: bool,
340) -> Result<(
341 Vec<NormalizedSbom>,
342 Vec<crate::pipeline::AggregatedEnrichmentStats>,
343)> {
344 let mut sboms = Vec::with_capacity(paths.len());
345 for path in paths {
346 let parsed = parse_sbom_with_context(path, quiet)?;
347 sboms.push(parsed.into_sbom());
348 }
349 let stats = enrich_sboms(&mut sboms, enrichment, quiet);
350 Ok((sboms, stats))
351}
352
353pub(crate) fn parse_multiple_sboms(paths: &[PathBuf]) -> Result<Vec<NormalizedSbom>> {
357 let mut sboms = Vec::with_capacity(paths.len());
358 for path in paths {
359 let parsed = parse_sbom_with_context(path, false)?;
360 sboms.push(parsed.into_sbom());
361 }
362 Ok(sboms)
363}
364
365fn determine_multi_exit_code<'a, I>(
373 behavior: &crate::config::BehaviorConfig,
374 filtering: &FilterConfig,
375 diffs: I,
376) -> i32
377where
378 I: IntoIterator<Item = &'a DiffResult>,
379{
380 let mut total_introduced = 0usize;
381 let mut total_changes = 0usize;
382 let mut total_gaps = 0usize;
383 let mut introduced_gaps = 0usize;
384 let mut persistent_gaps = 0usize;
385
386 for diff in diffs {
387 total_introduced += diff.summary.vulnerabilities_introduced;
388 total_changes += diff.summary.total_changes;
389 if filtering.fail_on_vex_gap {
390 let vex = diff.vulnerabilities.vex_summary();
391 introduced_gaps += vex.introduced_without_vex;
392 persistent_gaps += vex.persistent_without_vex;
393 total_gaps += vex.introduced_without_vex + vex.persistent_without_vex;
394 }
395 }
396
397 if filtering.fail_on_vex_gap && total_gaps > 0 {
398 eprintln!(
399 "VEX gap: {total_gaps} vulnerability(ies) lack VEX statements \
400 ({introduced_gaps} introduced, {persistent_gaps} persistent)",
401 );
402 return exit_codes::VEX_GAPS_FOUND;
403 }
404 if behavior.fail_on_vuln && total_introduced > 0 {
405 return exit_codes::VULNS_INTRODUCED;
406 }
407 if behavior.fail_on_change && total_changes > 0 {
408 return exit_codes::CHANGES_DETECTED;
409 }
410 exit_codes::SUCCESS
411}
412
413fn get_fuzzy_config(preset: &crate::config::FuzzyPreset) -> FuzzyMatchConfig {
415 FuzzyMatchConfig::from_preset(preset.as_str()).unwrap_or_else(|| {
416 FuzzyMatchConfig::balanced()
418 })
419}
420
421pub(crate) fn get_sbom_name(path: &Path) -> String {
423 path.file_stem().map_or_else(
424 || "unknown".to_string(),
425 |s| s.to_string_lossy().to_string(),
426 )
427}
428
429fn prepare_sbom_refs<'a>(
431 sboms: &'a [NormalizedSbom],
432 paths: &[PathBuf],
433) -> Vec<(&'a NormalizedSbom, String, String)> {
434 sboms
435 .iter()
436 .zip(paths.iter())
437 .map(|(sbom, path)| {
438 let name = get_sbom_name(path);
439 let path_str = path.to_string_lossy().to_string();
440 (sbom, name, path_str)
441 })
442 .collect()
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn test_get_fuzzy_config_valid_presets() {
451 let config = get_fuzzy_config(&crate::config::FuzzyPreset::Strict);
452 assert!(config.threshold > 0.8);
453
454 let config = get_fuzzy_config(&crate::config::FuzzyPreset::Balanced);
455 assert!(config.threshold >= 0.7 && config.threshold <= 0.85);
456
457 let config = get_fuzzy_config(&crate::config::FuzzyPreset::Permissive);
458 assert!(config.threshold <= 0.70);
459 }
460
461 #[test]
462 fn test_get_sbom_name() {
463 let path = PathBuf::from("/path/to/my-sbom.cdx.json");
464 assert_eq!(get_sbom_name(&path), "my-sbom.cdx");
465
466 let path = PathBuf::from("simple.json");
467 assert_eq!(get_sbom_name(&path), "simple");
468 }
469
470 #[test]
471 fn test_prepare_sbom_refs() {
472 let sbom1 = NormalizedSbom::default();
473 let sbom2 = NormalizedSbom::default();
474 let sboms = vec![sbom1, sbom2];
475 let paths = vec![PathBuf::from("first.json"), PathBuf::from("second.json")];
476
477 let refs = prepare_sbom_refs(&sboms, &paths);
478 assert_eq!(refs.len(), 2);
479 assert_eq!(refs[0].1, "first");
480 assert_eq!(refs[1].1, "second");
481 }
482
483 fn output_config(format: ReportFormat, file: Option<PathBuf>) -> crate::config::OutputConfig {
484 crate::config::OutputConfig {
485 format,
486 file,
487 ..Default::default()
488 }
489 }
490
491 #[test]
492 fn resolve_multi_output_accepts_tui_and_json() {
493 assert!(matches!(
494 resolve_multi_output(&output_config(ReportFormat::Tui, None)).unwrap(),
495 MultiOutput::Tui
496 ));
497 assert!(matches!(
498 resolve_multi_output(&output_config(ReportFormat::Json, None)).unwrap(),
499 MultiOutput::Json(_)
500 ));
501 }
502
503 #[test]
504 fn resolve_multi_output_auto_to_file_is_json() {
505 let cfg = output_config(ReportFormat::Auto, Some(PathBuf::from("/tmp/out.json")));
507 assert!(matches!(
508 resolve_multi_output(&cfg).unwrap(),
509 MultiOutput::Json(_)
510 ));
511 }
512
513 #[test]
514 fn resolve_multi_output_rejects_unsupported_formats() {
515 for fmt in [
516 ReportFormat::Table,
517 ReportFormat::Markdown,
518 ReportFormat::Summary,
519 ReportFormat::Sarif,
520 ReportFormat::Html,
521 ReportFormat::Csv,
522 ReportFormat::SideBySide,
523 ] {
524 let result = resolve_multi_output(&output_config(fmt, None));
525 let msg = match result {
526 Ok(_) => panic!("format {fmt} must be rejected"),
527 Err(e) => e.to_string(),
528 };
529 assert!(
530 msg.contains("not supported for multi-SBOM commands"),
531 "{msg}"
532 );
533 assert!(msg.contains("tui, json"), "{msg}");
534 }
535 }
536
537 #[test]
538 fn determine_multi_exit_code_change_gate() {
539 let mut diff = DiffResult::new();
540 diff.summary.total_changes = 3;
541 let behavior = crate::config::BehaviorConfig {
542 fail_on_change: true,
543 ..Default::default()
544 };
545 let filtering = FilterConfig::default();
546 assert_eq!(
547 determine_multi_exit_code(&behavior, &filtering, std::iter::once(&diff)),
548 exit_codes::CHANGES_DETECTED
549 );
550
551 let behavior = crate::config::BehaviorConfig::default();
553 assert_eq!(
554 determine_multi_exit_code(&behavior, &filtering, std::iter::once(&diff)),
555 exit_codes::SUCCESS
556 );
557 }
558}