1use crate::diff::MultiDiffEngine;
7use crate::matching::FuzzyMatchConfig;
8use crate::model::NormalizedSbom;
9use crate::pipeline::{parse_sbom_with_context, write_output, OutputTarget};
10use crate::reports::ReportFormat;
11use crate::tui::{run_tui, App};
12use anyhow::{bail, 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 let baseline_parsed = parse_sbom_with_context(&baseline_path, false)?;
26 let target_sboms = parse_multiple_sboms(&target_paths)?;
27
28 tracing::info!(
29 "Comparing baseline ({} components) against {} targets",
30 baseline_parsed.sbom().component_count(),
31 target_sboms.len()
32 );
33
34 let fuzzy_config = get_fuzzy_config(&fuzzy_preset);
35
36 let targets = prepare_sbom_refs(&target_sboms, &target_paths);
38 let target_refs: Vec<_> = targets
39 .iter()
40 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
41 .collect();
42
43 let mut engine = MultiDiffEngine::new()
45 .with_fuzzy_config(fuzzy_config)
46 .include_unchanged(include_unchanged);
47
48 let baseline_name = get_sbom_name(&baseline_path);
49
50 let result = engine.diff_multi(
51 baseline_parsed.sbom(),
52 &baseline_name,
53 &baseline_path.to_string_lossy(),
54 &target_refs,
55 );
56
57 tracing::info!(
58 "Multi-diff complete: {} comparisons, max deviation: {:.1}%",
59 result.comparisons.len(),
60 result.summary.max_deviation * 100.0
61 );
62
63 output_multi_result(output, output_file, || {
65 let mut app = App::new_multi_diff(result.clone());
66 run_tui(&mut app).map_err(Into::into)
67 }, || {
68 serde_json::to_string_pretty(&result).map_err(Into::into)
69 })
70}
71
72#[allow(clippy::needless_pass_by_value)]
74pub fn run_timeline(
75 sbom_paths: Vec<PathBuf>,
76 output: ReportFormat,
77 output_file: Option<PathBuf>,
78 fuzzy_preset: String,
79) -> Result<()> {
80 if sbom_paths.len() < 2 {
81 bail!("Timeline analysis requires at least 2 SBOMs");
82 }
83
84 let sboms = parse_multiple_sboms(&sbom_paths)?;
85
86 tracing::info!("Analyzing timeline of {} SBOMs", sboms.len());
87
88 let fuzzy_config = get_fuzzy_config(&fuzzy_preset);
89
90 let sbom_data = prepare_sbom_refs(&sboms, &sbom_paths);
92 let sbom_refs: Vec<_> = sbom_data
93 .iter()
94 .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
95 .collect();
96
97 let mut engine = MultiDiffEngine::new().with_fuzzy_config(fuzzy_config);
99 let result = engine.timeline(&sbom_refs);
100
101 tracing::info!(
102 "Timeline analysis complete: {} incremental diffs",
103 result.incremental_diffs.len()
104 );
105
106 output_multi_result(output, output_file, || {
108 let mut app = App::new_timeline(result.clone());
109 run_tui(&mut app).map_err(Into::into)
110 }, || {
111 serde_json::to_string_pretty(&result).map_err(Into::into)
112 })
113}
114
115#[allow(clippy::needless_pass_by_value)]
117pub 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
171pub(crate) fn parse_multiple_sboms(paths: &[PathBuf]) -> Result<Vec<NormalizedSbom>> {
173 let mut sboms = Vec::with_capacity(paths.len());
174 for path in paths {
175 let parsed = parse_sbom_with_context(path, false)?;
176 sboms.push(parsed.into_sbom());
177 }
178 Ok(sboms)
179}
180
181fn get_fuzzy_config(preset: &str) -> FuzzyMatchConfig {
183 FuzzyMatchConfig::from_preset(preset).unwrap_or_else(|| {
184 tracing::warn!(
185 "Unknown fuzzy preset '{}', using 'balanced'. Valid options: strict, balanced, permissive",
186 preset
187 );
188 FuzzyMatchConfig::balanced()
189 })
190}
191
192pub(crate) fn get_sbom_name(path: &Path) -> String {
194 path.file_stem().map_or_else(|| "unknown".to_string(), |s| s.to_string_lossy().to_string())
195}
196
197fn prepare_sbom_refs<'a>(
199 sboms: &'a [NormalizedSbom],
200 paths: &[PathBuf],
201) -> Vec<(&'a NormalizedSbom, String, String)> {
202 sboms
203 .iter()
204 .zip(paths.iter())
205 .map(|(sbom, path)| {
206 let name = get_sbom_name(path);
207 let path_str = path.to_string_lossy().to_string();
208 (sbom, name, path_str)
209 })
210 .collect()
211}
212
213fn output_multi_result<F, G>(
215 output: ReportFormat,
216 output_file: Option<PathBuf>,
217 run_tui_fn: F,
218 generate_json: G,
219) -> Result<()>
220where
221 F: FnOnce() -> Result<()>,
222 G: FnOnce() -> Result<String>,
223{
224 if output == ReportFormat::Tui {
225 run_tui_fn()
226 } else {
227 let json = generate_json()?;
228 let target = OutputTarget::from_option(output_file);
229 write_output(&json, &target, false)
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_get_fuzzy_config_valid_presets() {
239 let config = get_fuzzy_config("strict");
240 assert!(config.threshold > 0.8);
241
242 let config = get_fuzzy_config("balanced");
243 assert!(config.threshold >= 0.7 && config.threshold <= 0.85);
244
245 let config = get_fuzzy_config("permissive");
246 assert!(config.threshold <= 0.70);
247 }
248
249 #[test]
250 fn test_get_fuzzy_config_invalid_preset() {
251 let config = get_fuzzy_config("invalid");
253 let balanced = FuzzyMatchConfig::balanced();
254 assert_eq!(config.threshold, balanced.threshold);
255 }
256
257 #[test]
258 fn test_get_sbom_name() {
259 let path = PathBuf::from("/path/to/my-sbom.cdx.json");
260 assert_eq!(get_sbom_name(&path), "my-sbom.cdx");
261
262 let path = PathBuf::from("simple.json");
263 assert_eq!(get_sbom_name(&path), "simple");
264 }
265
266 #[test]
267 fn test_prepare_sbom_refs() {
268 let sbom1 = NormalizedSbom::default();
269 let sbom2 = NormalizedSbom::default();
270 let sboms = vec![sbom1, sbom2];
271 let paths = vec![
272 PathBuf::from("first.json"),
273 PathBuf::from("second.json"),
274 ];
275
276 let refs = prepare_sbom_refs(&sboms, &paths);
277 assert_eq!(refs.len(), 2);
278 assert_eq!(refs[0].1, "first");
279 assert_eq!(refs[1].1, "second");
280 }
281}