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