1use crate::license_detection::index::{loaded_license_to_license, loaded_rule_to_rule};
18use crate::license_detection::models::{License, LoadedLicense, LoadedRule, Rule};
19use anyhow::{Context, Result, anyhow};
20use log::warn;
21use regex::Regex;
22use serde::{Deserialize, Deserializer, Serialize};
23use std::collections::HashSet;
24use std::fs;
25use std::path::Path;
26use std::sync::LazyLock;
27
28static FM_BOUNDARY: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"(?m)^-{3,}\s*$").expect("Invalid frontmatter regex"));
30
31fn deserialize_yes_no_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
32where
33 D: Deserializer<'de>,
34{
35 #[derive(Deserialize, Serialize)]
36 #[serde(untagged)]
37 enum YesNoOrBool {
38 String(String),
39 Bool(bool),
40 }
41
42 match YesNoOrBool::deserialize(deserializer)? {
43 YesNoOrBool::Bool(b) => Ok(Some(b)),
44 YesNoOrBool::String(s) => {
45 let lower = s.to_lowercase();
46 if lower == "yes" || lower == "true" || lower == "1" {
47 Ok(Some(true))
48 } else if lower == "no" || lower == "false" || lower == "0" {
49 Ok(Some(false))
50 } else {
51 Ok(None)
52 }
53 }
54 }
55}
56
57trait ParseNumber {
58 fn as_u8(&self) -> Option<u8>;
59}
60
61impl ParseNumber for yaml_serde::Number {
62 fn as_u8(&self) -> Option<u8> {
63 self.as_i64()
64 .and_then(|n| u8::try_from(n).ok())
65 .or_else(|| {
66 self.as_f64().and_then(|f| {
67 if f >= 0.0 && f <= f64::from(u8::MAX) {
68 #[allow(clippy::cast_sign_loss)]
70 Some(f as u8)
71 } else {
72 None
73 }
74 })
75 })
76 }
77}
78
79#[derive(Debug, Deserialize)]
81#[allow(dead_code)]
82struct LicenseFrontmatter {
83 #[serde(default)]
84 key: Option<String>,
85
86 #[serde(default)]
87 short_name: Option<String>,
88
89 #[serde(default)]
90 name: Option<String>,
91
92 #[serde(default)]
93 category: Option<String>,
94
95 #[serde(default)]
96 owner: Option<String>,
97
98 #[serde(default)]
99 homepage_url: Option<String>,
100
101 #[serde(default)]
102 notes: Option<String>,
103
104 #[serde(default)]
105 spdx_license_key: Option<String>,
106
107 #[serde(default)]
108 other_spdx_license_keys: Option<Vec<String>>,
109
110 #[serde(default)]
111 osi_license_key: Option<String>,
112
113 #[serde(default)]
114 text_urls: Option<Vec<String>>,
115
116 #[serde(default)]
117 osi_url: Option<String>,
118
119 #[serde(default)]
120 faq_url: Option<String>,
121
122 #[serde(default)]
123 other_urls: Option<Vec<String>>,
124
125 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
126 is_deprecated: Option<bool>,
127
128 #[serde(default)]
129 replaced_by: Option<Vec<String>>,
130
131 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
132 is_exception: Option<bool>,
133
134 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
135 is_unknown: Option<bool>,
136
137 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
138 is_generic: Option<bool>,
139
140 #[serde(default)]
141 minimum_coverage: Option<yaml_serde::Number>,
142
143 #[serde(default)]
144 standard_notice: Option<String>,
145
146 #[serde(default)]
147 ignorable_copyrights: Option<Vec<String>>,
148
149 #[serde(default)]
150 ignorable_holders: Option<Vec<String>>,
151
152 #[serde(default)]
153 ignorable_authors: Option<Vec<String>>,
154
155 #[serde(default)]
156 ignorable_urls: Option<Vec<String>>,
157
158 #[serde(default)]
159 ignorable_emails: Option<Vec<String>>,
160}
161
162struct ParsedRuleFile {
164 yaml_content: String,
165 text_content: String,
166 has_stored_minimum_coverage: bool,
167}
168
169struct ParsedLicenseFile {
171 yaml_content: String,
172 text_content: String,
173}
174
175fn parse_file_content(content: &str, path: &Path) -> Result<ParsedRuleFile> {
180 if content.len() < 6 {
181 return Err(anyhow!("File content too short: {}", path.display()));
182 }
183
184 let parts: Vec<&str> = FM_BOUNDARY.splitn(content, 3).collect();
185
186 if parts.len() < 3 {
187 let trimmed = content.trim();
188 if trimmed.is_empty() {
189 return Err(anyhow!(
190 "File is empty or has no content: {}",
191 path.display()
192 ));
193 }
194 return Err(anyhow!("File missing delimiter '---': {}", path.display()));
195 }
196
197 let yaml_content = parts
198 .get(1)
199 .ok_or_else(|| anyhow!("Missing YAML frontmatter in {}", path.display()))?
200 .to_string();
201 let text_content = parts
202 .get(2)
203 .ok_or_else(|| {
204 anyhow!(
205 "Missing text content after frontmatter in {}",
206 path.display()
207 )
208 })?
209 .trim_start_matches('\n')
210 .trim()
211 .to_string();
212
213 let frontmatter_value: yaml_serde::Value =
214 yaml_serde::from_str(&yaml_content).map_err(|e| {
215 anyhow!(
216 "Failed to parse frontmatter YAML in {}: {}\nContent was:\n{}",
217 path.display(),
218 e,
219 yaml_content
220 )
221 })?;
222
223 let has_stored_minimum_coverage = frontmatter_value.as_mapping().is_some_and(|mapping| {
224 mapping.contains_key(yaml_serde::Value::String("minimum_coverage".to_string()))
225 });
226
227 Ok(ParsedRuleFile {
228 yaml_content,
229 text_content,
230 has_stored_minimum_coverage,
231 })
232}
233
234#[derive(Debug, Deserialize)]
236#[allow(dead_code)]
237struct RuleFrontmatter {
238 #[serde(default)]
239 license_expression: Option<String>,
240
241 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
242 is_license_text: Option<bool>,
243
244 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
245 is_license_notice: Option<bool>,
246
247 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
248 is_license_reference: Option<bool>,
249
250 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
251 is_license_tag: Option<bool>,
252
253 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
254 is_license_intro: Option<bool>,
255
256 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
257 is_license_clue: Option<bool>,
258
259 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
260 is_false_positive: Option<bool>,
261
262 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
263 is_required_phrase: Option<bool>,
264
265 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
266 skip_for_required_phrase_generation: Option<bool>,
267
268 #[serde(default)]
269 relevance: Option<yaml_serde::Number>,
270
271 #[serde(default)]
272 minimum_coverage: Option<yaml_serde::Number>,
273
274 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
275 is_continuous: Option<bool>,
276
277 #[serde(default, deserialize_with = "deserialize_yes_no_bool")]
278 is_deprecated: Option<bool>,
279
280 #[serde(default)]
281 referenced_filenames: Option<Vec<String>>,
282
283 #[serde(default)]
284 replaced_by: Option<Vec<String>>,
285
286 #[serde(default)]
287 ignorable_urls: Option<Vec<String>>,
288
289 #[serde(default)]
290 ignorable_emails: Option<Vec<String>>,
291
292 #[serde(default)]
293 notes: Option<String>,
294
295 #[serde(default)]
296 ignorable_copyrights: Option<Vec<String>>,
297
298 #[serde(default)]
299 ignorable_holders: Option<Vec<String>>,
300
301 #[serde(default)]
302 ignorable_authors: Option<Vec<String>>,
303
304 #[serde(default)]
305 language: Option<String>,
306}
307
308fn parse_rule_source_to_loaded(
309 identifier: &str,
310 content: &str,
311 source_path: &Path,
312) -> Result<LoadedRule> {
313 let identifier = LoadedRule::derive_identifier(
314 source_path
315 .file_name()
316 .and_then(|s| s.to_str())
317 .unwrap_or(identifier),
318 );
319
320 let parsed = parse_file_content(content, source_path)?;
321
322 if parsed.text_content.is_empty() {
323 return Err(anyhow!(
324 "Rule file has empty text content: {}",
325 source_path.display()
326 ));
327 }
328
329 let fm: RuleFrontmatter = yaml_serde::from_str(&parsed.yaml_content).map_err(|e| {
330 anyhow!(
331 "Failed to parse rule frontmatter YAML in {}: {}\nContent was:\n{}",
332 source_path.display(),
333 e,
334 parsed.yaml_content
335 )
336 })?;
337
338 let is_false_positive = fm.is_false_positive.unwrap_or(false);
339
340 let rule_kind = LoadedRule::derive_rule_kind(
341 fm.is_license_text.unwrap_or(false),
342 fm.is_license_notice.unwrap_or(false),
343 fm.is_license_reference.unwrap_or(false),
344 fm.is_license_tag.unwrap_or(false),
345 fm.is_license_intro.unwrap_or(false),
346 fm.is_license_clue.unwrap_or(false),
347 )
348 .map_err(|e| {
349 anyhow!(
350 "Rule file has invalid rule-kind flags: {}: {}",
351 source_path.display(),
352 e
353 )
354 })?;
355
356 LoadedRule::validate_rule_kind_flags(rule_kind, is_false_positive).map_err(|e| {
357 anyhow!(
358 "Rule file has invalid flags: {}: {}",
359 source_path.display(),
360 e
361 )
362 })?;
363
364 let license_expression = LoadedRule::normalize_license_expression(
365 fm.license_expression.as_deref(),
366 is_false_positive,
367 )
368 .map_err(|e| {
369 anyhow!(
370 "Rule file has invalid license_expression: {}: {}",
371 source_path.display(),
372 e
373 )
374 })?;
375
376 let relevance = fm.relevance.and_then(|n| n.as_u8());
377
378 let minimum_coverage = fm.minimum_coverage.and_then(|n| n.as_u8());
379
380 Ok(LoadedRule {
381 identifier,
382 license_expression,
383 text: parsed.text_content,
384 rule_kind,
385 is_false_positive,
386 is_required_phrase: fm.is_required_phrase.unwrap_or(false),
387 skip_for_required_phrase_generation: fm
388 .skip_for_required_phrase_generation
389 .unwrap_or(false),
390 relevance,
391 minimum_coverage,
392 has_stored_minimum_coverage: parsed.has_stored_minimum_coverage,
393 is_continuous: fm.is_continuous.unwrap_or(false),
394 referenced_filenames: LoadedRule::normalize_optional_list(
395 fm.referenced_filenames.as_deref(),
396 ),
397 ignorable_urls: LoadedRule::normalize_optional_list(fm.ignorable_urls.as_deref()),
398 ignorable_emails: LoadedRule::normalize_optional_list(fm.ignorable_emails.as_deref()),
399 ignorable_copyrights: LoadedRule::normalize_optional_list(
400 fm.ignorable_copyrights.as_deref(),
401 ),
402 ignorable_holders: LoadedRule::normalize_optional_list(fm.ignorable_holders.as_deref()),
403 ignorable_authors: LoadedRule::normalize_optional_list(fm.ignorable_authors.as_deref()),
404 language: LoadedRule::normalize_optional_string(fm.language.as_deref()),
405 notes: LoadedRule::normalize_optional_string(fm.notes.as_deref()),
406 is_deprecated: fm.is_deprecated.unwrap_or(false),
407 replaced_by: fm.replaced_by.unwrap_or_default(),
408 })
409}
410
411pub fn parse_rule_to_loaded(path: &Path) -> Result<LoadedRule> {
416 let content = fs::read_to_string(path)
417 .with_context(|| format!("Failed to read rule file: {}", path.display()))?;
418 parse_rule_source_to_loaded(
419 path.file_name()
420 .and_then(|s| s.to_str())
421 .unwrap_or("unknown.RULE"),
422 &content,
423 path,
424 )
425}
426
427pub fn parse_rule_str_to_loaded(identifier: &str, content: &str) -> Result<LoadedRule> {
429 let synthetic_path = Path::new(identifier);
430 parse_rule_source_to_loaded(identifier, content, synthetic_path)
431}
432
433fn parse_license_source_to_loaded(
434 filename: &str,
435 content: &str,
436 source_path: &Path,
437) -> Result<LoadedLicense> {
438 let key = LoadedLicense::derive_key(Path::new(filename))?;
439
440 let parsed = parse_license_file_content(content, source_path)?;
441
442 let fm: LicenseFrontmatter = yaml_serde::from_str(&parsed.yaml_content).map_err(|e| {
443 anyhow!(
444 "Failed to parse license frontmatter YAML in {}: {}\nContent was:\n{}",
445 source_path.display(),
446 e,
447 parsed.yaml_content
448 )
449 })?;
450
451 LoadedLicense::validate_key_match(&key, fm.key.as_deref()).map_err(|e| {
452 anyhow!(
453 "License file has key mismatch: {}: {}",
454 source_path.display(),
455 e
456 )
457 })?;
458
459 let is_deprecated = fm.is_deprecated.unwrap_or(false);
460 let is_unknown = fm.is_unknown.unwrap_or(false);
461 let is_generic = fm.is_generic.unwrap_or(false);
462
463 LoadedLicense::validate_text_content(
464 &parsed.text_content,
465 is_deprecated,
466 is_unknown,
467 is_generic,
468 )
469 .map_err(|e| {
470 anyhow!(
471 "License file has invalid content: {}: {}",
472 source_path.display(),
473 e
474 )
475 })?;
476
477 let name = LoadedLicense::derive_name(fm.name.as_deref(), fm.short_name.as_deref(), &key);
478
479 let reference_urls = LoadedLicense::merge_reference_urls(
480 fm.text_urls.as_deref(),
481 fm.other_urls.as_deref(),
482 fm.osi_url.as_deref(),
483 fm.faq_url.as_deref(),
484 fm.homepage_url.as_deref(),
485 );
486
487 let minimum_coverage = fm.minimum_coverage.and_then(|n| n.as_u8());
488
489 Ok(LoadedLicense {
490 key,
491 short_name: LoadedLicense::normalize_optional_string(fm.short_name.as_deref()),
492 name,
493 language: Some("en".to_string()),
494 spdx_license_key: LoadedLicense::normalize_optional_string(fm.spdx_license_key.as_deref()),
495 other_spdx_license_keys: fm.other_spdx_license_keys.unwrap_or_default(),
496 category: LoadedLicense::normalize_optional_string(fm.category.as_deref()),
497 owner: LoadedLicense::normalize_optional_string(fm.owner.as_deref()),
498 homepage_url: LoadedLicense::normalize_optional_string(fm.homepage_url.as_deref()),
499 text: parsed.text_content,
500 reference_urls,
501 osi_license_key: LoadedLicense::normalize_optional_string(fm.osi_license_key.as_deref()),
502 text_urls: LoadedLicense::normalize_optional_list(fm.text_urls.as_deref())
503 .unwrap_or_default(),
504 osi_url: LoadedLicense::normalize_optional_string(fm.osi_url.as_deref()),
505 faq_url: LoadedLicense::normalize_optional_string(fm.faq_url.as_deref()),
506 other_urls: LoadedLicense::normalize_optional_list(fm.other_urls.as_deref())
507 .unwrap_or_default(),
508 notes: LoadedLicense::normalize_optional_string(fm.notes.as_deref()),
509 is_deprecated,
510 is_exception: fm.is_exception.unwrap_or(false),
511 is_unknown,
512 is_generic,
513 replaced_by: fm.replaced_by.unwrap_or_default(),
514 minimum_coverage,
515 standard_notice: LoadedLicense::normalize_optional_string(fm.standard_notice.as_deref()),
516 ignorable_copyrights: LoadedLicense::normalize_optional_list(
517 fm.ignorable_copyrights.as_deref(),
518 ),
519 ignorable_holders: LoadedLicense::normalize_optional_list(fm.ignorable_holders.as_deref()),
520 ignorable_authors: LoadedLicense::normalize_optional_list(fm.ignorable_authors.as_deref()),
521 ignorable_urls: LoadedLicense::normalize_optional_list(fm.ignorable_urls.as_deref()),
522 ignorable_emails: LoadedLicense::normalize_optional_list(fm.ignorable_emails.as_deref()),
523 })
524}
525
526pub fn parse_license_to_loaded(path: &Path) -> Result<LoadedLicense> {
531 let content = fs::read_to_string(path)
532 .with_context(|| format!("Failed to read license file: {}", path.display()))?;
533 parse_license_source_to_loaded(
534 path.file_name()
535 .and_then(|s| s.to_str())
536 .unwrap_or("unknown.LICENSE"),
537 &content,
538 path,
539 )
540}
541
542pub fn parse_license_str_to_loaded(filename: &str, content: &str) -> Result<LoadedLicense> {
544 let synthetic_path = Path::new(filename);
545 parse_license_source_to_loaded(filename, content, synthetic_path)
546}
547
548fn parse_license_file_content(content: &str, path: &Path) -> Result<ParsedLicenseFile> {
552 if content.len() < 6 {
553 return Err(anyhow!(
554 "License file content too short: {}",
555 path.display()
556 ));
557 }
558
559 let parts: Vec<&str> = FM_BOUNDARY.splitn(content, 3).collect();
560
561 if parts.len() < 3 {
562 let trimmed = content.trim();
563 if trimmed.is_empty() {
564 return Err(anyhow!(
565 "License file is empty or has no content: {}",
566 path.display()
567 ));
568 }
569 return Err(anyhow!(
570 "License file missing delimiter '---': {}",
571 path.display()
572 ));
573 }
574
575 let yaml_content = parts
576 .get(1)
577 .ok_or_else(|| anyhow!("Missing YAML frontmatter in {}", path.display()))?
578 .to_string();
579 let text_content = parts
580 .get(2)
581 .ok_or_else(|| {
582 anyhow!(
583 "Missing text content after frontmatter in {}",
584 path.display()
585 )
586 })?
587 .trim_start_matches('\n')
588 .trim()
589 .to_string();
590
591 Ok(ParsedLicenseFile {
592 yaml_content,
593 text_content,
594 })
595}
596
597pub fn load_loaded_rules_from_directory(dir: &Path) -> Result<Vec<LoadedRule>> {
609 let mut rules = Vec::new();
610
611 let entries = fs::read_dir(dir)
612 .with_context(|| format!("Failed to read rules directory: {}", dir.display()))?;
613
614 for entry in entries {
615 let entry = entry
616 .with_context(|| format!("Failed to read directory entry in: {}", dir.display()))?;
617 let path = entry.path();
618
619 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("RULE") {
620 match parse_rule_to_loaded(&path) {
621 Ok(rule) => rules.push(rule),
622 Err(e) => {
623 warn!("Failed to parse rule file {}: {}", path.display(), e);
624 }
625 }
626 }
627 }
628
629 Ok(rules)
630}
631
632pub fn load_loaded_licenses_from_directory(dir: &Path) -> Result<Vec<LoadedLicense>> {
644 let mut licenses = Vec::new();
645
646 let entries = fs::read_dir(dir)
647 .with_context(|| format!("Failed to read licenses directory: {}", dir.display()))?;
648
649 for entry in entries {
650 let entry = entry
651 .with_context(|| format!("Failed to read directory entry in: {}", dir.display()))?;
652 let path = entry.path();
653
654 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("LICENSE") {
655 match parse_license_to_loaded(&path) {
656 Ok(license) => licenses.push(license),
657 Err(e) => {
658 warn!("Failed to parse license file {}: {}", path.display(), e);
659 }
660 }
661 }
662 }
663
664 Ok(licenses)
665}
666
667#[allow(dead_code)]
679fn validate_rules(rules: &[Rule]) {
680 let mut seen_texts: HashSet<&str> = HashSet::new();
681 let mut duplicate_count = 0;
682
683 for rule in rules {
684 if !seen_texts.insert(&rule.text) {
685 warn!(
686 "Duplicate rule text found for license_expression: {}",
687 rule.license_expression
688 );
689 duplicate_count += 1;
690 }
691
692 if !rule.is_false_positive && rule.license_expression.trim().is_empty() {
693 warn!("Rule has empty license_expression but is not marked as false_positive");
694 }
695 }
696
697 if duplicate_count > 0 {
698 warn!(
699 "Found {} duplicate rule text(s) during rule validation",
700 duplicate_count
701 );
702 }
703}
704
705#[allow(dead_code)]
714pub fn load_rules_from_directory(dir: &Path, with_deprecated: bool) -> Result<Vec<Rule>> {
715 let loaded = load_loaded_rules_from_directory(dir)?;
716 let rules: Vec<Rule> = loaded
717 .into_iter()
718 .filter(|r| with_deprecated || !r.is_deprecated)
719 .map(loaded_rule_to_rule)
720 .collect();
721 validate_rules(&rules);
722 Ok(rules)
723}
724
725#[allow(dead_code)]
734pub fn load_licenses_from_directory(dir: &Path, with_deprecated: bool) -> Result<Vec<License>> {
735 let loaded = load_loaded_licenses_from_directory(dir)?;
736 let licenses: Vec<License> = loaded
737 .into_iter()
738 .filter(|l| with_deprecated || !l.is_deprecated)
739 .map(loaded_license_to_license)
740 .collect();
741 Ok(licenses)
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use std::collections::HashMap;
748 use std::fs;
749 use tempfile::tempdir;
750
751 pub fn parse_rule_file(path: &Path) -> Result<Rule> {
752 let loaded = parse_rule_to_loaded(path)?;
753 Ok(loaded_rule_to_rule(loaded))
754 }
755
756 #[test]
757 fn test_parse_number_as_u8() {
758 let num_int: yaml_serde::Number = yaml_serde::from_str("100").unwrap();
759 assert_eq!(num_int.as_u8(), Some(100));
760
761 let num_out_of_range: yaml_serde::Number = yaml_serde::from_str("500").unwrap();
762 assert_eq!(num_out_of_range.as_u8(), None);
763
764 let num_float: yaml_serde::Number = yaml_serde::from_str("90.5").unwrap();
765 assert_eq!(num_float.as_u8(), Some(90));
766 }
767
768 #[test]
769 fn test_parse_simple_license_file() {
770 let dir = tempdir().unwrap();
771 let license_path = dir.path().join("mit.LICENSE");
772 fs::write(
773 &license_path,
774 r#"---
775key: mit
776short_name: MIT License
777name: MIT License
778category: Permissive
779spdx_license_key: MIT
780---
781MIT License text here"#,
782 )
783 .unwrap();
784
785 let license = parse_license_to_loaded(&license_path)
786 .map(loaded_license_to_license)
787 .unwrap();
788 assert_eq!(license.key, "mit");
789 assert_eq!(license.name, "MIT License");
790 assert!(license.text.contains("MIT License text"));
791 }
792
793 #[test]
794 fn test_parse_simple_rule_file() {
795 let dir = tempdir().unwrap();
796 let rule_path = dir.path().join("mit_1.RULE");
797 fs::write(
798 &rule_path,
799 r#"---
800license_expression: mit
801is_license_reference: yes
802relevance: 90
803referenced_filenames:
804 - MIT.txt
805---
806MIT.txt"#,
807 )
808 .unwrap();
809
810 let rule = parse_rule_file(&rule_path).unwrap();
811 assert_eq!(rule.license_expression, "mit");
812 assert_eq!(rule.text, "MIT.txt");
813 assert!(rule.is_license_reference());
814 assert_eq!(rule.relevance, 90);
815 }
816
817 #[test]
818 fn test_deserialize_yes_no_bool() {
819 let dir = tempdir().unwrap();
820 let rule_path = dir.path().join("test.RULE");
821
822 fs::write(
823 &rule_path,
824 r#"---
825license_expression: mit
826is_license_notice: yes
827is_license_tag: no
828---
829MIT License"#,
830 )
831 .unwrap();
832
833 let rule = parse_rule_file(&rule_path).unwrap();
834 assert!(rule.is_license_notice());
835 assert!(!rule.is_license_tag());
836 }
837
838 #[test]
839 fn test_load_licenses_from_directory() {
840 let dir = tempdir().unwrap();
841
842 fs::write(
843 dir.path().join("test.LICENSE"),
844 r#"---
845key: test
846name: Test License
847spdx_license_key: TEST
848category: Permissive
849---
850Test license text here"#,
851 )
852 .unwrap();
853
854 let licenses = load_licenses_from_directory(dir.path(), false).unwrap();
855 assert_eq!(licenses.len(), 1);
856
857 let license = &licenses[0];
858 assert_eq!(license.key, "test");
859 assert_eq!(license.name, "Test License");
860 assert_eq!(license.spdx_license_key, Some("TEST".to_string()));
861 assert!(!license.text.is_empty());
862 }
863
864 #[test]
865 fn test_load_rules_from_directory() {
866 let dir = tempdir().unwrap();
867
868 fs::write(
869 dir.path().join("test_1.RULE"),
870 r#"---
871license_expression: test
872is_license_reference: yes
873relevance: 85
874referenced_filenames:
875 - TEST.txt
876---
877TEST.txt"#,
878 )
879 .unwrap();
880
881 let rules = load_rules_from_directory(dir.path(), false).unwrap();
882 assert_eq!(rules.len(), 1);
883
884 let rule = &rules[0];
885 assert_eq!(rule.license_expression, "test");
886 assert!(rule.is_license_reference());
887 assert_eq!(rule.relevance, 85);
888 }
889
890 #[test]
891 fn test_validate_rules_detects_duplicates() {
892 let rules = vec![
893 Rule {
894 identifier: "mit.LICENSE".to_string(),
895 license_expression: "mit".to_string(),
896 text: "MIT License".to_string(),
897 tokens: vec![],
898 rule_kind: crate::license_detection::models::RuleKind::Text,
899 is_false_positive: false,
900 is_required_phrase: false,
901 is_from_license: false,
902 relevance: 100,
903 minimum_coverage: None,
904 has_stored_minimum_coverage: false,
905 is_continuous: false,
906 required_phrase_spans: vec![],
907 stopwords_by_pos: HashMap::new(),
908 referenced_filenames: None,
909 ignorable_urls: None,
910 ignorable_emails: None,
911 ignorable_copyrights: None,
912 ignorable_holders: None,
913 ignorable_authors: None,
914 language: None,
915 notes: None,
916 length_unique: 0,
917 high_length_unique: 0,
918 high_length: 0,
919 min_matched_length: 0,
920 min_high_matched_length: 0,
921 min_matched_length_unique: 0,
922 min_high_matched_length_unique: 0,
923 is_small: false,
924 is_tiny: false,
925 starts_with_license: false,
926 ends_with_license: false,
927 is_deprecated: false,
928 spdx_license_key: None,
929 other_spdx_license_keys: vec![],
930 },
931 Rule {
932 identifier: "apache-2.0.LICENSE".to_string(),
933 license_expression: "apache-2.0".to_string(),
934 text: "MIT License".to_string(),
935 tokens: vec![],
936 rule_kind: crate::license_detection::models::RuleKind::Text,
937 is_false_positive: false,
938 is_required_phrase: false,
939 is_from_license: false,
940 relevance: 100,
941 minimum_coverage: None,
942 has_stored_minimum_coverage: false,
943 is_continuous: false,
944 required_phrase_spans: vec![],
945 stopwords_by_pos: HashMap::new(),
946 referenced_filenames: None,
947 ignorable_urls: None,
948 ignorable_emails: None,
949 ignorable_copyrights: None,
950 ignorable_holders: None,
951 ignorable_authors: None,
952 language: None,
953 notes: None,
954 length_unique: 0,
955 high_length_unique: 0,
956 high_length: 0,
957 min_matched_length: 0,
958 min_high_matched_length: 0,
959 min_matched_length_unique: 0,
960 min_high_matched_length_unique: 0,
961 is_small: false,
962 is_tiny: false,
963 starts_with_license: false,
964 ends_with_license: false,
965 is_deprecated: false,
966 spdx_license_key: None,
967 other_spdx_license_keys: vec![],
968 },
969 ];
970
971 validate_rules(&rules);
972 }
973
974 #[test]
975 fn test_validate_rules_accepts_false_positive_without_expression() {
976 let rules = vec![Rule {
977 identifier: "fp.RULE".to_string(),
978 license_expression: "".to_string(),
979 text: "Some text".to_string(),
980 tokens: vec![],
981 rule_kind: crate::license_detection::models::RuleKind::None,
982 is_false_positive: true,
983 is_required_phrase: false,
984 is_from_license: false,
985 relevance: 100,
986 minimum_coverage: None,
987 has_stored_minimum_coverage: false,
988 is_continuous: false,
989 required_phrase_spans: vec![],
990 stopwords_by_pos: HashMap::new(),
991 referenced_filenames: None,
992 ignorable_urls: None,
993 ignorable_emails: None,
994 ignorable_copyrights: None,
995 ignorable_holders: None,
996 ignorable_authors: None,
997 language: None,
998 notes: Some("False positive for common pattern".to_string()),
999 length_unique: 0,
1000 high_length_unique: 0,
1001 high_length: 0,
1002 min_matched_length: 0,
1003 min_high_matched_length: 0,
1004 min_matched_length_unique: 0,
1005 min_high_matched_length_unique: 0,
1006 is_small: false,
1007 is_tiny: false,
1008 starts_with_license: false,
1009 ends_with_license: false,
1010 is_deprecated: false,
1011 spdx_license_key: None,
1012 other_spdx_license_keys: vec![],
1013 }];
1014
1015 validate_rules(&rules);
1016 }
1017
1018 #[test]
1019 fn test_validate_rules_no_duplicates() {
1020 let rules = vec![
1021 Rule {
1022 identifier: "mit.LICENSE".to_string(),
1023 license_expression: "mit".to_string(),
1024 text: "MIT License".to_string(),
1025 tokens: vec![],
1026 rule_kind: crate::license_detection::models::RuleKind::Text,
1027 is_false_positive: false,
1028 is_required_phrase: false,
1029 is_from_license: false,
1030 relevance: 100,
1031 minimum_coverage: None,
1032 has_stored_minimum_coverage: false,
1033 is_continuous: false,
1034 required_phrase_spans: vec![],
1035 stopwords_by_pos: HashMap::new(),
1036 referenced_filenames: None,
1037 ignorable_urls: None,
1038 ignorable_emails: None,
1039 ignorable_copyrights: None,
1040 ignorable_holders: None,
1041 ignorable_authors: None,
1042 language: None,
1043 notes: None,
1044 length_unique: 0,
1045 high_length_unique: 0,
1046 high_length: 0,
1047 min_matched_length: 0,
1048 min_high_matched_length: 0,
1049 min_matched_length_unique: 0,
1050 min_high_matched_length_unique: 0,
1051 is_small: false,
1052 is_tiny: false,
1053 starts_with_license: false,
1054 ends_with_license: false,
1055 is_deprecated: false,
1056 spdx_license_key: None,
1057 other_spdx_license_keys: vec![],
1058 },
1059 Rule {
1060 identifier: "apache-2.0.LICENSE".to_string(),
1061 license_expression: "apache-2.0".to_string(),
1062 text: "Apache License".to_string(),
1063 tokens: vec![],
1064 rule_kind: crate::license_detection::models::RuleKind::Text,
1065 is_false_positive: false,
1066 is_required_phrase: false,
1067 is_from_license: false,
1068 relevance: 100,
1069 minimum_coverage: None,
1070 has_stored_minimum_coverage: false,
1071 is_continuous: false,
1072 required_phrase_spans: vec![],
1073 stopwords_by_pos: HashMap::new(),
1074 referenced_filenames: None,
1075 ignorable_urls: None,
1076 ignorable_emails: None,
1077 ignorable_copyrights: None,
1078 ignorable_holders: None,
1079 ignorable_authors: None,
1080 language: None,
1081 notes: None,
1082 length_unique: 0,
1083 high_length_unique: 0,
1084 high_length: 0,
1085 min_matched_length: 0,
1086 min_high_matched_length: 0,
1087 min_matched_length_unique: 0,
1088 min_high_matched_length_unique: 0,
1089 is_small: false,
1090 is_tiny: false,
1091 starts_with_license: false,
1092 ends_with_license: false,
1093 is_deprecated: false,
1094 spdx_license_key: None,
1095 other_spdx_license_keys: vec![],
1096 },
1097 ];
1098
1099 validate_rules(&rules);
1100 }
1101
1102 #[test]
1103 fn test_load_licenses_filters_deprecated_by_default() {
1104 let dir = tempdir().unwrap();
1105
1106 fs::write(
1107 dir.path().join("active.LICENSE"),
1108 r#"---
1109key: active
1110name: Active License
1111---
1112Active license text"#,
1113 )
1114 .unwrap();
1115
1116 fs::write(
1117 dir.path().join("deprecated.LICENSE"),
1118 r#"---
1119key: deprecated
1120name: Deprecated License
1121is_deprecated: yes
1122---
1123Deprecated license text"#,
1124 )
1125 .unwrap();
1126
1127 let licenses_without = load_licenses_from_directory(dir.path(), false).unwrap();
1128 assert_eq!(licenses_without.len(), 1);
1129 assert_eq!(licenses_without[0].key, "active");
1130
1131 let licenses_with = load_licenses_from_directory(dir.path(), true).unwrap();
1132 assert_eq!(licenses_with.len(), 2);
1133 }
1134
1135 #[test]
1136 fn test_load_rules_filters_deprecated_by_default() {
1137 let dir = tempdir().unwrap();
1138
1139 fs::write(
1140 dir.path().join("active.RULE"),
1141 r#"---
1142license_expression: active
1143is_license_notice: yes
1144---
1145Active rule text"#,
1146 )
1147 .unwrap();
1148
1149 fs::write(
1150 dir.path().join("deprecated.RULE"),
1151 r#"---
1152license_expression: deprecated
1153is_license_notice: yes
1154is_deprecated: yes
1155---
1156Deprecated rule text"#,
1157 )
1158 .unwrap();
1159
1160 let rules_without = load_rules_from_directory(dir.path(), false).unwrap();
1161 assert_eq!(rules_without.len(), 1);
1162 assert_eq!(rules_without[0].license_expression, "active");
1163
1164 let rules_with = load_rules_from_directory(dir.path(), true).unwrap();
1165 assert_eq!(rules_with.len(), 2);
1166 }
1167
1168 #[test]
1169 fn test_parse_rule_to_loaded() {
1170 let dir = tempdir().unwrap();
1171 let rule_path = dir.path().join("mit_1.RULE");
1172 fs::write(
1173 &rule_path,
1174 r#"---
1175license_expression: mit
1176is_license_reference: yes
1177relevance: 90
1178referenced_filenames:
1179 - MIT.txt
1180---
1181MIT.txt"#,
1182 )
1183 .unwrap();
1184
1185 let loaded = parse_rule_to_loaded(&rule_path).unwrap();
1186 assert_eq!(loaded.identifier, "mit_1.RULE");
1187 assert_eq!(loaded.license_expression, "mit");
1188 assert_eq!(loaded.text, "MIT.txt");
1189 assert_eq!(
1190 loaded.rule_kind,
1191 crate::license_detection::models::RuleKind::Reference
1192 );
1193 assert_eq!(loaded.relevance, Some(90));
1194 assert_eq!(
1195 loaded.referenced_filenames,
1196 Some(vec!["MIT.txt".to_string()])
1197 );
1198 assert!(!loaded.is_deprecated);
1199 }
1200
1201 #[test]
1202 fn test_parse_license_to_loaded() {
1203 let dir = tempdir().unwrap();
1204 let license_path = dir.path().join("mit.LICENSE");
1205 fs::write(
1206 &license_path,
1207 r#"---
1208key: mit
1209short_name: MIT License
1210name: MIT License
1211category: Permissive
1212spdx_license_key: MIT
1213---
1214MIT License text here"#,
1215 )
1216 .unwrap();
1217
1218 let loaded = parse_license_to_loaded(&license_path).unwrap();
1219 assert_eq!(loaded.key, "mit");
1220 assert_eq!(loaded.name, "MIT License");
1221 assert!(loaded.text.contains("MIT License text"));
1222 assert_eq!(loaded.spdx_license_key, Some("MIT".to_string()));
1223 }
1224
1225 #[test]
1226 fn test_load_loaded_rules_from_directory_includes_deprecated() {
1227 let dir = tempdir().unwrap();
1228
1229 fs::write(
1230 dir.path().join("active.RULE"),
1231 r#"---
1232license_expression: active
1233is_license_notice: yes
1234---
1235Active rule text"#,
1236 )
1237 .unwrap();
1238
1239 fs::write(
1240 dir.path().join("deprecated.RULE"),
1241 r#"---
1242license_expression: deprecated
1243is_license_notice: yes
1244is_deprecated: yes
1245---
1246Deprecated rule text"#,
1247 )
1248 .unwrap();
1249
1250 let loaded_rules = load_loaded_rules_from_directory(dir.path()).unwrap();
1251 assert_eq!(loaded_rules.len(), 2);
1252
1253 let active = loaded_rules
1254 .iter()
1255 .find(|r| r.license_expression == "active")
1256 .unwrap();
1257 assert!(!active.is_deprecated);
1258
1259 let deprecated = loaded_rules
1260 .iter()
1261 .find(|r| r.license_expression == "deprecated")
1262 .unwrap();
1263 assert!(deprecated.is_deprecated);
1264 }
1265
1266 #[test]
1267 fn test_load_loaded_licenses_from_directory_includes_deprecated() {
1268 let dir = tempdir().unwrap();
1269
1270 fs::write(
1271 dir.path().join("active.LICENSE"),
1272 r#"---
1273key: active
1274name: Active License
1275---
1276Active license text"#,
1277 )
1278 .unwrap();
1279
1280 fs::write(
1281 dir.path().join("deprecated.LICENSE"),
1282 r#"---
1283key: deprecated
1284name: Deprecated License
1285is_deprecated: yes
1286---
1287Deprecated license text"#,
1288 )
1289 .unwrap();
1290
1291 let loaded_licenses = load_loaded_licenses_from_directory(dir.path()).unwrap();
1292 assert_eq!(loaded_licenses.len(), 2);
1293
1294 let active = loaded_licenses.iter().find(|l| l.key == "active").unwrap();
1295 assert!(!active.is_deprecated);
1296
1297 let deprecated = loaded_licenses
1298 .iter()
1299 .find(|l| l.key == "deprecated")
1300 .unwrap();
1301 assert!(deprecated.is_deprecated);
1302 }
1303}