1use sbom_model::{Component, ComponentId, Sbom};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashSet};
4
5pub mod renderer;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Diff {
9 pub added: Vec<Component>,
10 pub removed: Vec<Component>,
11 pub changed: Vec<ComponentChange>,
12 pub metadata_changed: bool,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ComponentChange {
17 pub id: ComponentId,
18 pub old: Component,
19 pub new: Component,
20 pub changes: Vec<FieldChange>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub enum FieldChange {
25 Version(String, String),
26 License(Vec<String>, Vec<String>),
27 Supplier(Option<String>, Option<String>),
28 Purl(Option<String>, Option<String>),
29 Hashes,
30}
31
32#[derive(Debug, Copy, Clone, PartialEq, Eq)]
33pub enum Field {
34 Version,
35 License,
36 Supplier,
37 Purl,
38 Hashes,
39}
40
41pub struct Differ;
42
43impl Differ {
44 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
45 let mut old = old.clone();
46 let mut new = new.clone();
47
48 old.normalize();
49 new.normalize();
50
51 let mut added = Vec::new();
52 let mut removed = Vec::new();
53 let mut changed = Vec::new();
54
55 let mut processed_old = HashSet::new();
56 let mut processed_new = HashSet::new();
57
58 for (id, new_comp) in &new.components {
60 if let Some(old_comp) = old.components.get(id) {
61 processed_old.insert(id.clone());
62 processed_new.insert(id.clone());
63
64 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
65 changed.push(change);
66 }
67 }
68 }
69
70 let mut old_identity_map = BTreeMap::new();
72 for (id, comp) in &old.components {
73 if !processed_old.contains(id) {
74 let identity = (comp.ecosystem.clone(), comp.name.clone());
75 old_identity_map
76 .entry(identity)
77 .or_insert_with(Vec::new)
78 .push(id.clone());
79 }
80 }
81
82 for (id, new_comp) in &new.components {
83 if processed_new.contains(id) {
84 continue;
85 }
86
87 let identity = (new_comp.ecosystem.clone(), new_comp.name.clone());
88 if let Some(old_ids) = old_identity_map.get_mut(&identity) {
89 if let Some(old_id) = old_ids.pop() {
90 if let Some(old_comp) = old.components.get(&old_id) {
91 processed_old.insert(old_id.clone());
92 processed_new.insert(id.clone());
93
94 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
95 changed.push(change);
96 }
97 continue;
98 }
99 }
100 }
101
102 added.push(new_comp.clone());
103 processed_new.insert(id.clone());
104 }
105
106 for (id, old_comp) in &old.components {
107 if !processed_old.contains(id) {
108 removed.push(old_comp.clone());
109 }
110 }
111
112 Diff {
113 added,
114 removed,
115 changed,
116 metadata_changed: old.metadata != new.metadata,
117 }
118 }
119
120 fn compute_change(
121 old: &Component,
122 new: &Component,
123 only: Option<&[Field]>,
124 ) -> Option<ComponentChange> {
125 let mut changes = Vec::new();
126
127 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
128
129 if should_include(Field::Version) && old.version != new.version {
130 changes.push(FieldChange::Version(
131 old.version.clone().unwrap_or_default(),
132 new.version.clone().unwrap_or_default(),
133 ));
134 }
135
136 if should_include(Field::License) && old.licenses != new.licenses {
137 changes.push(FieldChange::License(
138 old.licenses.clone(),
139 new.licenses.clone(),
140 ));
141 }
142
143 if should_include(Field::Supplier) && old.supplier != new.supplier {
144 changes.push(FieldChange::Supplier(
145 old.supplier.clone(),
146 new.supplier.clone(),
147 ));
148 }
149
150 if should_include(Field::Purl) && old.purl != new.purl {
151 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
152 }
153
154 if should_include(Field::Hashes) && old.hashes != new.hashes {
155 changes.push(FieldChange::Hashes);
156 }
157
158 if changes.is_empty() {
159 None
160 } else {
161 Some(ComponentChange {
162 id: new.id.clone(),
163 old: old.clone(),
164 new: new.clone(),
165 changes,
166 })
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn test_diff_added_removed() {
177 let mut old = Sbom::default();
178 let mut new = Sbom::default();
179
180 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
181 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
182
183 old.components.insert(c1.id.clone(), c1);
184 new.components.insert(c2.id.clone(), c2);
185
186 let diff = Differ::diff(&old, &new, None);
187 assert_eq!(diff.added.len(), 1);
188 assert_eq!(diff.removed.len(), 1);
189 assert_eq!(diff.changed.len(), 0);
190 }
191
192 #[test]
193 fn test_diff_changed() {
194 let mut old = Sbom::default();
195 let mut new = Sbom::default();
196
197 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
198 let mut c2 = c1.clone();
199 c2.version = Some("1.1".to_string());
200
201 old.components.insert(c1.id.clone(), c1);
202 new.components.insert(c2.id.clone(), c2);
203
204 let diff = Differ::diff(&old, &new, None);
205 assert_eq!(diff.added.len(), 0);
206 assert_eq!(diff.removed.len(), 0);
207 assert_eq!(diff.changed.len(), 1);
208 assert!(matches!(
209 diff.changed[0].changes[0],
210 FieldChange::Version(_, _)
211 ));
212 }
213
214 #[test]
215 fn test_diff_identity_reconciliation() {
216 let mut old = Sbom::default();
217 let mut new = Sbom::default();
218
219 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
220 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
221
222 old.components.insert(c1.id.clone(), c1);
223 new.components.insert(c2.id.clone(), c2);
224
225 let diff = Differ::diff(&old, &new, None);
226 assert_eq!(diff.changed.len(), 1);
227 assert_eq!(diff.added.len(), 0);
228 }
229
230 #[test]
231 fn test_diff_filtering() {
232 let mut old = Sbom::default();
233 let mut new = Sbom::default();
234
235 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
236 c1.licenses.push("MIT".into());
237
238 let mut c2 = c1.clone();
239 c2.version = Some("1.1".to_string());
240 c2.licenses = vec!["Apache-2.0".into()];
241
242 old.components.insert(c1.id.clone(), c1);
243 new.components.insert(c2.id.clone(), c2);
244
245 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
246 assert_eq!(diff.changed.len(), 1);
247 assert_eq!(diff.changed[0].changes.len(), 1);
248 assert!(matches!(
249 diff.changed[0].changes[0],
250 FieldChange::Version(_, _)
251 ));
252 }
253}