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//! Uses the pipeline module for parsing and enrichment (shared with `diff`).
5
6use 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/// Run the diff-multi command (1:N comparison)
16#[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    // Prepare target references with names
37    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    // Run multi-diff
44    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 result
64    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/// Run the timeline command
73#[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    // Prepare SBOM references with names
91    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    // Run timeline analysis
98    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 result
107    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/// Run the matrix command (N×N comparison)
116#[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    // 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 using the pipeline (with structured error context).
172pub(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
181/// Get fuzzy matching config from preset name
182fn 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
192/// Get SBOM name from path
193pub(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
197/// Prepare SBOM references with names and paths
198fn 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
213/// Output multi-SBOM result with TUI or JSON fallback
214fn 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        // Should fall back to balanced
252        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}