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