Skip to main content

sbom_tools/diff/
traits.rs

1//! Trait definitions for diff computation strategies.
2//!
3//! This module provides abstractions for computing different types of changes,
4//! enabling modular and testable diff operations.
5
6use super::{
7    ComponentChange, ComponentLicenseChange, DependencyChange, LicenseChange, VexStatusChange,
8    VulnerabilityDetail,
9};
10use crate::model::{CanonicalId, NormalizedSbom};
11use std::collections::HashMap;
12
13/// Result of matching components between two SBOMs.
14pub type ComponentMatches = HashMap<CanonicalId, Option<CanonicalId>>;
15
16/// Trait for computing a specific type of change between SBOMs.
17///
18/// Implementors provide logic for detecting a particular category of changes
19/// (components, dependencies, licenses, vulnerabilities).
20pub trait ChangeComputer: Send + Sync {
21    /// The type of changes this computer produces.
22    type ChangeSet;
23
24    /// Compute changes between old and new SBOMs given component matches.
25    fn compute(
26        &self,
27        old: &NormalizedSbom,
28        new: &NormalizedSbom,
29        matches: &ComponentMatches,
30    ) -> Self::ChangeSet;
31
32    /// Get the name of this change computer for logging/debugging.
33    fn name(&self) -> &str;
34}
35
36/// Container for component changes (added, removed, modified).
37#[derive(Debug, Clone, Default)]
38pub struct ComponentChangeSet {
39    pub added: Vec<ComponentChange>,
40    pub removed: Vec<ComponentChange>,
41    pub modified: Vec<ComponentChange>,
42}
43
44impl ComponentChangeSet {
45    #[must_use]
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    #[must_use]
51    pub fn is_empty(&self) -> bool {
52        self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
53    }
54
55    #[must_use]
56    pub fn total(&self) -> usize {
57        self.added.len() + self.removed.len() + self.modified.len()
58    }
59}
60
61/// Container for dependency changes (added, removed).
62#[derive(Debug, Clone, Default)]
63pub struct DependencyChangeSet {
64    pub added: Vec<DependencyChange>,
65    pub removed: Vec<DependencyChange>,
66}
67
68impl DependencyChangeSet {
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    #[must_use]
75    pub fn is_empty(&self) -> bool {
76        self.added.is_empty() && self.removed.is_empty()
77    }
78
79    #[must_use]
80    pub fn total(&self) -> usize {
81        self.added.len() + self.removed.len()
82    }
83}
84
85/// Container for license changes.
86#[derive(Debug, Clone, Default)]
87pub struct LicenseChangeSet {
88    pub new_licenses: Vec<LicenseChange>,
89    pub removed_licenses: Vec<LicenseChange>,
90    pub component_changes: Vec<ComponentLicenseChange>,
91}
92
93impl LicenseChangeSet {
94    #[must_use]
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    #[must_use]
100    pub fn is_empty(&self) -> bool {
101        self.new_licenses.is_empty()
102            && self.removed_licenses.is_empty()
103            && self.component_changes.is_empty()
104    }
105}
106
107/// Container for vulnerability changes.
108#[derive(Debug, Clone, Default)]
109pub struct VulnerabilityChangeSet {
110    pub introduced: Vec<VulnerabilityDetail>,
111    pub resolved: Vec<VulnerabilityDetail>,
112    pub persistent: Vec<VulnerabilityDetail>,
113    /// VEX state transitions detected across persistent vulnerabilities
114    pub vex_changes: Vec<VexStatusChange>,
115}
116
117impl VulnerabilityChangeSet {
118    #[must_use]
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    #[must_use]
124    pub fn is_empty(&self) -> bool {
125        self.introduced.is_empty() && self.resolved.is_empty()
126    }
127
128    /// Sort vulnerabilities by severity (critical first).
129    ///
130    /// Ties are broken by vulnerability and component ID since the details
131    /// are collected from hash-map iteration in otherwise random order.
132    pub fn sort_by_severity(&mut self) {
133        let severity_order = |s: &str| match s {
134            "Critical" => 0,
135            "High" => 1,
136            "Medium" => 2,
137            "Low" => 3,
138            _ => 4,
139        };
140        let detail_order = |a: &VulnerabilityDetail, b: &VulnerabilityDetail| {
141            severity_order(&a.severity)
142                .cmp(&severity_order(&b.severity))
143                .then_with(|| a.id.cmp(&b.id))
144                .then_with(|| a.component_id.cmp(&b.component_id))
145        };
146
147        self.introduced.sort_by(detail_order);
148        self.resolved.sort_by(detail_order);
149        self.persistent.sort_by(detail_order);
150        self.vex_changes.sort_by(|a, b| {
151            a.vuln_id
152                .cmp(&b.vuln_id)
153                .then_with(|| a.component_name.cmp(&b.component_name))
154        });
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_component_change_set_empty() {
164        let set = ComponentChangeSet::new();
165        assert!(set.is_empty());
166        assert_eq!(set.total(), 0);
167    }
168
169    #[test]
170    fn test_dependency_change_set_empty() {
171        let set = DependencyChangeSet::new();
172        assert!(set.is_empty());
173        assert_eq!(set.total(), 0);
174    }
175
176    #[test]
177    fn test_license_change_set_empty() {
178        let set = LicenseChangeSet::new();
179        assert!(set.is_empty());
180    }
181
182    #[test]
183    fn test_vulnerability_change_set_empty() {
184        let set = VulnerabilityChangeSet::new();
185        assert!(set.is_empty());
186    }
187}