Skip to main content

sbom_tools/diff/changes/
licenses.rs

1//! License change computer implementation.
2
3use crate::diff::traits::{ChangeComputer, ComponentMatches, LicenseChangeSet};
4use crate::diff::{ComponentLicenseChange, LicenseChange};
5use crate::model::NormalizedSbom;
6use std::collections::{BTreeSet, HashMap};
7
8/// Computes license-level changes between SBOMs.
9pub struct LicenseChangeComputer;
10
11impl LicenseChangeComputer {
12    /// Create a new license change computer.
13    #[must_use]
14    pub const fn new() -> Self {
15        Self
16    }
17}
18
19impl Default for LicenseChangeComputer {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl ChangeComputer for LicenseChangeComputer {
26    type ChangeSet = LicenseChangeSet;
27
28    /// Computes global license-set changes plus per-component license
29    /// transitions for matched pairs.
30    ///
31    /// Only declared licenses are compared: parsers for SPDX documents
32    /// populate both declared and concluded, so including concluded would
33    /// double-flag the same transition.
34    fn compute(
35        &self,
36        old: &NormalizedSbom,
37        new: &NormalizedSbom,
38        matches: &ComponentMatches,
39    ) -> LicenseChangeSet {
40        let mut result = LicenseChangeSet::new();
41
42        // Collect all licenses from old SBOM
43        let mut old_licenses: HashMap<String, Vec<String>> = HashMap::new();
44        for (_id, comp) in &old.components {
45            for lic in &comp.licenses.declared {
46                old_licenses
47                    .entry(lic.expression.clone())
48                    .or_default()
49                    .push(comp.name.clone());
50            }
51        }
52
53        // Collect all licenses from new SBOM
54        let mut new_licenses: HashMap<String, Vec<String>> = HashMap::new();
55        for (_id, comp) in &new.components {
56            for lic in &comp.licenses.declared {
57                new_licenses
58                    .entry(lic.expression.clone())
59                    .or_default()
60                    .push(comp.name.clone());
61            }
62        }
63
64        // Find new licenses
65        for (license, components) in &new_licenses {
66            if !old_licenses.contains_key(license) {
67                result.new_licenses.push(LicenseChange {
68                    license: license.clone(),
69                    components: components.clone(),
70                    family: "Unknown".to_string(), // Would need license analysis
71                });
72            }
73        }
74
75        // Find removed licenses
76        for (license, components) in &old_licenses {
77            if !new_licenses.contains_key(license) {
78                result.removed_licenses.push(LicenseChange {
79                    license: license.clone(),
80                    components: components.clone(),
81                    family: "Unknown".to_string(),
82                });
83            }
84        }
85
86        // License sets are collected from hash-map iteration; sort for
87        // deterministic output ordering
88        result
89            .new_licenses
90            .sort_by(|a, b| a.license.cmp(&b.license));
91        result
92            .removed_licenses
93            .sort_by(|a, b| a.license.cmp(&b.license));
94
95        // Per-component license transitions for matched pairs
96        for (old_id, new_id_opt) in matches {
97            if let Some(new_id) = new_id_opt
98                && let (Some(old_comp), Some(new_comp)) =
99                    (old.components.get(old_id), new.components.get(new_id))
100            {
101                // Content hash covers declared licenses; equal hashes mean no change
102                if old_comp.content_hash == new_comp.content_hash {
103                    continue;
104                }
105
106                let old_set: BTreeSet<&str> = old_comp
107                    .licenses
108                    .declared
109                    .iter()
110                    .map(|lic| lic.expression.as_str())
111                    .collect();
112                let new_set: BTreeSet<&str> = new_comp
113                    .licenses
114                    .declared
115                    .iter()
116                    .map(|lic| lic.expression.as_str())
117                    .collect();
118
119                if old_set != new_set {
120                    result.component_changes.push(ComponentLicenseChange {
121                        component_id: new_id.to_string(),
122                        component_name: new_comp.name.clone(),
123                        old_licenses: old_set.into_iter().map(String::from).collect(),
124                        new_licenses: new_set.into_iter().map(String::from).collect(),
125                    });
126                }
127            }
128        }
129
130        result.component_changes.sort_by(|a, b| {
131            (&a.component_name, &a.component_id).cmp(&(&b.component_name, &b.component_id))
132        });
133
134        result
135    }
136
137    fn name(&self) -> &'static str {
138        "LicenseChangeComputer"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::model::{Component, LicenseExpression};
146
147    fn component_with_licenses(name: &str, format_id: &str, licenses: &[&str]) -> Component {
148        let mut comp = Component::new(name.to_string(), format_id.to_string());
149        for lic in licenses {
150            comp.licenses
151                .add_declared(LicenseExpression::new((*lic).to_string()));
152        }
153        // Mirror parser behavior: the hash-skip guard requires real hashes
154        comp.calculate_content_hash();
155        comp
156    }
157
158    fn sbom_with(components: Vec<Component>) -> NormalizedSbom {
159        let mut sbom = NormalizedSbom::default();
160        for comp in components {
161            sbom.add_component(comp);
162        }
163        sbom
164    }
165
166    #[test]
167    fn test_license_change_computer_default() {
168        let computer = LicenseChangeComputer;
169        assert_eq!(computer.name(), "LicenseChangeComputer");
170    }
171
172    #[test]
173    fn test_empty_sboms() {
174        let computer = LicenseChangeComputer;
175        let old = NormalizedSbom::default();
176        let new = NormalizedSbom::default();
177        let matches = ComponentMatches::new();
178
179        let result = computer.compute(&old, &new, &matches);
180        assert!(result.is_empty());
181    }
182
183    #[test]
184    fn matched_pair_license_transition() {
185        let old_comp = component_with_licenses("a", "a@1.0", &["MIT"]);
186        let new_comp = component_with_licenses("a", "a@1.0", &["GPL-3.0-only"]);
187        let mut matches = ComponentMatches::new();
188        matches.insert(
189            old_comp.canonical_id.clone(),
190            Some(new_comp.canonical_id.clone()),
191        );
192        let old = sbom_with(vec![old_comp]);
193        let new = sbom_with(vec![new_comp]);
194
195        let result = LicenseChangeComputer::new().compute(&old, &new, &matches);
196
197        assert_eq!(result.component_changes.len(), 1);
198        let change = &result.component_changes[0];
199        assert_eq!(change.component_name, "a");
200        assert_eq!(change.old_licenses, vec!["MIT".to_string()]);
201        assert_eq!(change.new_licenses, vec!["GPL-3.0-only".to_string()]);
202        assert_eq!(result.new_licenses.len(), 1);
203        assert_eq!(result.new_licenses[0].license, "GPL-3.0-only");
204        assert_eq!(result.removed_licenses.len(), 1);
205        assert_eq!(result.removed_licenses[0].license, "MIT");
206    }
207
208    #[test]
209    fn renamed_but_matched_component_included() {
210        let old_comp = component_with_licenses("a-old", "a-old@1.0", &["MIT"]);
211        let new_comp = component_with_licenses("a-new", "a-new@1.0", &["Apache-2.0"]);
212        let mut matches = ComponentMatches::new();
213        matches.insert(
214            old_comp.canonical_id.clone(),
215            Some(new_comp.canonical_id.clone()),
216        );
217        let new_id = new_comp.canonical_id.clone();
218        let old = sbom_with(vec![old_comp]);
219        let new = sbom_with(vec![new_comp]);
220
221        let result = LicenseChangeComputer::new().compute(&old, &new, &matches);
222
223        assert_eq!(result.component_changes.len(), 1);
224        assert_eq!(result.component_changes[0].component_name, "a-new");
225        assert_eq!(result.component_changes[0].component_id, new_id.to_string());
226    }
227
228    #[test]
229    fn unmatched_add_remove_not_in_component_changes() {
230        let old_comp = component_with_licenses("a", "a@1.0", &["MIT"]);
231        let new_comp = component_with_licenses("b", "b@1.0", &["Apache-2.0"]);
232        let mut matches = ComponentMatches::new();
233        matches.insert(old_comp.canonical_id.clone(), None);
234        let old = sbom_with(vec![old_comp]);
235        let new = sbom_with(vec![new_comp]);
236
237        let result = LicenseChangeComputer::new().compute(&old, &new, &matches);
238
239        assert!(result.component_changes.is_empty());
240        assert_eq!(result.new_licenses.len(), 1);
241        assert_eq!(result.removed_licenses.len(), 1);
242    }
243
244    #[test]
245    fn identical_license_sets_not_reported() {
246        let old_comp = component_with_licenses("a", "a@1.0", &["MIT"]);
247        let mut new_comp = component_with_licenses("a", "a@1.0", &["MIT"]);
248        new_comp.version = Some("2.0.0".to_string());
249        new_comp.calculate_content_hash();
250        let mut matches = ComponentMatches::new();
251        matches.insert(
252            old_comp.canonical_id.clone(),
253            Some(new_comp.canonical_id.clone()),
254        );
255        let old = sbom_with(vec![old_comp]);
256        let new = sbom_with(vec![new_comp]);
257
258        let result = LicenseChangeComputer::new().compute(&old, &new, &matches);
259
260        assert!(result.component_changes.is_empty());
261    }
262}