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