1use serde::{Deserialize, Serialize};
19
20#[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
58impl LoadedLicense {
60 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 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 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 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 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 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 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#[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#[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}