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