Skip to main content

provenant/license_detection/rules/
loader.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//! Parse .LICENSE and .RULE files.
8//!
9//! This module provides two-stage loading:
10//! 1. Loader-stage: Parse files into `LoadedRule` and `LoadedLicense`
11//! 2. Build-stage: Convert to runtime `Rule` and `License` (deprecated filtering, etc.)
12//!
13//! The loader-stage functions (`parse_rule_to_loaded`, `parse_license_to_loaded`,
14//! `load_loaded_rules_from_directory`, `load_loaded_licenses_from_directory`) return
15//! all entries including deprecated ones. Deprecated filtering is a build-stage concern.
16
17use 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                        // truncation toward zero is intentional (e.g. 90.5 → 90)
69                        #[allow(clippy::cast_sign_loss)]
70                        Some(f as u8)
71                    } else {
72                        None
73                    }
74                })
75            })
76    }
77}
78
79// Serde deserialization target: mirrors the full .LICENSE frontmatter; not every field is read.
80#[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
162/// Parsed rule file content, split into frontmatter and text.
163struct ParsedRuleFile {
164    yaml_content: String,
165    text_content: String,
166    has_stored_minimum_coverage: bool,
167}
168
169/// Parsed license file content, split into frontmatter and text.
170struct ParsedLicenseFile {
171    yaml_content: String,
172    text_content: String,
173}
174
175/// Parse file content into frontmatter and text sections.
176///
177/// Returns `ParsedRuleFile` with yaml_content, text_content, and metadata.
178/// The `path` parameter is used for error messages only.
179fn 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// Serde deserialization target: mirrors the full .RULE frontmatter; not every field is read.
235#[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
411/// Parse a .RULE file into a `LoadedRule` (loader-stage).
412///
413/// This function parses the file and returns a `LoadedRule` with normalized data.
414/// Deprecated entries are included - filtering is a build-stage concern.
415pub 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
427/// Parse a rule from in-memory ScanCode-style `.RULE` content.
428pub 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
526/// Parse a .LICENSE file into a `LoadedLicense` (loader-stage).
527///
528/// This function parses the file and returns a `LoadedLicense` with normalized data.
529/// Deprecated entries are included - filtering is a build-stage concern.
530pub 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
542/// Parse a license from in-memory ScanCode-style `.LICENSE` content.
543pub 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
548/// Parse license file content into frontmatter and text sections.
549///
550/// The `path` parameter is used for error messages only.
551fn 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
597/// Load all .RULE files from a directory into `LoadedRule` values (loader-stage).
598///
599/// This function loads ALL rules, including deprecated ones.
600/// Deprecated filtering is a build-stage concern.
601///
602/// # Arguments
603/// * `dir` - Directory containing .RULE files
604///
605/// # Returns
606/// * `Ok(Vec<LoadedRule>)` - All loaded rules (including deprecated)
607/// * `Err(...)` - Directory read error
608pub 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
632/// Load all .LICENSE files from a directory into `LoadedLicense` values (loader-stage).
633///
634/// This function loads ALL licenses, including deprecated ones.
635/// Deprecated filtering is a build-stage concern.
636///
637/// # Arguments
638/// * `dir` - Directory containing .LICENSE files
639///
640/// # Returns
641/// * `Ok(Vec<LoadedLicense>)` - All loaded licenses (including deprecated)
642/// * `Err(...)` - Directory read error
643pub 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/// Validate loaded rules for common issues.
668///
669/// Checks for:
670/// 1. Duplicate rule texts (warns if found)
671/// 2. Empty license expressions for non-false-positive rules (warns if found)
672///
673/// Corresponds to Python:
674/// - `models.py:validate()` for license expression validation
675/// - `index.py:_add_rules()` for duplicate detection via hash
676///
677/// Kept for backward compatibility with `load_rules_from_directory`.
678#[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/// Load all .RULE files from a directory into `Rule` values (backward-compatible).
706///
707/// This function loads rules and applies deprecated filtering during loading.
708/// For the two-stage pipeline, prefer `load_loaded_rules_from_directory` and
709/// `build_index_from_loaded`.
710///
711/// Kept for backward compatibility and testing despite not being used in production code.
712/// The new pipeline uses the two-stage loading process instead.
713#[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/// Load all .LICENSE files from a directory into `License` values (backward-compatible).
726///
727/// This function loads licenses and applies deprecated filtering during loading.
728/// For the two-stage pipeline, prefer `load_loaded_licenses_from_directory` and
729/// `build_index_from_loaded`.
730///
731/// Kept for backward compatibility and testing despite not being used in production code.
732/// The new pipeline uses the two-stage loading process instead.
733#[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}