Skip to main content

provenant/license_detection/models/
loaded_license.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Loader-stage license type.
7//!
8//! This module defines `LoadedLicense`, which represents a parsed and normalized
9//! license file (.LICENSE) before it is converted to a runtime `License`.
10//!
11//! Loader-stage responsibilities include:
12//! - Key derivation from filename
13//! - Name fallback chain resolution
14//! - URL merging from multiple source fields
15//! - Text trimming and normalization
16//! - Deprecation metadata preservation (without filtering)
17
18use serde::{Deserialize, Serialize};
19
20/// Loader-stage representation of a license.
21///
22/// This struct contains parsed and normalized data from a .LICENSE file.
23/// It is serialized at build time and deserialized at runtime, then converted
24/// to a runtime `License` during the build stage.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct LoadedLicense {
27    pub key: String,
28    pub short_name: Option<String>,
29    pub name: String,
30    pub language: Option<String>,
31    pub spdx_license_key: Option<String>,
32    pub other_spdx_license_keys: Vec<String>,
33    pub category: Option<String>,
34    pub owner: Option<String>,
35    pub homepage_url: Option<String>,
36    pub text: String,
37    pub reference_urls: Vec<String>,
38    pub osi_license_key: Option<String>,
39    pub text_urls: Vec<String>,
40    pub osi_url: Option<String>,
41    pub faq_url: Option<String>,
42    pub other_urls: Vec<String>,
43    pub notes: Option<String>,
44    pub is_deprecated: bool,
45    pub is_exception: bool,
46    pub is_unknown: bool,
47    pub is_generic: bool,
48    pub replaced_by: Vec<String>,
49    pub minimum_coverage: Option<u8>,
50    pub standard_notice: Option<String>,
51    pub ignorable_copyrights: Option<Vec<String>>,
52    pub ignorable_holders: Option<Vec<String>>,
53    pub ignorable_authors: Option<Vec<String>>,
54    pub ignorable_urls: Option<Vec<String>>,
55    pub ignorable_emails: Option<Vec<String>>,
56}
57
58/// Loader-stage normalization functions for license data.
59impl LoadedLicense {
60    /// Derive key from filename.
61    ///
62    /// Returns the file stem (filename without extension) as the key.
63    /// This should match the `key` field in the frontmatter.
64    pub fn derive_key(path: &std::path::Path) -> Result<String, LicenseKeyError> {
65        path.file_stem()
66            .and_then(|s| s.to_str())
67            .map(|s| s.to_string())
68            .ok_or(LicenseKeyError::CannotExtractKey)
69    }
70
71    /// Validate that the frontmatter key matches the filename key.
72    pub fn validate_key_match(
73        filename_key: &str,
74        frontmatter_key: Option<&str>,
75    ) -> Result<(), LicenseKeyError> {
76        match frontmatter_key {
77            Some(fm_key) if fm_key != filename_key => Err(LicenseKeyError::KeyMismatch {
78                filename: filename_key.to_string(),
79                frontmatter: fm_key.to_string(),
80            }),
81            _ => Ok(()),
82        }
83    }
84
85    /// Derive name using the fallback chain.
86    ///
87    /// Priority order:
88    /// 1. `name` field
89    /// 2. `short_name` field
90    /// 3. `key` as fallback
91    pub fn derive_name(name: Option<&str>, short_name: Option<&str>, key: &str) -> String {
92        name.map(|s| s.trim().to_string())
93            .filter(|s| !s.is_empty())
94            .or_else(|| {
95                short_name
96                    .map(|s| s.trim().to_string())
97                    .filter(|s| !s.is_empty())
98            })
99            .unwrap_or_else(|| key.to_string())
100    }
101
102    /// Merge reference URLs from multiple source fields.
103    ///
104    /// Collects URLs in this order:
105    /// 1. text_urls
106    /// 2. other_urls
107    /// 3. osi_url
108    /// 4. faq_url
109    /// 5. homepage_url
110    pub fn merge_reference_urls(
111        text_urls: Option<&[String]>,
112        other_urls: Option<&[String]>,
113        osi_url: Option<&str>,
114        faq_url: Option<&str>,
115        homepage_url: Option<&str>,
116    ) -> Vec<String> {
117        let mut urls = Vec::new();
118
119        if let Some(u) = text_urls {
120            urls.extend(u.iter().cloned());
121        }
122        if let Some(u) = other_urls {
123            urls.extend(u.iter().cloned());
124        }
125        if let Some(u) = osi_url {
126            let u = u.trim();
127            if !u.is_empty() {
128                urls.push(u.to_string());
129            }
130        }
131        if let Some(u) = faq_url {
132            let u = u.trim();
133            if !u.is_empty() {
134                urls.push(u.to_string());
135            }
136        }
137        if let Some(u) = homepage_url {
138            let u = u.trim();
139            if !u.is_empty() {
140                urls.push(u.to_string());
141            }
142        }
143
144        urls
145    }
146
147    /// Normalize optional string field.
148    ///
149    /// Returns `None` for empty strings, `Some(trimmed)` otherwise.
150    pub fn normalize_optional_string(s: Option<&str>) -> Option<String> {
151        s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
152    }
153
154    /// Normalize optional string list.
155    ///
156    /// Returns `None` for empty lists, `Some(list)` with trimmed strings otherwise.
157    pub fn normalize_optional_list(list: Option<&[String]>) -> Option<Vec<String>> {
158        list.map(|l| {
159            l.iter()
160                .map(|s| s.trim().to_string())
161                .filter(|s| !s.is_empty())
162                .collect::<Vec<_>>()
163        })
164        .filter(|l: &Vec<String>| !l.is_empty())
165    }
166
167    /// Validate that a non-deprecated, non-unknown, non-generic license has text content.
168    pub fn validate_text_content(
169        text: &str,
170        is_deprecated: bool,
171        is_unknown: bool,
172        is_generic: bool,
173    ) -> Result<(), LicenseTextError> {
174        if text.trim().is_empty() && !is_deprecated && !is_unknown && !is_generic {
175            Err(LicenseTextError::EmptyText)
176        } else {
177            Ok(())
178        }
179    }
180}
181
182/// Error type for license key validation failures.
183#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
184pub enum LicenseKeyError {
185    #[error("cannot extract key from license file path")]
186    CannotExtractKey,
187    #[error("license key mismatch: filename '{filename}' vs frontmatter '{frontmatter}'")]
188    KeyMismatch {
189        filename: String,
190        frontmatter: String,
191    },
192}
193
194/// Error type for license text validation failures.
195#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
196pub enum LicenseTextError {
197    #[error("license file has empty text content and is not deprecated/unknown/generic")]
198    EmptyText,
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::path::PathBuf;
205
206    #[test]
207    fn test_derive_key() {
208        assert_eq!(
209            LoadedLicense::derive_key(&PathBuf::from("licenses/mit.LICENSE")),
210            Ok("mit".to_string())
211        );
212        assert_eq!(
213            LoadedLicense::derive_key(&PathBuf::from("/path/to/apache-2.0.LICENSE")),
214            Ok("apache-2.0".to_string())
215        );
216        assert_eq!(
217            LoadedLicense::derive_key(&PathBuf::from("no-extension")),
218            Ok("no-extension".to_string())
219        );
220        assert_eq!(
221            LoadedLicense::derive_key(&PathBuf::from("/")),
222            Err(LicenseKeyError::CannotExtractKey)
223        );
224    }
225
226    #[test]
227    fn test_validate_key_match() {
228        assert!(LoadedLicense::validate_key_match("mit", Some("mit")).is_ok());
229        assert!(LoadedLicense::validate_key_match("mit", None).is_ok());
230        assert_eq!(
231            LoadedLicense::validate_key_match("mit", Some("apache")),
232            Err(LicenseKeyError::KeyMismatch {
233                filename: "mit".to_string(),
234                frontmatter: "apache".to_string()
235            })
236        );
237    }
238
239    #[test]
240    fn test_derive_name() {
241        assert_eq!(
242            LoadedLicense::derive_name(Some("MIT License"), None, "mit"),
243            "MIT License"
244        );
245        assert_eq!(LoadedLicense::derive_name(None, Some("MIT"), "mit"), "MIT");
246        assert_eq!(
247            LoadedLicense::derive_name(Some("  MIT License  "), None, "mit"),
248            "MIT License"
249        );
250        assert_eq!(LoadedLicense::derive_name(None, None, "mit"), "mit");
251        assert_eq!(
252            LoadedLicense::derive_name(Some(""), Some("Short"), "key"),
253            "Short"
254        );
255        assert_eq!(LoadedLicense::derive_name(Some("   "), None, "key"), "key");
256    }
257
258    #[test]
259    fn test_merge_reference_urls() {
260        let text_urls = vec!["https://example.com/text".to_string()];
261        let other_urls = vec!["https://example.com/other".to_string()];
262
263        let urls = LoadedLicense::merge_reference_urls(
264            Some(&text_urls),
265            Some(&other_urls),
266            Some("https://opensource.org/licenses/MIT"),
267            Some("https://example.com/faq"),
268            Some("https://example.com/home"),
269        );
270        assert_eq!(urls.len(), 5);
271        assert_eq!(urls[0], "https://example.com/text");
272        assert_eq!(urls[1], "https://example.com/other");
273        assert_eq!(urls[2], "https://opensource.org/licenses/MIT");
274        assert_eq!(urls[3], "https://example.com/faq");
275        assert_eq!(urls[4], "https://example.com/home");
276    }
277
278    #[test]
279    fn test_merge_reference_urls_empty() {
280        let urls = LoadedLicense::merge_reference_urls(None, None, None, None, None);
281        assert!(urls.is_empty());
282    }
283
284    #[test]
285    fn test_merge_reference_urls_trims_whitespace() {
286        let urls = LoadedLicense::merge_reference_urls(
287            None,
288            None,
289            Some("  https://example.com  "),
290            None,
291            None,
292        );
293        assert_eq!(urls, vec!["https://example.com"]);
294    }
295
296    #[test]
297    fn test_normalize_optional_string() {
298        assert_eq!(LoadedLicense::normalize_optional_string(None), None);
299        assert_eq!(LoadedLicense::normalize_optional_string(Some("")), None);
300        assert_eq!(LoadedLicense::normalize_optional_string(Some("   ")), None);
301        assert_eq!(
302            LoadedLicense::normalize_optional_string(Some("hello")),
303            Some("hello".to_string())
304        );
305        assert_eq!(
306            LoadedLicense::normalize_optional_string(Some("  hello  ")),
307            Some("hello".to_string())
308        );
309    }
310
311    #[test]
312    fn test_normalize_optional_list() {
313        assert_eq!(LoadedLicense::normalize_optional_list(None), None);
314        assert_eq!(LoadedLicense::normalize_optional_list(Some(&[])), None);
315        assert_eq!(
316            LoadedLicense::normalize_optional_list(Some(&["a".to_string(), "b".to_string()])),
317            Some(vec!["a".to_string(), "b".to_string()])
318        );
319    }
320
321    #[test]
322    fn test_validate_text_content() {
323        assert!(LoadedLicense::validate_text_content("some text", false, false, false).is_ok());
324        assert!(LoadedLicense::validate_text_content("", true, false, false).is_ok());
325        assert!(LoadedLicense::validate_text_content("", false, true, false).is_ok());
326        assert!(LoadedLicense::validate_text_content("", false, false, true).is_ok());
327        assert_eq!(
328            LoadedLicense::validate_text_content("", false, false, false),
329            Err(LicenseTextError::EmptyText)
330        );
331        assert_eq!(
332            LoadedLicense::validate_text_content("   ", false, false, false),
333            Err(LicenseTextError::EmptyText)
334        );
335    }
336
337    #[test]
338    fn test_serde_roundtrip() {
339        let license = LoadedLicense {
340            key: "mit".to_string(),
341            short_name: Some("MIT".to_string()),
342            name: "MIT License".to_string(),
343            language: Some("en".to_string()),
344            spdx_license_key: Some("MIT".to_string()),
345            other_spdx_license_keys: vec![],
346            category: Some("Permissive".to_string()),
347            owner: Some("Open Source Initiative".to_string()),
348            homepage_url: Some("https://opensource.org/licenses/MIT".to_string()),
349            text: "MIT License text".to_string(),
350            reference_urls: vec!["https://opensource.org/licenses/MIT".to_string()],
351            osi_license_key: Some("MIT".to_string()),
352            text_urls: vec!["https://opensource.org/licenses/MIT".to_string()],
353            osi_url: Some("https://opensource.org/licenses/MIT".to_string()),
354            faq_url: None,
355            other_urls: vec![],
356            notes: Some("Test note".to_string()),
357            is_deprecated: false,
358            is_exception: false,
359            is_unknown: false,
360            is_generic: false,
361            replaced_by: vec![],
362            minimum_coverage: None,
363            standard_notice: None,
364            ignorable_copyrights: None,
365            ignorable_holders: None,
366            ignorable_authors: None,
367            ignorable_urls: None,
368            ignorable_emails: None,
369        };
370
371        let json = serde_json::to_string(&license).unwrap();
372        let deserialized: LoadedLicense = serde_json::from_str(&json).unwrap();
373        assert_eq!(license, deserialized);
374    }
375}