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