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    pub fn new(cost_model: CostModel) -> Self {
16        Self { cost_model }
17    }
18
19    /// Compute individual field changes between two components.
20    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        // Version change
25        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        // License change
37        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        // Supplier change
73        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        // Hash change (same version but different hash = integrity concern)
83        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        // Find removed components
129        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        // Find added components
141        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        // Find modified components
153        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                    // Check if component was actually modified
159                    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}