Skip to main content

provenant/license_detection/models/
loaded_rule.rs

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