1use serde::{Deserialize, Serialize};
20
21#[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
59impl LoadedLicense {
61 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 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 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 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 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 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 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#[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#[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}