Skip to main content

provenant/license_detection/models/
loaded_license.rs

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