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