Skip to main content

sbom_tools/diff/
engine.rs

1//! Semantic diff engine implementation.
2
3use super::changes::{
4    ComponentChangeComputer, DependencyChangeComputer, LicenseChangeComputer,
5    VulnerabilityChangeComputer,
6};
7pub use super::engine_config::LargeSbomConfig;
8use super::engine_matching::{match_components, ComponentMatchResult};
9use super::engine_rules::{apply_rules, remap_match_result};
10use super::traits::ChangeComputer;
11use super::{diff_dependency_graph, CostModel, DiffResult, GraphDiffConfig, MatchInfo};
12use crate::error::SbomDiffError;
13use crate::matching::{
14    ComponentMatcher, FuzzyMatchConfig, FuzzyMatcher, MatchingRulesConfig, RuleEngine,
15};
16use crate::model::NormalizedSbom;
17use std::borrow::Cow;
18
19/// Semantic diff engine for comparing SBOMs.
20pub struct DiffEngine {
21    cost_model: CostModel,
22    fuzzy_config: FuzzyMatchConfig,
23    include_unchanged: bool,
24    graph_diff_config: Option<GraphDiffConfig>,
25    rule_engine: Option<RuleEngine>,
26    custom_matcher: Option<Box<dyn ComponentMatcher>>,
27    large_sbom_config: LargeSbomConfig,
28}
29
30impl DiffEngine {
31    /// Create a new diff engine with default settings
32    pub fn new() -> Self {
33        Self {
34            cost_model: CostModel::default(),
35            fuzzy_config: FuzzyMatchConfig::balanced(),
36            include_unchanged: false,
37            graph_diff_config: None,
38            rule_engine: None,
39            custom_matcher: None,
40            large_sbom_config: LargeSbomConfig::default(),
41        }
42    }
43
44    /// Create a diff engine with a custom cost model
45    pub fn with_cost_model(mut self, cost_model: CostModel) -> Self {
46        self.cost_model = cost_model;
47        self
48    }
49
50    /// Set fuzzy matching configuration
51    pub fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
52        self.fuzzy_config = config;
53        self
54    }
55
56    /// Include unchanged components in the result
57    pub fn include_unchanged(mut self, include: bool) -> Self {
58        self.include_unchanged = include;
59        self
60    }
61
62    /// Enable graph-aware diffing with the given configuration
63    pub fn with_graph_diff(mut self, config: GraphDiffConfig) -> Self {
64        self.graph_diff_config = Some(config);
65        self
66    }
67
68    /// Set custom matching rules from a configuration
69    pub fn with_matching_rules(mut self, config: MatchingRulesConfig) -> Result<Self, String> {
70        self.rule_engine = Some(RuleEngine::new(config)?);
71        Ok(self)
72    }
73
74    /// Set custom matching rules engine directly
75    pub fn with_rule_engine(mut self, engine: RuleEngine) -> Self {
76        self.rule_engine = Some(engine);
77        self
78    }
79
80    /// Set a custom component matcher.
81    pub fn with_matcher(mut self, matcher: Box<dyn ComponentMatcher>) -> Self {
82        self.custom_matcher = Some(matcher);
83        self
84    }
85
86    /// Configure large SBOM optimization settings.
87    pub fn with_large_sbom_config(mut self, config: LargeSbomConfig) -> Self {
88        self.large_sbom_config = config;
89        self
90    }
91
92    /// Get the large SBOM configuration.
93    pub fn large_sbom_config(&self) -> &LargeSbomConfig {
94        &self.large_sbom_config
95    }
96
97    /// Check if a custom matcher is configured
98    pub fn has_custom_matcher(&self) -> bool {
99        self.custom_matcher.is_some()
100    }
101
102    /// Check if graph diffing is enabled
103    pub fn graph_diff_enabled(&self) -> bool {
104        self.graph_diff_config.is_some()
105    }
106
107    /// Check if custom matching rules are configured
108    pub fn has_matching_rules(&self) -> bool {
109        self.rule_engine.is_some()
110    }
111
112    /// Compare two SBOMs and return the diff result
113    pub fn diff(&self, old: &NormalizedSbom, new: &NormalizedSbom) -> Result<DiffResult, SbomDiffError> {
114        let mut result = DiffResult::new();
115
116        // Quick check: if content hashes match, SBOMs are identical
117        if old.content_hash == new.content_hash && old.content_hash != 0 {
118            return Ok(result);
119        }
120
121        // Apply custom matching rules if configured
122        // Use Cow to avoid cloning SBOMs when no rules are applied
123        let (old_filtered, new_filtered, canonical_maps) =
124            if let Some(rule_result) = apply_rules(self.rule_engine.as_ref(), old, new) {
125                result.rules_applied = rule_result.rules_count;
126                (
127                    Cow::Owned(rule_result.old_filtered),
128                    Cow::Owned(rule_result.new_filtered),
129                    Some((rule_result.old_canonical, rule_result.new_canonical)),
130                )
131            } else {
132                (Cow::Borrowed(old), Cow::Borrowed(new), None)
133            };
134
135        // Build component mappings using the configured matcher
136        let default_matcher;
137        let matcher: &dyn ComponentMatcher = match &self.custom_matcher {
138            Some(m) => m.as_ref(),
139            None => {
140                default_matcher = FuzzyMatcher::new(self.fuzzy_config.clone());
141                &default_matcher
142            }
143        };
144
145        let mut component_matches = match_components(
146            &old_filtered,
147            &new_filtered,
148            matcher,
149            &self.fuzzy_config,
150            &self.large_sbom_config,
151        );
152
153        // Apply canonical mappings from rule engine
154        if let Some((old_canonical, new_canonical)) = &canonical_maps {
155            component_matches = remap_match_result(&component_matches, old_canonical, new_canonical);
156        }
157
158        // Compute changes using the modular change computers
159        self.compute_all_changes(&old_filtered, &new_filtered, &component_matches, matcher, &mut result);
160
161        // Perform graph-aware diffing if enabled
162        if let Some(ref graph_config) = self.graph_diff_config {
163            let (graph_changes, graph_summary) =
164                diff_dependency_graph(&old_filtered, &new_filtered, &component_matches.matches, graph_config);
165            result.graph_changes = graph_changes;
166            result.graph_summary = Some(graph_summary);
167        }
168
169        // Calculate semantic score
170        result.semantic_score = self.cost_model.calculate_semantic_score(
171            result.components.added.len(),
172            result.components.removed.len(),
173            result.components.modified.len(),
174            result.licenses.component_changes.len(),
175            result.vulnerabilities.introduced.len(),
176            result.vulnerabilities.resolved.len(),
177            result.dependencies.added.len(),
178            result.dependencies.removed.len(),
179        );
180
181        result.calculate_summary();
182        Ok(result)
183    }
184
185    /// Compute all changes using the modular change computers.
186    fn compute_all_changes(
187        &self,
188        old: &NormalizedSbom,
189        new: &NormalizedSbom,
190        match_result: &ComponentMatchResult,
191        matcher: &dyn ComponentMatcher,
192        result: &mut DiffResult,
193    ) {
194        // Component changes
195        let comp_computer = ComponentChangeComputer::new(self.cost_model.clone());
196        let comp_changes = comp_computer.compute(old, new, &match_result.matches);
197        result.components.added = comp_changes.added;
198        result.components.removed = comp_changes.removed;
199        result.components.modified = comp_changes
200            .modified
201            .into_iter()
202            .map(|mut change| {
203                // Add match explanation for modified components
204                // Use stored canonical IDs directly instead of reconstructing from name+version
205                if let (Some(ref old_id), Some(ref new_id)) =
206                    (&change.old_canonical_id, &change.canonical_id)
207                {
208                    if let (Some(old_comp), Some(new_comp)) =
209                        (old.components.get(old_id), new.components.get(new_id))
210                    {
211                        let explanation = matcher.explain_match(old_comp, new_comp);
212                        let mut match_info = MatchInfo::from_explanation(&explanation);
213
214                        // Use the actual score from the matching phase if available
215                        if let Some(&score) =
216                            match_result.pairs.get(&(old_id.clone(), new_id.clone()))
217                        {
218                            match_info.score = score;
219                        }
220
221                        change = change.with_match_info(match_info);
222                    }
223                }
224                change
225            })
226            .collect();
227
228        // Dependency changes
229        let dep_computer = DependencyChangeComputer::new();
230        let dep_changes = dep_computer.compute(old, new, &match_result.matches);
231        result.dependencies.added = dep_changes.added;
232        result.dependencies.removed = dep_changes.removed;
233
234        // License changes
235        let lic_computer = LicenseChangeComputer::new();
236        let lic_changes = lic_computer.compute(old, new, &match_result.matches);
237        result.licenses.new_licenses = lic_changes.new_licenses;
238        result.licenses.removed_licenses = lic_changes.removed_licenses;
239
240        // Vulnerability changes
241        let vuln_computer = VulnerabilityChangeComputer::new();
242        let vuln_changes = vuln_computer.compute(old, new, &match_result.matches);
243        result.vulnerabilities.introduced = vuln_changes.introduced;
244        result.vulnerabilities.resolved = vuln_changes.resolved;
245        result.vulnerabilities.persistent = vuln_changes.persistent;
246    }
247}
248
249impl Default for DiffEngine {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_empty_diff() {
261        let engine = DiffEngine::new();
262        let sbom = NormalizedSbom::default();
263        let result = engine.diff(&sbom, &sbom).expect("diff should succeed");
264        assert!(!result.has_changes());
265    }
266}