Skip to main content

provenant/license_detection/models/
loaded_rule.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Loader-stage rule type.
8//!
9//! This module defines `LoadedRule`, which represents a parsed and normalized
10//! rule file (.RULE or .LICENSE) before it is converted to a runtime `Rule`.
11//!
12//! Loader-stage responsibilities include:
13//! - Text trimming and normalization
14//! - Fallback/default handling derived only from one file
15//! - Empty-vector to `None` cleanup
16//! - File-local validation
17//! - False-positive handling for missing `license_expression`
18
19use serde::{Deserialize, Serialize};
20
21use super::RuleKind;
22
23/// Loader-stage representation of a rule.
24///
25/// This struct contains parsed and normalized data from a .RULE or .LICENSE file.
26/// It is serialized at build time and deserialized at runtime, then converted
27/// to a runtime `Rule` during the build stage.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct LoadedRule {
30    /// Unique identifier derived from the filename (e.g., "mit.LICENSE").
31    pub identifier: String,
32
33    /// License expression string using SPDX syntax and ScanCode license keys.
34    /// For false-positive rules with no source expression, this is set to "unknown".
35    pub license_expression: String,
36
37    /// Pattern text to match, trimmed and normalized.
38    pub text: String,
39
40    /// Classification of this rule, derived from source rule-kind booleans.
41    pub rule_kind: RuleKind,
42
43    /// True if exact matches to this rule are false positives.
44    pub is_false_positive: bool,
45
46    /// True if this rule text is a required phrase.
47    pub is_required_phrase: bool,
48
49    #[serde(default)]
50    pub skip_for_required_phrase_generation: bool,
51
52    /// Relevance score 0-100 (100 is most relevant).
53    /// Stored as Option to distinguish between explicit 100 and default 100.
54    pub relevance: Option<u8>,
55
56    /// Minimum match coverage percentage (0-100) if specified.
57    pub minimum_coverage: Option<u8>,
58
59    /// True if minimum_coverage was explicitly stored in source frontmatter.
60    pub has_stored_minimum_coverage: bool,
61
62    /// Tokens must appear in order if true.
63    pub is_continuous: bool,
64
65    /// Filenames where this rule should be considered.
66    pub referenced_filenames: Option<Vec<String>>,
67
68    /// URLs that should be ignored when found in this rule text.
69    pub ignorable_urls: Option<Vec<String>>,
70
71    /// Emails that should be ignored when found in this rule text.
72    pub ignorable_emails: Option<Vec<String>>,
73
74    /// Copyrights that should be ignored when found in this rule text.
75    pub ignorable_copyrights: Option<Vec<String>>,
76
77    /// Holder names that should be ignored when found in this rule text.
78    pub ignorable_holders: Option<Vec<String>>,
79
80    /// Author names that should be ignored when found in this rule text.
81    pub ignorable_authors: Option<Vec<String>>,
82
83    /// Programming language for the rule if specified.
84    pub language: Option<String>,
85
86    /// Free text notes.
87    pub notes: Option<String>,
88
89    /// Whether this rule is deprecated.
90    pub is_deprecated: bool,
91
92    #[serde(default)]
93    pub replaced_by: Vec<String>,
94}
95
96/// Loader-stage normalization functions for rule data.
97impl LoadedRule {
98    /// Derive identifier from filename.
99    ///
100    /// Returns the filename as-is, which serves as the unique identifier.
101    pub fn derive_identifier(filename: &str) -> String {
102        filename.to_string()
103    }
104
105    /// Derive rule kind from source rule-kind booleans.
106    ///
107    /// Returns an error if multiple flags are set.
108    pub fn derive_rule_kind(
109        is_license_text: bool,
110        is_license_notice: bool,
111        is_license_reference: bool,
112        is_license_tag: bool,
113        is_license_intro: bool,
114        is_license_clue: bool,
115    ) -> Result<RuleKind, RuleKindError> {
116        RuleKind::from_rule_flags(
117            is_license_text,
118            is_license_notice,
119            is_license_reference,
120            is_license_tag,
121            is_license_intro,
122            is_license_clue,
123        )
124        .map_err(|_| RuleKindError::MultipleFlagsSet)
125    }
126
127    /// Normalize license expression.
128    ///
129    /// - Strips trivial outer parentheses
130    /// - For false-positive rules with no expression, returns "unknown"
131    /// - For non-false-positive rules with no expression, returns an error
132    pub fn normalize_license_expression(
133        expression: Option<&str>,
134        is_false_positive: bool,
135    ) -> Result<String, LicenseExpressionError> {
136        match expression {
137            Some(expr) if !expr.trim().is_empty() => {
138                Ok(normalize_trivial_outer_parens(expr.trim()))
139            }
140            Some(_) => {
141                if is_false_positive {
142                    Ok("unknown".to_string())
143                } else {
144                    Err(LicenseExpressionError::EmptyExpression)
145                }
146            }
147            None => {
148                if is_false_positive {
149                    Ok("unknown".to_string())
150                } else {
151                    Err(LicenseExpressionError::MissingExpression)
152                }
153            }
154        }
155    }
156
157    /// Normalize optional string field.
158    ///
159    /// Returns `None` for empty strings, `Some(trimmed)` otherwise.
160    pub fn normalize_optional_string(s: Option<&str>) -> Option<String> {
161        s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
162    }
163
164    /// Normalize optional string list.
165    ///
166    /// Returns `None` for empty lists, `Some(list)` with trimmed strings otherwise.
167    pub fn normalize_optional_list(list: Option<&[String]>) -> Option<Vec<String>> {
168        list.map(|l| {
169            l.iter()
170                .map(|s| s.trim().to_string())
171                .filter(|s| !s.is_empty())
172                .collect::<Vec<_>>()
173        })
174        .filter(|l: &Vec<String>| !l.is_empty())
175    }
176
177    /// Validate rule-kind flags against false_positive flag.
178    ///
179    /// - False-positive rules must NOT have any is_license_* flags set
180    /// - Non-false-positive rules MUST have exactly one is_license_* flag set
181    pub fn validate_rule_kind_flags(
182        rule_kind: RuleKind,
183        is_false_positive: bool,
184    ) -> Result<(), RuleKindError> {
185        if is_false_positive && rule_kind != RuleKind::None {
186            return Err(RuleKindError::FalsePositiveWithFlags);
187        }
188        if !is_false_positive && rule_kind == RuleKind::None {
189            return Err(RuleKindError::NoFlagsSet);
190        }
191        Ok(())
192    }
193}
194
195/// Error type for rule-kind validation failures.
196#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
197pub enum RuleKindError {
198    #[error("rule has multiple is_license_* flags set")]
199    MultipleFlagsSet,
200    #[error("non-false-positive rule has no is_license_* flags set")]
201    NoFlagsSet,
202    #[error("false-positive rule cannot have is_license_* flags set")]
203    FalsePositiveWithFlags,
204}
205
206/// Error type for license expression validation failures.
207#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
208pub enum LicenseExpressionError {
209    #[error("license_expression is required for non-false-positive rules")]
210    MissingExpression,
211    #[error("license_expression cannot be empty for non-false-positive rules")]
212    EmptyExpression,
213}
214
215/// Check if a string has trivial outer parentheses.
216///
217/// Trivial outer parentheses are a single pair of parens that wrap the entire
218/// expression without any other top-level parens.
219fn has_trivial_outer_parens(s: &str) -> bool {
220    let trimmed = s.trim();
221    if !trimmed.starts_with('(') || !trimmed.ends_with(')') {
222        return false;
223    }
224    let mut depth = 0;
225    let chars: Vec<char> = trimmed.chars().collect();
226    for (i, c) in chars.iter().enumerate() {
227        if *c == '(' {
228            depth += 1;
229        } else if *c == ')' {
230            depth -= 1;
231            if depth == 0 && i < chars.len() - 1 {
232                return false;
233            }
234        }
235    }
236    depth == 0
237}
238
239/// Normalize license expression by removing trivial outer parentheses.
240///
241/// This recursively strips outer parens that wrap the entire expression.
242fn normalize_trivial_outer_parens(expr: &str) -> String {
243    let trimmed = expr.trim();
244    if has_trivial_outer_parens(trimmed) {
245        let inner = &trimmed[1..trimmed.len() - 1];
246        normalize_trivial_outer_parens(inner)
247    } else {
248        trimmed.to_string()
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_derive_identifier() {
258        assert_eq!(LoadedRule::derive_identifier("mit.LICENSE"), "mit.LICENSE");
259        assert_eq!(
260            LoadedRule::derive_identifier("gpl-2.0_12.RULE"),
261            "gpl-2.0_12.RULE"
262        );
263    }
264
265    #[test]
266    fn test_derive_rule_kind_single_flag() {
267        assert_eq!(
268            LoadedRule::derive_rule_kind(true, false, false, false, false, false),
269            Ok(RuleKind::Text)
270        );
271        assert_eq!(
272            LoadedRule::derive_rule_kind(false, true, false, false, false, false),
273            Ok(RuleKind::Notice)
274        );
275        assert_eq!(
276            LoadedRule::derive_rule_kind(false, false, true, false, false, false),
277            Ok(RuleKind::Reference)
278        );
279        assert_eq!(
280            LoadedRule::derive_rule_kind(false, false, false, true, false, false),
281            Ok(RuleKind::Tag)
282        );
283        assert_eq!(
284            LoadedRule::derive_rule_kind(false, false, false, false, true, false),
285            Ok(RuleKind::Intro)
286        );
287        assert_eq!(
288            LoadedRule::derive_rule_kind(false, false, false, false, false, true),
289            Ok(RuleKind::Clue)
290        );
291    }
292
293    #[test]
294    fn test_derive_rule_kind_none() {
295        assert_eq!(
296            LoadedRule::derive_rule_kind(false, false, false, false, false, false),
297            Ok(RuleKind::None)
298        );
299    }
300
301    #[test]
302    fn test_derive_rule_kind_multiple_flags() {
303        assert_eq!(
304            LoadedRule::derive_rule_kind(true, true, false, false, false, false),
305            Err(RuleKindError::MultipleFlagsSet)
306        );
307    }
308
309    #[test]
310    fn test_normalize_license_expression_with_value() {
311        assert_eq!(
312            LoadedRule::normalize_license_expression(Some("mit"), false),
313            Ok("mit".to_string())
314        );
315    }
316
317    #[test]
318    fn test_normalize_license_expression_false_positive_fallback() {
319        assert_eq!(
320            LoadedRule::normalize_license_expression(None, true),
321            Ok("unknown".to_string())
322        );
323        assert_eq!(
324            LoadedRule::normalize_license_expression(Some(""), true),
325            Ok("unknown".to_string())
326        );
327        assert_eq!(
328            LoadedRule::normalize_license_expression(Some("   "), true),
329            Ok("unknown".to_string())
330        );
331    }
332
333    #[test]
334    fn test_normalize_license_expression_missing_error() {
335        assert_eq!(
336            LoadedRule::normalize_license_expression(None, false),
337            Err(LicenseExpressionError::MissingExpression)
338        );
339    }
340
341    #[test]
342    fn test_normalize_license_expression_empty_error() {
343        assert_eq!(
344            LoadedRule::normalize_license_expression(Some(""), false),
345            Err(LicenseExpressionError::EmptyExpression)
346        );
347    }
348
349    #[test]
350    fn test_normalize_trivial_outer_parens() {
351        assert_eq!(normalize_trivial_outer_parens("mit"), "mit");
352        assert_eq!(normalize_trivial_outer_parens("(mit)"), "mit");
353        assert_eq!(normalize_trivial_outer_parens("((mit))"), "mit");
354        assert_eq!(
355            normalize_trivial_outer_parens("(mit OR apache-2.0)"),
356            "mit OR apache-2.0"
357        );
358        assert_eq!(
359            normalize_trivial_outer_parens("(mit) OR (apache-2.0)"),
360            "(mit) OR (apache-2.0)"
361        );
362    }
363
364    #[test]
365    fn test_normalize_optional_string() {
366        assert_eq!(LoadedRule::normalize_optional_string(None), None);
367        assert_eq!(LoadedRule::normalize_optional_string(Some("")), None);
368        assert_eq!(LoadedRule::normalize_optional_string(Some("   ")), None);
369        assert_eq!(
370            LoadedRule::normalize_optional_string(Some("hello")),
371            Some("hello".to_string())
372        );
373        assert_eq!(
374            LoadedRule::normalize_optional_string(Some("  hello  ")),
375            Some("hello".to_string())
376        );
377    }
378
379    #[test]
380    fn test_normalize_optional_list() {
381        assert_eq!(LoadedRule::normalize_optional_list(None), None);
382        assert_eq!(LoadedRule::normalize_optional_list(Some(&[])), None);
383        assert_eq!(
384            LoadedRule::normalize_optional_list(Some(&["a".to_string(), "b".to_string()])),
385            Some(vec!["a".to_string(), "b".to_string()])
386        );
387        assert_eq!(
388            LoadedRule::normalize_optional_list(Some(&["  a  ".to_string(), "  b  ".to_string()])),
389            Some(vec!["a".to_string(), "b".to_string()])
390        );
391        assert_eq!(
392            LoadedRule::normalize_optional_list(Some(&["".to_string(), "  ".to_string()])),
393            None
394        );
395    }
396
397    #[test]
398    fn test_validate_rule_kind_flags() {
399        assert!(LoadedRule::validate_rule_kind_flags(RuleKind::Text, false).is_ok());
400        assert_eq!(
401            LoadedRule::validate_rule_kind_flags(RuleKind::None, false),
402            Err(RuleKindError::NoFlagsSet)
403        );
404        assert!(LoadedRule::validate_rule_kind_flags(RuleKind::None, true).is_ok());
405        assert_eq!(
406            LoadedRule::validate_rule_kind_flags(RuleKind::Text, true),
407            Err(RuleKindError::FalsePositiveWithFlags)
408        );
409    }
410
411    #[test]
412    fn test_serde_roundtrip() {
413        let rule = LoadedRule {
414            identifier: "mit.LICENSE".to_string(),
415            license_expression: "mit".to_string(),
416            text: "MIT License".to_string(),
417            rule_kind: RuleKind::Text,
418            is_false_positive: false,
419            is_required_phrase: false,
420            skip_for_required_phrase_generation: false,
421            relevance: Some(100),
422            minimum_coverage: Some(90),
423            has_stored_minimum_coverage: true,
424            is_continuous: false,
425            referenced_filenames: Some(vec!["MIT.txt".to_string()]),
426            ignorable_urls: None,
427            ignorable_emails: None,
428            ignorable_copyrights: None,
429            ignorable_holders: None,
430            ignorable_authors: None,
431            language: None,
432            notes: Some("Test note".to_string()),
433            is_deprecated: false,
434            replaced_by: vec![],
435        };
436
437        let json = serde_json::to_string(&rule).unwrap();
438        let deserialized: LoadedRule = serde_json::from_str(&json).unwrap();
439        assert_eq!(rule, deserialized);
440    }
441}