1use crate::config::{MatrixConfig, MultiDiffConfig, TimelineConfig};
7use crate::diff::MultiDiffEngine;
8use crate::matching::FuzzyMatchConfig;
9use crate::model::NormalizedSbom;
10use crate::pipeline::{
11 OutputTarget, auto_detect_format, enrich_sbom_full, enrich_sboms, exit_codes,
12 parse_sbom_with_context, write_output,
13};
14use crate::reports::ReportFormat;
15use crate::tui::{App, run_tui};
16use anyhow::{Result, bail};
17use std::path::{Path, PathBuf};
18
19fn resolve_output(output: &crate::config::OutputConfig) -> (OutputTarget, ReportFormat) {
21 let target = OutputTarget::from_option(output.file.clone());
22 let format = auto_detect_format(output.format, &target);
23 (target, format)
24}
25
26#[allow(clippy::needless_pass_by_value)]
28pub fn run_diff_multi(config: MultiDiffConfig) -> Result<i32> {
29 let quiet = config.behavior.quiet;
30
31 let mut baseline_parsed = parse_sbom_with_context(&config.baseline, quiet)?;
33 let (target_sboms, target_stats) =
35 parse_and_enrich_sboms(&config.targets, &config.enrichment, quiet)?;
36
37 let baseline_stats = enrich_sbom_full(baseline_parsed.sbom_mut(), &config.enrichment, quiet);
39
40 tracing::info!(
41 "Comparing baseline ({} components) against {} targets",
42 baseline_parsed.sbom().component_count(),
43 target_sboms.len()
44 );
45
46 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
47
48 let targets = prepare_sbom_refs(&target_sboms, &config.targets);
50 let target_refs: Vec<_> = targets
51 .iter()
52 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
53 .collect();
54
55 let mut engine = MultiDiffEngine::new()
57 .with_fuzzy_config(fuzzy_config)
58 .include_unchanged(config.matching.include_unchanged);
59 if config.graph_diff.enabled {
60 engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
61 }
62
63 let baseline_name = get_sbom_name(&config.baseline);
64
65 let result = engine.diff_multi(
66 baseline_parsed.sbom(),
67 &baseline_name,
68 &config.baseline.to_string_lossy(),
69 &target_refs,
70 );
71
72 tracing::info!(
73 "Multi-diff complete: {} comparisons, max deviation: {:.1}%",
74 result.comparisons.len(),
75 result.summary.max_deviation * 100.0
76 );
77
78 let exit_code = determine_multi_exit_code(&config.behavior, &result);
80
81 let (output_target, effective_output) = resolve_output(&config.output);
83
84 if effective_output == ReportFormat::Tui {
85 let mut app = App::new_multi_diff(result);
86 app.export_template = config.output.export_template.clone();
87
88 let all_warnings: Vec<_> = std::iter::once(&baseline_stats)
90 .chain(target_stats.iter())
91 .flat_map(|s| s.warnings.iter())
92 .collect();
93 if !all_warnings.is_empty() {
94 app.set_status_message(format!(
95 "Warning: {}",
96 all_warnings
97 .iter()
98 .map(|s| s.as_str())
99 .collect::<Vec<_>>()
100 .join(", ")
101 ));
102 app.status_sticky = true;
103 }
104
105 run_tui(&mut app)?;
106 } else {
107 let json = serde_json::to_string_pretty(&result)?;
108 write_output(&json, &output_target, quiet)?;
109 }
110
111 Ok(exit_code)
112}
113
114#[allow(clippy::needless_pass_by_value)]
116pub fn run_timeline(config: TimelineConfig) -> Result<i32> {
117 let quiet = config.behavior.quiet;
118
119 if config.sbom_paths.len() < 2 {
120 bail!("Timeline analysis requires at least 2 SBOMs");
121 }
122
123 let (sboms, _enrich_stats) =
124 parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
125
126 tracing::info!("Analyzing timeline of {} SBOMs", sboms.len());
127
128 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
129
130 let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
132 let sbom_refs: Vec<_> = sbom_data
133 .iter()
134 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
135 .collect();
136
137 let mut engine = MultiDiffEngine::new().with_fuzzy_config(fuzzy_config);
139 if config.graph_diff.enabled {
140 engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
141 }
142 let result = engine.timeline(&sbom_refs);
143
144 tracing::info!(
145 "Timeline analysis complete: {} incremental diffs",
146 result.incremental_diffs.len()
147 );
148
149 let (output_target, effective_output) = resolve_output(&config.output);
151
152 let exit_code = determine_timeline_exit_code(&config.behavior, &result);
154
155 if effective_output == ReportFormat::Tui {
156 let mut app = App::new_timeline(result);
157 run_tui(&mut app)?;
158 } else {
159 let json = serde_json::to_string_pretty(&result)?;
160 write_output(&json, &output_target, quiet)?;
161 }
162
163 Ok(exit_code)
164}
165
166#[allow(clippy::needless_pass_by_value)]
168pub fn run_matrix(config: MatrixConfig) -> Result<i32> {
169 let quiet = config.behavior.quiet;
170
171 if config.sbom_paths.len() < 2 {
172 bail!("Matrix comparison requires at least 2 SBOMs");
173 }
174
175 let (sboms, _enrich_stats) =
176 parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
177
178 tracing::info!(
179 "Computing {}x{} comparison matrix",
180 sboms.len(),
181 sboms.len()
182 );
183
184 let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
185
186 let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
188 let sbom_refs: Vec<_> = sbom_data
189 .iter()
190 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
191 .collect();
192
193 let mut engine = MultiDiffEngine::new().with_fuzzy_config(fuzzy_config);
195 if config.graph_diff.enabled {
196 engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
197 }
198 let result = engine.matrix(&sbom_refs, Some(config.cluster_threshold));
199
200 tracing::info!(
201 "Matrix comparison complete: {} pairs computed",
202 result.num_pairs()
203 );
204
205 if let Some(ref clustering) = result.clustering {
206 tracing::info!(
207 "Found {} clusters, {} outliers",
208 clustering.clusters.len(),
209 clustering.outliers.len()
210 );
211 }
212
213 let (output_target, effective_output) = resolve_output(&config.output);
215
216 let exit_code = determine_matrix_exit_code(&config.behavior, &result);
218
219 if effective_output == ReportFormat::Tui {
220 let mut app = App::new_matrix(result);
221 run_tui(&mut app)?;
222 } else {
223 let json = serde_json::to_string_pretty(&result)?;
224 write_output(&json, &output_target, quiet)?;
225 }
226
227 Ok(exit_code)
228}
229
230fn parse_and_enrich_sboms(
232 paths: &[PathBuf],
233 enrichment: &crate::config::EnrichmentConfig,
234 quiet: bool,
235) -> Result<(
236 Vec<NormalizedSbom>,
237 Vec<crate::pipeline::AggregatedEnrichmentStats>,
238)> {
239 let mut sboms = Vec::with_capacity(paths.len());
240 for path in paths {
241 let parsed = parse_sbom_with_context(path, quiet)?;
242 sboms.push(parsed.into_sbom());
243 }
244 let stats = enrich_sboms(&mut sboms, enrichment, quiet);
245 Ok((sboms, stats))
246}
247
248pub(crate) fn parse_multiple_sboms(paths: &[PathBuf]) -> Result<Vec<NormalizedSbom>> {
252 let mut sboms = Vec::with_capacity(paths.len());
253 for path in paths {
254 let parsed = parse_sbom_with_context(path, false)?;
255 sboms.push(parsed.into_sbom());
256 }
257 Ok(sboms)
258}
259
260fn determine_multi_exit_code(
262 behavior: &crate::config::BehaviorConfig,
263 result: &crate::diff::MultiDiffResult,
264) -> i32 {
265 let (total_introduced, total_changes) =
266 result
267 .comparisons
268 .iter()
269 .fold((0usize, 0usize), |(vi, tc), c| {
270 (
271 vi + c.diff.summary.vulnerabilities_introduced,
272 tc + c.diff.summary.total_changes,
273 )
274 });
275
276 if behavior.fail_on_vuln && total_introduced > 0 {
277 return exit_codes::VULNS_INTRODUCED;
278 }
279 if behavior.fail_on_change && total_changes > 0 {
280 return exit_codes::CHANGES_DETECTED;
281 }
282 exit_codes::SUCCESS
283}
284
285fn determine_timeline_exit_code(
287 behavior: &crate::config::BehaviorConfig,
288 result: &crate::diff::TimelineResult,
289) -> i32 {
290 if behavior.fail_on_vuln {
291 let total_introduced: usize = result
292 .incremental_diffs
293 .iter()
294 .map(|d| d.summary.vulnerabilities_introduced)
295 .sum();
296 if total_introduced > 0 {
297 return exit_codes::VULNS_INTRODUCED;
298 }
299 }
300 if behavior.fail_on_change {
301 let total_changes: usize = result
302 .incremental_diffs
303 .iter()
304 .map(|d| d.summary.total_changes)
305 .sum();
306 if total_changes > 0 {
307 return exit_codes::CHANGES_DETECTED;
308 }
309 }
310 exit_codes::SUCCESS
311}
312
313fn determine_matrix_exit_code(
315 behavior: &crate::config::BehaviorConfig,
316 result: &crate::diff::MatrixResult,
317) -> i32 {
318 if behavior.fail_on_vuln {
319 let total_introduced: usize = result
320 .diffs
321 .iter()
322 .flatten()
323 .map(|d| d.summary.vulnerabilities_introduced)
324 .sum();
325 if total_introduced > 0 {
326 return exit_codes::VULNS_INTRODUCED;
327 }
328 }
329 if behavior.fail_on_change {
330 let total_changes: usize = result
331 .diffs
332 .iter()
333 .flatten()
334 .map(|d| d.summary.total_changes)
335 .sum();
336 if total_changes > 0 {
337 return exit_codes::CHANGES_DETECTED;
338 }
339 }
340 exit_codes::SUCCESS
341}
342
343fn get_fuzzy_config(preset: &str) -> FuzzyMatchConfig {
345 FuzzyMatchConfig::from_preset(preset).unwrap_or_else(|| {
346 tracing::warn!(
347 "Unknown fuzzy preset '{}', using 'balanced'. Valid options: strict, balanced, permissive",
348 preset
349 );
350 FuzzyMatchConfig::balanced()
351 })
352}
353
354pub(crate) fn get_sbom_name(path: &Path) -> String {
356 path.file_stem().map_or_else(
357 || "unknown".to_string(),
358 |s| s.to_string_lossy().to_string(),
359 )
360}
361
362fn prepare_sbom_refs<'a>(
364 sboms: &'a [NormalizedSbom],
365 paths: &[PathBuf],
366) -> Vec<(&'a NormalizedSbom, String, String)> {
367 sboms
368 .iter()
369 .zip(paths.iter())
370 .map(|(sbom, path)| {
371 let name = get_sbom_name(path);
372 let path_str = path.to_string_lossy().to_string();
373 (sbom, name, path_str)
374 })
375 .collect()
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn test_get_fuzzy_config_valid_presets() {
384 let config = get_fuzzy_config("strict");
385 assert!(config.threshold > 0.8);
386
387 let config = get_fuzzy_config("balanced");
388 assert!(config.threshold >= 0.7 && config.threshold <= 0.85);
389
390 let config = get_fuzzy_config("permissive");
391 assert!(config.threshold <= 0.70);
392 }
393
394 #[test]
395 fn test_get_fuzzy_config_invalid_preset() {
396 let config = get_fuzzy_config("invalid");
398 let balanced = FuzzyMatchConfig::balanced();
399 assert_eq!(config.threshold, balanced.threshold);
400 }
401
402 #[test]
403 fn test_get_sbom_name() {
404 let path = PathBuf::from("/path/to/my-sbom.cdx.json");
405 assert_eq!(get_sbom_name(&path), "my-sbom.cdx");
406
407 let path = PathBuf::from("simple.json");
408 assert_eq!(get_sbom_name(&path), "simple");
409 }
410
411 #[test]
412 fn test_prepare_sbom_refs() {
413 let sbom1 = NormalizedSbom::default();
414 let sbom2 = NormalizedSbom::default();
415 let sboms = vec![sbom1, sbom2];
416 let paths = vec![PathBuf::from("first.json"), PathBuf::from("second.json")];
417
418 let refs = prepare_sbom_refs(&sboms, &paths);
419 assert_eq!(refs.len(), 2);
420 assert_eq!(refs[0].1, "first");
421 assert_eq!(refs[1].1, "second");
422 }
423}