Skip to main content

sbom_tools/cli/
diff.rs

1//! Diff command handler.
2//!
3//! Implements the `diff` subcommand for comparing two SBOMs.
4
5use crate::config::DiffConfig;
6use crate::pipeline::{
7    auto_detect_format, compute_diff, exit_codes, output_report, parse_sbom_with_context,
8    OutputTarget,
9};
10use crate::reports::ReportFormat;
11use crate::tui::{run_tui, App};
12use anyhow::Result;
13
14/// Run the diff command, returning the desired exit code.
15///
16/// Enrichment is handled based on the `enrichment` feature flag and the
17/// `config.enrichment.enabled` setting. When the feature is disabled,
18/// enrichment settings are silently ignored.
19///
20/// The caller is responsible for calling `std::process::exit()` with the
21/// returned code when it is non-zero.
22#[allow(clippy::needless_pass_by_value)]
23pub fn run_diff(config: DiffConfig) -> Result<i32> {
24    let quiet = config.behavior.quiet;
25
26    // Parse SBOMs
27    let mut old_parsed = parse_sbom_with_context(&config.paths.old, quiet)?;
28    let mut new_parsed = parse_sbom_with_context(&config.paths.new, quiet)?;
29
30    if !quiet {
31        tracing::info!(
32            "Parsed {} components from old SBOM, {} from new SBOM",
33            old_parsed.sbom().component_count(),
34            new_parsed.sbom().component_count()
35        );
36    }
37
38    // Enrich with OSV vulnerability data if enabled (runtime feature check)
39    #[cfg(feature = "enrichment")]
40    let enrichment_stats = {
41        if config.enrichment.enabled {
42            let osv_config = crate::pipeline::build_enrichment_config(&config.enrichment);
43            let stats_old =
44                crate::pipeline::enrich_sbom(old_parsed.sbom_mut(), &osv_config, quiet);
45            let stats_new =
46                crate::pipeline::enrich_sbom(new_parsed.sbom_mut(), &osv_config, quiet);
47            Some((stats_old, stats_new))
48        } else {
49            None
50        }
51    };
52
53    // Enrich with end-of-life data if enabled
54    #[cfg(feature = "enrichment")]
55    {
56        if config.enrichment.enable_eol {
57            let eol_config = crate::enrichment::EolClientConfig {
58                cache_dir: config
59                    .enrichment
60                    .cache_dir
61                    .clone()
62                    .unwrap_or_else(crate::pipeline::dirs::eol_cache_dir),
63                cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
64                bypass_cache: config.enrichment.bypass_cache,
65                timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
66                ..Default::default()
67            };
68            crate::pipeline::enrich_eol(old_parsed.sbom_mut(), &eol_config, quiet);
69            crate::pipeline::enrich_eol(new_parsed.sbom_mut(), &eol_config, quiet);
70        }
71    }
72
73    // Enrich with VEX data if VEX documents provided
74    #[cfg(feature = "enrichment")]
75    if !config.enrichment.vex_paths.is_empty() {
76        crate::pipeline::enrich_vex(old_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
77        crate::pipeline::enrich_vex(new_parsed.sbom_mut(), &config.enrichment.vex_paths, quiet);
78    }
79
80    #[cfg(not(feature = "enrichment"))]
81    {
82        if config.enrichment.enabled && !quiet {
83            tracing::warn!(
84                "Enrichment requested but the 'enrichment' feature is not enabled. \
85                 Rebuild with --features enrichment to enable vulnerability enrichment."
86            );
87        }
88    }
89
90    // Compute the diff
91    let result = compute_diff(&config, &old_parsed.sbom, &new_parsed.sbom)?;
92
93    // Determine exit code before potentially moving result into TUI
94    let exit_code = determine_exit_code(&config, &result);
95
96    // Route output
97    let output_target = OutputTarget::from_option(config.output.file.clone());
98    let effective_output = auto_detect_format(config.output.format, &output_target);
99
100    if effective_output == ReportFormat::Tui {
101        let (old_sbom, old_raw) = old_parsed.into_parts();
102        let (new_sbom, new_raw) = new_parsed.into_parts();
103
104        #[cfg(feature = "enrichment")]
105        let mut app = {
106            let app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
107            if let Some((stats_old, stats_new)) = enrichment_stats {
108                app.with_enrichment_stats(stats_old, stats_new)
109            } else {
110                app
111            }
112        };
113
114        #[cfg(not(feature = "enrichment"))]
115        let mut app = App::new_diff(result, old_sbom, new_sbom, &old_raw, &new_raw);
116
117        run_tui(&mut app)?;
118    } else {
119        old_parsed.drop_raw_content();
120        new_parsed.drop_raw_content();
121        output_report(&config, &result, &old_parsed.sbom, &new_parsed.sbom)?;
122    }
123
124    Ok(exit_code)
125}
126
127/// Determine the appropriate exit code based on diff results and config flags.
128const fn determine_exit_code(config: &DiffConfig, result: &crate::diff::DiffResult) -> i32 {
129    if config.behavior.fail_on_vuln && result.summary.vulnerabilities_introduced > 0 {
130        return exit_codes::VULNS_INTRODUCED;
131    }
132    if config.behavior.fail_on_change && result.summary.total_changes > 0 {
133        return exit_codes::CHANGES_DETECTED;
134    }
135    exit_codes::SUCCESS
136}
137
138#[cfg(test)]
139mod tests {
140    use crate::pipeline::OutputTarget;
141    use std::path::PathBuf;
142
143    #[test]
144    fn test_output_target_conversion() {
145        let none_target = OutputTarget::from_option(None);
146        assert!(matches!(none_target, OutputTarget::Stdout));
147
148        let some_target = OutputTarget::from_option(Some(PathBuf::from("/tmp/test.json")));
149        assert!(matches!(some_target, OutputTarget::File(_)));
150    }
151}