Skip to main content

sbom_tools/diff/changes/
components.rs

1//! Component change computer implementation.
2
3use crate::diff::traits::{ChangeComputer, ComponentChangeSet, ComponentMatches};
4use crate::diff::{ComponentChange, CostModel, FieldChange};
5use crate::model::{Component, NormalizedSbom};
6use std::collections::HashSet;
7
8/// Computes component-level changes between SBOMs.
9pub struct ComponentChangeComputer {
10    cost_model: CostModel,
11}
12
13impl ComponentChangeComputer {
14    /// Create a new component change computer with the given cost model.
15    #[must_use]
16    pub const fn new(cost_model: CostModel) -> Self {
17        Self { cost_model }
18    }
19
20    /// Compute individual field changes between two components.
21    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        // Version change
26        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        // License change
38        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        // Supplier change
74        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        // Hash change (same version but different hash = integrity concern)
84        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        // Find removed components
133        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        // Find added components
145        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        // Find modified components
157        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                // Check if component was actually modified
163                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}