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