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)
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    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    // Prepare target references with names
40    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    // Run multi-diff
47    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 result
67    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/// Run the timeline command
76#[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    // Prepare SBOM references with names
94    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    // Run timeline analysis
101    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 result
110    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/// Run the matrix command (N×N comparison)
119#[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    // Prepare SBOM references with names
142    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    // Run matrix comparison
149    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 result
166    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
174/// Parse multiple SBOMs from paths
175fn 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
185/// Get fuzzy matching config from preset name
186fn 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
196/// Get SBOM name from path
197fn 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
201/// Prepare SBOM references with names and paths
202fn 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
217/// Output multi-SBOM result with TUI or JSON fallback
218fn 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        // Should fall back to balanced
256        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}