1use crate::diff::MultiDiffEngine;
7use crate::matching::FuzzyMatchConfig;
8use crate::model::NormalizedSbom;
9use crate::pipeline::{OutputTarget, parse_sbom_with_context, write_output};
10use crate::reports::ReportFormat;
11use crate::tui::{App, run_tui};
12use anyhow::{Result, bail};
13use std::path::{Path, PathBuf};
14
15#[allow(clippy::needless_pass_by_value)]
17pub fn run_diff_multi(
18 baseline_path: PathBuf,
19 target_paths: Vec<PathBuf>,
20 output: ReportFormat,
21 output_file: Option<PathBuf>,
22 fuzzy_preset: String,
23 include_unchanged: bool,
24 graph_diff: bool,
25) -> Result<()> {
26 let baseline_parsed = parse_sbom_with_context(&baseline_path, false)?;
27 let target_sboms = parse_multiple_sboms(&target_paths)?;
28
29 tracing::info!(
30 "Comparing baseline ({} components) against {} targets",
31 baseline_parsed.sbom().component_count(),
32 target_sboms.len()
33 );
34
35 let fuzzy_config = get_fuzzy_config(&fuzzy_preset);
36
37 let targets = prepare_sbom_refs(&target_sboms, &target_paths);
39 let target_refs: Vec<_> = targets
40 .iter()
41 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
42 .collect();
43
44 let mut engine = MultiDiffEngine::new()
46 .with_fuzzy_config(fuzzy_config)
47 .include_unchanged(include_unchanged);
48 if graph_diff {
49 engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
50 }
51
52 let baseline_name = get_sbom_name(&baseline_path);
53
54 let result = engine.diff_multi(
55 baseline_parsed.sbom(),
56 &baseline_name,
57 &baseline_path.to_string_lossy(),
58 &target_refs,
59 );
60
61 tracing::info!(
62 "Multi-diff complete: {} comparisons, max deviation: {:.1}%",
63 result.comparisons.len(),
64 result.summary.max_deviation * 100.0
65 );
66
67 output_multi_result(
69 output,
70 output_file,
71 || {
72 let mut app = App::new_multi_diff(result.clone());
73 run_tui(&mut app).map_err(Into::into)
74 },
75 || serde_json::to_string_pretty(&result).map_err(Into::into),
76 )
77}
78
79#[allow(clippy::needless_pass_by_value)]
81pub fn run_timeline(
82 sbom_paths: Vec<PathBuf>,
83 output: ReportFormat,
84 output_file: Option<PathBuf>,
85 fuzzy_preset: String,
86 graph_diff: bool,
87) -> Result<()> {
88 if sbom_paths.len() < 2 {
89 bail!("Timeline analysis requires at least 2 SBOMs");
90 }
91
92 let sboms = parse_multiple_sboms(&sbom_paths)?;
93
94 tracing::info!("Analyzing timeline of {} SBOMs", sboms.len());
95
96 let fuzzy_config = get_fuzzy_config(&fuzzy_preset);
97
98 let sbom_data = prepare_sbom_refs(&sboms, &sbom_paths);
100 let sbom_refs: Vec<_> = sbom_data
101 .iter()
102 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
103 .collect();
104
105 let mut engine = MultiDiffEngine::new().with_fuzzy_config(fuzzy_config);
107 if graph_diff {
108 engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
109 }
110 let result = engine.timeline(&sbom_refs);
111
112 tracing::info!(
113 "Timeline analysis complete: {} incremental diffs",
114 result.incremental_diffs.len()
115 );
116
117 output_multi_result(
119 output,
120 output_file,
121 || {
122 let mut app = App::new_timeline(result.clone());
123 run_tui(&mut app).map_err(Into::into)
124 },
125 || serde_json::to_string_pretty(&result).map_err(Into::into),
126 )
127}
128
129#[allow(clippy::needless_pass_by_value)]
131pub fn run_matrix(
132 sbom_paths: Vec<PathBuf>,
133 output: ReportFormat,
134 output_file: Option<PathBuf>,
135 fuzzy_preset: String,
136 cluster_threshold: f64,
137 graph_diff: bool,
138) -> Result<()> {
139 if sbom_paths.len() < 2 {
140 bail!("Matrix comparison requires at least 2 SBOMs");
141 }
142
143 let sboms = parse_multiple_sboms(&sbom_paths)?;
144
145 tracing::info!(
146 "Computing {}x{} comparison matrix",
147 sboms.len(),
148 sboms.len()
149 );
150
151 let fuzzy_config = get_fuzzy_config(&fuzzy_preset);
152
153 let sbom_data = prepare_sbom_refs(&sboms, &sbom_paths);
155 let sbom_refs: Vec<_> = sbom_data
156 .iter()
157 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
158 .collect();
159
160 let mut engine = MultiDiffEngine::new().with_fuzzy_config(fuzzy_config);
162 if graph_diff {
163 engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
164 }
165 let result = engine.matrix(&sbom_refs, Some(cluster_threshold));
166
167 tracing::info!(
168 "Matrix comparison complete: {} pairs computed",
169 result.num_pairs()
170 );
171
172 if let Some(ref clustering) = result.clustering {
173 tracing::info!(
174 "Found {} clusters, {} outliers",
175 clustering.clusters.len(),
176 clustering.outliers.len()
177 );
178 }
179
180 output_multi_result(
182 output,
183 output_file,
184 || {
185 let mut app = App::new_matrix(result.clone());
186 run_tui(&mut app).map_err(Into::into)
187 },
188 || serde_json::to_string_pretty(&result).map_err(Into::into),
189 )
190}
191
192pub(crate) fn parse_multiple_sboms(paths: &[PathBuf]) -> Result<Vec<NormalizedSbom>> {
194 let mut sboms = Vec::with_capacity(paths.len());
195 for path in paths {
196 let parsed = parse_sbom_with_context(path, false)?;
197 sboms.push(parsed.into_sbom());
198 }
199 Ok(sboms)
200}
201
202fn get_fuzzy_config(preset: &str) -> FuzzyMatchConfig {
204 FuzzyMatchConfig::from_preset(preset).unwrap_or_else(|| {
205 tracing::warn!(
206 "Unknown fuzzy preset '{}', using 'balanced'. Valid options: strict, balanced, permissive",
207 preset
208 );
209 FuzzyMatchConfig::balanced()
210 })
211}
212
213pub(crate) fn get_sbom_name(path: &Path) -> String {
215 path.file_stem().map_or_else(
216 || "unknown".to_string(),
217 |s| s.to_string_lossy().to_string(),
218 )
219}
220
221fn prepare_sbom_refs<'a>(
223 sboms: &'a [NormalizedSbom],
224 paths: &[PathBuf],
225) -> Vec<(&'a NormalizedSbom, String, String)> {
226 sboms
227 .iter()
228 .zip(paths.iter())
229 .map(|(sbom, path)| {
230 let name = get_sbom_name(path);
231 let path_str = path.to_string_lossy().to_string();
232 (sbom, name, path_str)
233 })
234 .collect()
235}
236
237fn output_multi_result<F, G>(
239 output: ReportFormat,
240 output_file: Option<PathBuf>,
241 run_tui_fn: F,
242 generate_json: G,
243) -> Result<()>
244where
245 F: FnOnce() -> Result<()>,
246 G: FnOnce() -> Result<String>,
247{
248 if output == ReportFormat::Tui {
249 run_tui_fn()
250 } else {
251 let json = generate_json()?;
252 let target = OutputTarget::from_option(output_file);
253 write_output(&json, &target, false)
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_get_fuzzy_config_valid_presets() {
263 let config = get_fuzzy_config("strict");
264 assert!(config.threshold > 0.8);
265
266 let config = get_fuzzy_config("balanced");
267 assert!(config.threshold >= 0.7 && config.threshold <= 0.85);
268
269 let config = get_fuzzy_config("permissive");
270 assert!(config.threshold <= 0.70);
271 }
272
273 #[test]
274 fn test_get_fuzzy_config_invalid_preset() {
275 let config = get_fuzzy_config("invalid");
277 let balanced = FuzzyMatchConfig::balanced();
278 assert_eq!(config.threshold, balanced.threshold);
279 }
280
281 #[test]
282 fn test_get_sbom_name() {
283 let path = PathBuf::from("/path/to/my-sbom.cdx.json");
284 assert_eq!(get_sbom_name(&path), "my-sbom.cdx");
285
286 let path = PathBuf::from("simple.json");
287 assert_eq!(get_sbom_name(&path), "simple");
288 }
289
290 #[test]
291 fn test_prepare_sbom_refs() {
292 let sbom1 = NormalizedSbom::default();
293 let sbom2 = NormalizedSbom::default();
294 let sboms = vec![sbom1, sbom2];
295 let paths = vec![PathBuf::from("first.json"), PathBuf::from("second.json")];
296
297 let refs = prepare_sbom_refs(&sboms, &paths);
298 assert_eq!(refs.len(), 2);
299 assert_eq!(refs[0].1, "first");
300 assert_eq!(refs[1].1, "second");
301 }
302}