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.
22pub fn run_diff(config: DiffConfig) -> Result<i32> {
23    let quiet = config.behavior.quiet;
24
25    // Parse SBOMs
26    let mut old_parsed = parse_sbom_with_context(&config.paths.old, quiet)?;
27    let mut new_parsed = parse_sbom_with_context(&config.paths.new, quiet)?;
28
29    if !quiet {
30        tracing::info!(
31            "Parsed {} components from old SBOM, {} from new SBOM",
32            old_parsed.sbom().component_count(),
33            new_parsed.sbom().component_count()
34        );
35    }
36
37    // Enrich with OSV vulnerability data if enabled (runtime feature check)
38    #[cfg(feature = "enrichment")]
39    let enrichment_stats = {
40        if config.enrichment.enabled {
41            use crate::enrichment::OsvEnricherConfig;
42            use crate::pipeline::dirs;
43
44            let osv_config = OsvEnricherConfig {
45                cache_dir: config
46                    .enrichment
47                    .cache_dir
48                    .clone()
49                    .unwrap_or_else(dirs::osv_cache_dir),
50                cache_ttl: std::time::Duration::from_secs(config.enrichment.cache_ttl_hours * 3600),
51                bypass_cache: config.enrichment.bypass_cache,
52                timeout: std::time::Duration::from_secs(config.enrichment.timeout_secs),
53                ..Default::default()
54            };
55
56            let stats_old =
57                crate::pipeline::enrich_sbom(old_parsed.sbom_mut(), &osv_config, quiet);
58            let stats_new =
59                crate::pipeline::enrich_sbom(new_parsed.sbom_mut(), &osv_config, quiet);
60            Some((stats_old, stats_new))
61        } else {
62            None
63        }
64    };
65
66    #[cfg(not(feature = "enrichment"))]
67    {
68        if config.enrichment.enabled && !quiet {
69            tracing::warn!(
70                "Enrichment requested but the 'enrichment' feature is not enabled. \
71                 Rebuild with --features enrichment to enable vulnerability enrichment."
72            );
73        }
74    }
75
76    // Compute the diff
77    let result = compute_diff(&config, &old_parsed.sbom, &new_parsed.sbom)?;
78
79    // Determine exit code before potentially moving result into TUI
80    let exit_code = determine_exit_code(&config, &result);
81
82    // Route output
83    let output_target = OutputTarget::from_option(config.output.file.clone());
84    let effective_output = auto_detect_format(config.output.format, &output_target);
85
86    if effective_output == ReportFormat::Tui {
87        let (old_sbom, old_raw) = old_parsed.into_parts();
88        let (new_sbom, new_raw) = new_parsed.into_parts();
89
90        #[cfg(feature = "enrichment")]
91        let mut app = {
92            let app = App::new_diff(result, old_sbom, new_sbom, old_raw, new_raw);
93            if let Some((stats_old, stats_new)) = enrichment_stats {
94                app.with_enrichment_stats(stats_old, stats_new)
95            } else {
96                app
97            }
98        };
99
100        #[cfg(not(feature = "enrichment"))]
101        let mut app = App::new_diff(result, old_sbom, new_sbom, old_raw, new_raw);
102
103        run_tui(&mut app)?;
104    } else {
105        old_parsed.drop_raw_content();
106        new_parsed.drop_raw_content();
107        output_report(&config, &result, &old_parsed.sbom, &new_parsed.sbom)?;
108    }
109
110    Ok(exit_code)
111}
112
113/// Determine the appropriate exit code based on diff results and config flags.
114fn determine_exit_code(config: &DiffConfig, result: &crate::diff::DiffResult) -> i32 {
115    if config.behavior.fail_on_vuln && result.summary.vulnerabilities_introduced > 0 {
116        return exit_codes::VULNS_INTRODUCED;
117    }
118    if config.behavior.fail_on_change && result.summary.total_changes > 0 {
119        return exit_codes::CHANGES_DETECTED;
120    }
121    exit_codes::SUCCESS
122}
123
124#[cfg(test)]
125mod tests {
126    use crate::pipeline::OutputTarget;
127    use std::path::PathBuf;
128
129    #[test]
130    fn test_output_target_conversion() {
131        let none_target = OutputTarget::from_option(None);
132        assert!(matches!(none_target, OutputTarget::Stdout));
133
134        let some_target = OutputTarget::from_option(Some(PathBuf::from("/tmp/test.json")));
135        assert!(matches!(some_target, OutputTarget::File(_)));
136    }
137}