Skip to main content

sbom_tools/cli/
multi.rs

1//! Multi-SBOM command handlers.
2//!
3//! Implements the `diff-multi`, `timeline`, and `matrix` subcommands.
4
5use 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/// Run the diff-multi command (1:N comparison)
16pub 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    // Prepare target references with names
39    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    // Run multi-diff
46    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 result
66    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
74/// Run the timeline command
75pub 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    // Prepare SBOM references with names
92    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    // Run timeline analysis
99    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 result
108    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
116/// Run the matrix command (N×N comparison)
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    // Prepare SBOM references with names
139    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    // Run matrix comparison
146    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 result
163    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
171/// Parse multiple SBOMs from paths
172fn 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
182/// Get fuzzy matching config from preset name
183fn 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
196/// Get SBOM name from path
197fn 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
203/// Prepare SBOM references with names and paths
204fn 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
219/// Output multi-SBOM result with TUI or JSON fallback
220fn 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        // Should fall back to balanced
258        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}