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::config::{MatrixConfig, MultiDiffConfig, TimelineConfig};
7use crate::diff::MultiDiffEngine;
8use crate::matching::FuzzyMatchConfig;
9use crate::model::NormalizedSbom;
10use crate::pipeline::{
11    OutputTarget, auto_detect_format, enrich_sbom_full, enrich_sboms, exit_codes,
12    parse_sbom_with_context, write_output,
13};
14use crate::reports::ReportFormat;
15use crate::tui::{App, run_tui};
16use anyhow::{Result, bail};
17use std::path::{Path, PathBuf};
18
19/// Resolve output target and effective format from config.
20fn resolve_output(output: &crate::config::OutputConfig) -> (OutputTarget, ReportFormat) {
21    let target = OutputTarget::from_option(output.file.clone());
22    let format = auto_detect_format(output.format, &target);
23    (target, format)
24}
25
26/// Run the diff-multi command (1:N comparison), returning the desired exit code.
27#[allow(clippy::needless_pass_by_value)]
28pub fn run_diff_multi(config: MultiDiffConfig) -> Result<i32> {
29    let quiet = config.behavior.quiet;
30
31    // Parse baseline
32    let mut baseline_parsed = parse_sbom_with_context(&config.baseline, quiet)?;
33    // Parse and optionally enrich targets
34    let (target_sboms, target_stats) =
35        parse_and_enrich_sboms(&config.targets, &config.enrichment, quiet)?;
36
37    // Enrich baseline
38    let baseline_stats = enrich_sbom_full(baseline_parsed.sbom_mut(), &config.enrichment, quiet);
39
40    tracing::info!(
41        "Comparing baseline ({} components) against {} targets",
42        baseline_parsed.sbom().component_count(),
43        target_sboms.len()
44    );
45
46    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
47
48    // Prepare target references with names
49    let targets = prepare_sbom_refs(&target_sboms, &config.targets);
50    let target_refs: Vec<_> = targets
51        .iter()
52        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
53        .collect();
54
55    // Run multi-diff
56    let mut engine = MultiDiffEngine::new()
57        .with_fuzzy_config(fuzzy_config)
58        .include_unchanged(config.matching.include_unchanged);
59    if config.graph_diff.enabled {
60        engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
61    }
62
63    let baseline_name = get_sbom_name(&config.baseline);
64
65    let result = engine.diff_multi(
66        baseline_parsed.sbom(),
67        &baseline_name,
68        &config.baseline.to_string_lossy(),
69        &target_refs,
70    );
71
72    tracing::info!(
73        "Multi-diff complete: {} comparisons, max deviation: {:.1}%",
74        result.comparisons.len(),
75        result.summary.max_deviation * 100.0
76    );
77
78    // Determine exit code
79    let exit_code = determine_multi_exit_code(&config.behavior, &result);
80
81    // Output result
82    let (output_target, effective_output) = resolve_output(&config.output);
83
84    if effective_output == ReportFormat::Tui {
85        let mut app = App::new_multi_diff(result);
86        app.export_template = config.output.export_template.clone();
87
88        // Show enrichment warnings if any
89        let all_warnings: Vec<_> = std::iter::once(&baseline_stats)
90            .chain(target_stats.iter())
91            .flat_map(|s| s.warnings.iter())
92            .collect();
93        if !all_warnings.is_empty() {
94            app.set_status_message(format!(
95                "Warning: {}",
96                all_warnings
97                    .iter()
98                    .map(|s| s.as_str())
99                    .collect::<Vec<_>>()
100                    .join(", ")
101            ));
102            app.status_sticky = true;
103        }
104
105        run_tui(&mut app)?;
106    } else {
107        let json = serde_json::to_string_pretty(&result)?;
108        write_output(&json, &output_target, quiet)?;
109    }
110
111    Ok(exit_code)
112}
113
114/// Run the timeline command, returning the desired exit code.
115#[allow(clippy::needless_pass_by_value)]
116pub fn run_timeline(config: TimelineConfig) -> Result<i32> {
117    let quiet = config.behavior.quiet;
118
119    if config.sbom_paths.len() < 2 {
120        bail!("Timeline analysis requires at least 2 SBOMs");
121    }
122
123    let (sboms, _enrich_stats) =
124        parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
125
126    tracing::info!("Analyzing timeline of {} SBOMs", sboms.len());
127
128    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
129
130    // Prepare SBOM references with names
131    let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
132    let sbom_refs: Vec<_> = sbom_data
133        .iter()
134        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
135        .collect();
136
137    // Run timeline analysis
138    let mut engine = MultiDiffEngine::new().with_fuzzy_config(fuzzy_config);
139    if config.graph_diff.enabled {
140        engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
141    }
142    let result = engine.timeline(&sbom_refs);
143
144    tracing::info!(
145        "Timeline analysis complete: {} incremental diffs",
146        result.incremental_diffs.len()
147    );
148
149    // Output result
150    let (output_target, effective_output) = resolve_output(&config.output);
151
152    // Determine exit code
153    let exit_code = determine_timeline_exit_code(&config.behavior, &result);
154
155    if effective_output == ReportFormat::Tui {
156        let mut app = App::new_timeline(result);
157        run_tui(&mut app)?;
158    } else {
159        let json = serde_json::to_string_pretty(&result)?;
160        write_output(&json, &output_target, quiet)?;
161    }
162
163    Ok(exit_code)
164}
165
166/// Run the matrix command (N×N comparison), returning the desired exit code.
167#[allow(clippy::needless_pass_by_value)]
168pub fn run_matrix(config: MatrixConfig) -> Result<i32> {
169    let quiet = config.behavior.quiet;
170
171    if config.sbom_paths.len() < 2 {
172        bail!("Matrix comparison requires at least 2 SBOMs");
173    }
174
175    let (sboms, _enrich_stats) =
176        parse_and_enrich_sboms(&config.sbom_paths, &config.enrichment, quiet)?;
177
178    tracing::info!(
179        "Computing {}x{} comparison matrix",
180        sboms.len(),
181        sboms.len()
182    );
183
184    let fuzzy_config = get_fuzzy_config(&config.matching.fuzzy_preset);
185
186    // Prepare SBOM references with names
187    let sbom_data = prepare_sbom_refs(&sboms, &config.sbom_paths);
188    let sbom_refs: Vec<_> = sbom_data
189        .iter()
190        .map(|(sbom, name, path)| (*sbom, name.as_str(), path.as_str()))
191        .collect();
192
193    // Run matrix comparison
194    let mut engine = MultiDiffEngine::new().with_fuzzy_config(fuzzy_config);
195    if config.graph_diff.enabled {
196        engine = engine.with_graph_diff(crate::diff::GraphDiffConfig::default());
197    }
198    let result = engine.matrix(&sbom_refs, Some(config.cluster_threshold));
199
200    tracing::info!(
201        "Matrix comparison complete: {} pairs computed",
202        result.num_pairs()
203    );
204
205    if let Some(ref clustering) = result.clustering {
206        tracing::info!(
207            "Found {} clusters, {} outliers",
208            clustering.clusters.len(),
209            clustering.outliers.len()
210        );
211    }
212
213    // Output result
214    let (output_target, effective_output) = resolve_output(&config.output);
215
216    // Determine exit code
217    let exit_code = determine_matrix_exit_code(&config.behavior, &result);
218
219    if effective_output == ReportFormat::Tui {
220        let mut app = App::new_matrix(result);
221        run_tui(&mut app)?;
222    } else {
223        let json = serde_json::to_string_pretty(&result)?;
224        write_output(&json, &output_target, quiet)?;
225    }
226
227    Ok(exit_code)
228}
229
230/// Parse and optionally enrich multiple SBOMs.
231fn parse_and_enrich_sboms(
232    paths: &[PathBuf],
233    enrichment: &crate::config::EnrichmentConfig,
234    quiet: bool,
235) -> Result<(
236    Vec<NormalizedSbom>,
237    Vec<crate::pipeline::AggregatedEnrichmentStats>,
238)> {
239    let mut sboms = Vec::with_capacity(paths.len());
240    for path in paths {
241        let parsed = parse_sbom_with_context(path, quiet)?;
242        sboms.push(parsed.into_sbom());
243    }
244    let stats = enrich_sboms(&mut sboms, enrichment, quiet);
245    Ok((sboms, stats))
246}
247
248/// Parse multiple SBOMs without enrichment.
249///
250/// Used by the query command where enrichment is handled separately.
251pub(crate) fn parse_multiple_sboms(paths: &[PathBuf]) -> Result<Vec<NormalizedSbom>> {
252    let mut sboms = Vec::with_capacity(paths.len());
253    for path in paths {
254        let parsed = parse_sbom_with_context(path, false)?;
255        sboms.push(parsed.into_sbom());
256    }
257    Ok(sboms)
258}
259
260/// Determine exit code for multi-SBOM commands based on behavior config.
261fn determine_multi_exit_code(
262    behavior: &crate::config::BehaviorConfig,
263    result: &crate::diff::MultiDiffResult,
264) -> i32 {
265    let (total_introduced, total_changes) =
266        result
267            .comparisons
268            .iter()
269            .fold((0usize, 0usize), |(vi, tc), c| {
270                (
271                    vi + c.diff.summary.vulnerabilities_introduced,
272                    tc + c.diff.summary.total_changes,
273                )
274            });
275
276    if behavior.fail_on_vuln && total_introduced > 0 {
277        return exit_codes::VULNS_INTRODUCED;
278    }
279    if behavior.fail_on_change && total_changes > 0 {
280        return exit_codes::CHANGES_DETECTED;
281    }
282    exit_codes::SUCCESS
283}
284
285/// Determine exit code for timeline commands based on behavior config.
286fn determine_timeline_exit_code(
287    behavior: &crate::config::BehaviorConfig,
288    result: &crate::diff::TimelineResult,
289) -> i32 {
290    if behavior.fail_on_vuln {
291        let total_introduced: usize = result
292            .incremental_diffs
293            .iter()
294            .map(|d| d.summary.vulnerabilities_introduced)
295            .sum();
296        if total_introduced > 0 {
297            return exit_codes::VULNS_INTRODUCED;
298        }
299    }
300    if behavior.fail_on_change {
301        let total_changes: usize = result
302            .incremental_diffs
303            .iter()
304            .map(|d| d.summary.total_changes)
305            .sum();
306        if total_changes > 0 {
307            return exit_codes::CHANGES_DETECTED;
308        }
309    }
310    exit_codes::SUCCESS
311}
312
313/// Determine exit code for matrix commands based on behavior config.
314fn determine_matrix_exit_code(
315    behavior: &crate::config::BehaviorConfig,
316    result: &crate::diff::MatrixResult,
317) -> i32 {
318    if behavior.fail_on_vuln {
319        let total_introduced: usize = result
320            .diffs
321            .iter()
322            .flatten()
323            .map(|d| d.summary.vulnerabilities_introduced)
324            .sum();
325        if total_introduced > 0 {
326            return exit_codes::VULNS_INTRODUCED;
327        }
328    }
329    if behavior.fail_on_change {
330        let total_changes: usize = result
331            .diffs
332            .iter()
333            .flatten()
334            .map(|d| d.summary.total_changes)
335            .sum();
336        if total_changes > 0 {
337            return exit_codes::CHANGES_DETECTED;
338        }
339    }
340    exit_codes::SUCCESS
341}
342
343/// Get fuzzy matching config from preset name
344fn get_fuzzy_config(preset: &crate::config::FuzzyPreset) -> FuzzyMatchConfig {
345    FuzzyMatchConfig::from_preset(preset.as_str()).unwrap_or_else(|| {
346        // Enum guarantees valid preset, but from_preset may not know all variants
347        FuzzyMatchConfig::balanced()
348    })
349}
350
351/// Get SBOM name from path
352pub(crate) fn get_sbom_name(path: &Path) -> String {
353    path.file_stem().map_or_else(
354        || "unknown".to_string(),
355        |s| s.to_string_lossy().to_string(),
356    )
357}
358
359/// Prepare SBOM references with names and paths
360fn prepare_sbom_refs<'a>(
361    sboms: &'a [NormalizedSbom],
362    paths: &[PathBuf],
363) -> Vec<(&'a NormalizedSbom, String, String)> {
364    sboms
365        .iter()
366        .zip(paths.iter())
367        .map(|(sbom, path)| {
368            let name = get_sbom_name(path);
369            let path_str = path.to_string_lossy().to_string();
370            (sbom, name, path_str)
371        })
372        .collect()
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn test_get_fuzzy_config_valid_presets() {
381        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Strict);
382        assert!(config.threshold > 0.8);
383
384        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Balanced);
385        assert!(config.threshold >= 0.7 && config.threshold <= 0.85);
386
387        let config = get_fuzzy_config(&crate::config::FuzzyPreset::Permissive);
388        assert!(config.threshold <= 0.70);
389    }
390
391    #[test]
392    fn test_get_sbom_name() {
393        let path = PathBuf::from("/path/to/my-sbom.cdx.json");
394        assert_eq!(get_sbom_name(&path), "my-sbom.cdx");
395
396        let path = PathBuf::from("simple.json");
397        assert_eq!(get_sbom_name(&path), "simple");
398    }
399
400    #[test]
401    fn test_prepare_sbom_refs() {
402        let sbom1 = NormalizedSbom::default();
403        let sbom2 = NormalizedSbom::default();
404        let sboms = vec![sbom1, sbom2];
405        let paths = vec![PathBuf::from("first.json"), PathBuf::from("second.json")];
406
407        let refs = prepare_sbom_refs(&sboms, &paths);
408        assert_eq!(refs.len(), 2);
409        assert_eq!(refs[0].1, "first");
410        assert_eq!(refs[1].1, "second");
411    }
412}