sbom_tools/diff/changes/
components.rs1use crate::diff::traits::{ChangeComputer, ComponentChangeSet, ComponentMatches};
4use crate::diff::{ComponentChange, CostModel, FieldChange};
5use crate::model::{Component, NormalizedSbom};
6use std::collections::HashSet;
7
8pub struct ComponentChangeComputer {
10 cost_model: CostModel,
11}
12
13impl ComponentChangeComputer {
14 #[must_use]
16 pub const fn new(cost_model: CostModel) -> Self {
17 Self { cost_model }
18 }
19
20 fn compute_field_changes(&self, old: &Component, new: &Component) -> (Vec<FieldChange>, u32) {
22 let mut changes = Vec::new();
23 let mut total_cost = 0u32;
24
25 if old.version != new.version {
27 changes.push(FieldChange {
28 field: "version".to_string(),
29 old_value: old.version.clone(),
30 new_value: new.version.clone(),
31 });
32 total_cost += self
33 .cost_model
34 .version_change_cost(&old.semver, &new.semver);
35 }
36
37 let old_licenses: HashSet<_> = old
39 .licenses
40 .declared
41 .iter()
42 .map(|l| &l.expression)
43 .collect();
44 let new_licenses: HashSet<_> = new
45 .licenses
46 .declared
47 .iter()
48 .map(|l| &l.expression)
49 .collect();
50 if old_licenses != new_licenses {
51 changes.push(FieldChange {
52 field: "licenses".to_string(),
53 old_value: Some(
54 old.licenses
55 .declared
56 .iter()
57 .map(|l| l.expression.clone())
58 .collect::<Vec<_>>()
59 .join(", "),
60 ),
61 new_value: Some(
62 new.licenses
63 .declared
64 .iter()
65 .map(|l| l.expression.clone())
66 .collect::<Vec<_>>()
67 .join(", "),
68 ),
69 });
70 total_cost += self.cost_model.license_changed;
71 }
72
73 if old.supplier != new.supplier {
75 changes.push(FieldChange {
76 field: "supplier".to_string(),
77 old_value: old.supplier.as_ref().map(|s| s.name.clone()),
78 new_value: new.supplier.as_ref().map(|s| s.name.clone()),
79 });
80 total_cost += self.cost_model.supplier_changed;
81 }
82
83 if old.version == new.version && !old.hashes.is_empty() && !new.hashes.is_empty() {
85 let old_hashes: HashSet<_> = old.hashes.iter().map(|h| &h.value).collect();
86 let new_hashes: HashSet<_> = new.hashes.iter().map(|h| &h.value).collect();
87 if old_hashes.is_disjoint(&new_hashes) {
88 changes.push(FieldChange {
89 field: "hashes".to_string(),
90 old_value: Some(
91 old.hashes
92 .first()
93 .map(|h| h.value.clone())
94 .unwrap_or_default(),
95 ),
96 new_value: Some(
97 new.hashes
98 .first()
99 .map(|h| h.value.clone())
100 .unwrap_or_default(),
101 ),
102 });
103 total_cost += self.cost_model.hash_mismatch;
104 }
105 }
106
107 (changes, total_cost)
108 }
109}
110
111impl Default for ComponentChangeComputer {
112 fn default() -> Self {
113 Self::new(CostModel::default())
114 }
115}
116
117impl ChangeComputer for ComponentChangeComputer {
118 type ChangeSet = ComponentChangeSet;
119
120 fn compute(
121 &self,
122 old: &NormalizedSbom,
123 new: &NormalizedSbom,
124 matches: &ComponentMatches,
125 ) -> ComponentChangeSet {
126 let mut result = ComponentChangeSet::new();
127 let matched_new_ids: HashSet<_> = matches
128 .values()
129 .filter_map(std::clone::Clone::clone)
130 .collect();
131
132 for (old_id, new_id_opt) in matches {
134 if new_id_opt.is_none()
135 && let Some(old_comp) = old.components.get(old_id)
136 {
137 result.removed.push(ComponentChange::removed(
138 old_comp,
139 self.cost_model.component_removed,
140 ));
141 }
142 }
143
144 for new_id in new.components.keys() {
146 if !matched_new_ids.contains(new_id)
147 && let Some(new_comp) = new.components.get(new_id)
148 {
149 result.added.push(ComponentChange::added(
150 new_comp,
151 self.cost_model.component_added,
152 ));
153 }
154 }
155
156 for (old_id, new_id_opt) in matches {
158 if let Some(new_id) = new_id_opt
159 && let (Some(old_comp), Some(new_comp)) =
160 (old.components.get(old_id), new.components.get(new_id))
161 {
162 if old_comp.content_hash != new_comp.content_hash {
164 let (field_changes, cost) = self.compute_field_changes(old_comp, new_comp);
165 if !field_changes.is_empty() {
166 result.modified.push(ComponentChange::modified(
167 old_comp,
168 new_comp,
169 field_changes,
170 cost,
171 ));
172 }
173 }
174 }
175 }
176
177 result
178 }
179
180 fn name(&self) -> &'static str {
181 "ComponentChangeComputer"
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn test_component_change_computer_default() {
191 let computer = ComponentChangeComputer::default();
192 assert_eq!(computer.name(), "ComponentChangeComputer");
193 }
194
195 #[test]
196 fn test_empty_sboms() {
197 let computer = ComponentChangeComputer::default();
198 let old = NormalizedSbom::default();
199 let new = NormalizedSbom::default();
200 let matches = ComponentMatches::new();
201
202 let result = computer.compute(&old, &new, &matches);
203 assert!(result.is_empty());
204 }
205}