Skip to main content

provenant/license_detection/models/
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//! Rule metadata loaded from .LICENSE and .RULE files.
8
9use std::collections::HashMap;
10use std::ops::Range;
11
12use rkyv::Archive;
13use serde::{Deserialize, Serialize};
14
15use crate::license_detection::index::dictionary::TokenId;
16
17const SCANCODE_LICENSE_URL_BASE: &str =
18    "https://github.com/aboutcode-org/scancode-toolkit/tree/develop/src/licensedcode/data/licenses";
19const SCANCODE_RULE_URL_BASE: &str =
20    "https://github.com/aboutcode-org/scancode-toolkit/tree/develop/src/licensedcode/data/rules";
21
22mod range_serde {
23    use serde::{Deserialize, Deserializer, Serialize, Serializer};
24    use std::ops::Range;
25
26    pub fn serialize<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
27    where
28        S: Serializer,
29    {
30        let tuples: Vec<(usize, usize)> = ranges.iter().map(|r| (r.start, r.end)).collect();
31        tuples.serialize(serializer)
32    }
33
34    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Range<usize>>, D::Error>
35    where
36        D: Deserializer<'de>,
37    {
38        let tuples: Vec<(usize, usize)> = Vec::deserialize(deserializer)?;
39        Ok(tuples
40            .into_iter()
41            .map(|(start, end)| Range { start, end })
42            .collect())
43    }
44}
45
46mod stopwords_serde {
47    use serde::{Deserialize, Deserializer, Serialize, Serializer};
48    use std::collections::HashMap;
49
50    pub fn serialize<S>(
51        map: &HashMap<Option<usize>, usize>,
52        serializer: S,
53    ) -> Result<S::Ok, S::Error>
54    where
55        S: Serializer,
56    {
57        let mut entries: Vec<(Option<usize>, usize)> = map.iter().map(|(k, v)| (*k, *v)).collect();
58        entries.sort_by_key(|(k, _)| *k);
59        entries.serialize(serializer)
60    }
61
62    pub fn deserialize<'de, D>(deserializer: D) -> Result<HashMap<Option<usize>, usize>, D::Error>
63    where
64        D: Deserializer<'de>,
65    {
66        let entries: Vec<(Option<usize>, usize)> = Vec::deserialize(deserializer)?;
67        Ok(entries.into_iter().collect())
68    }
69}
70
71#[derive(
72    Debug,
73    Clone,
74    Copy,
75    PartialEq,
76    Eq,
77    Hash,
78    PartialOrd,
79    Ord,
80    Default,
81    Serialize,
82    Deserialize,
83    Archive,
84    rkyv::Serialize,
85    rkyv::Deserialize,
86)]
87#[rkyv(derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord))]
88pub enum RuleKind {
89    #[default]
90    None,
91    Text,
92    Notice,
93    Reference,
94    Tag,
95    Intro,
96    Clue,
97}
98
99impl RuleKind {
100    pub fn from_rule_flags(
101        is_license_text: bool,
102        is_license_notice: bool,
103        is_license_reference: bool,
104        is_license_tag: bool,
105        is_license_intro: bool,
106        is_license_clue: bool,
107    ) -> Result<Self, &'static str> {
108        let mut active = None;
109
110        for (enabled, kind) in [
111            (is_license_text, Self::Text),
112            (is_license_notice, Self::Notice),
113            (is_license_reference, Self::Reference),
114            (is_license_tag, Self::Tag),
115            (is_license_intro, Self::Intro),
116            (is_license_clue, Self::Clue),
117        ] {
118            if !enabled {
119                continue;
120            }
121
122            if active.replace(kind).is_some() {
123                return Err("rule has multiple rule kinds set");
124            }
125        }
126
127        Ok(active.unwrap_or(Self::None))
128    }
129
130    pub const fn is_license_text(self) -> bool {
131        matches!(self, Self::Text)
132    }
133
134    pub const fn is_license_notice(self) -> bool {
135        matches!(self, Self::Notice)
136    }
137
138    pub const fn is_license_reference(self) -> bool {
139        matches!(self, Self::Reference)
140    }
141
142    pub const fn is_license_tag(self) -> bool {
143        matches!(self, Self::Tag)
144    }
145
146    pub const fn is_license_intro(self) -> bool {
147        matches!(self, Self::Intro)
148    }
149
150    pub const fn is_license_clue(self) -> bool {
151        matches!(self, Self::Clue)
152    }
153}
154
155/// Rule metadata loaded from .LICENSE and .RULE files.
156#[derive(
157    Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Archive, rkyv::Serialize, rkyv::Deserialize,
158)]
159pub struct Rule {
160    /// Unique identifier for this rule (e.g., "mit.LICENSE", "gpl-2.0_12.RULE")
161    /// Used for sorting to match Python's attr.s field order.
162    /// This is the primary sort key after rid (which is None at sort time in Python).
163    pub identifier: String,
164
165    /// License expression string using SPDX syntax and ScanCode license keys
166    pub license_expression: String,
167
168    /// Pattern text to match
169    pub text: String,
170
171    /// Token IDs for the text (assigned during indexing)
172    #[serde(
173        serialize_with = "serialize_token_ids",
174        deserialize_with = "deserialize_token_ids"
175    )]
176    pub tokens: Vec<TokenId>,
177
178    /// Classification of this rule.
179    pub rule_kind: RuleKind,
180
181    /// True if exact matches to this rule are false positives
182    pub is_false_positive: bool,
183
184    /// True if this rule text is a required phrase.
185    /// A required phrase is an essential section of the rule text which must be
186    /// present in the case of partial matches.
187    pub is_required_phrase: bool,
188
189    /// True if this rule was created from a license file (not a .RULE file)
190    pub is_from_license: bool,
191
192    /// Relevance score 0-100 (100 is most relevant)
193    pub relevance: u8,
194
195    /// Minimum match coverage percentage (0-100) if specified
196    pub minimum_coverage: Option<u8>,
197
198    /// True if minimum_coverage was explicitly stored in source frontmatter
199    pub has_stored_minimum_coverage: bool,
200
201    /// Tokens must appear in order if true
202    pub is_continuous: bool,
203
204    /// Token position spans for required phrases parsed from {{...}} markers.
205    /// Each span represents positions in the rule text that MUST be matched.
206    #[serde(with = "range_serde", default)]
207    pub required_phrase_spans: Vec<Range<usize>>,
208
209    /// Mapping from token position to count of stopwords at that position.
210    /// Used for required phrase validation.
211    #[serde(with = "stopwords_serde", default)]
212    pub stopwords_by_pos: HashMap<Option<usize>, usize>,
213
214    /// Filenames where this rule should be considered
215    pub referenced_filenames: Option<Vec<String>>,
216
217    /// URLs that should be ignored when found in this rule text
218    pub ignorable_urls: Option<Vec<String>>,
219
220    /// Emails that should be ignored when found in this rule text
221    pub ignorable_emails: Option<Vec<String>>,
222
223    /// Copyrights that should be ignored when found in this rule text
224    pub ignorable_copyrights: Option<Vec<String>>,
225
226    /// Holder names that should be ignored when found in this rule text
227    pub ignorable_holders: Option<Vec<String>>,
228
229    /// Author names that should be ignored when found in this rule text
230    pub ignorable_authors: Option<Vec<String>>,
231
232    /// Programming language for the rule if specified
233    pub language: Option<String>,
234
235    /// Free text notes
236    pub notes: Option<String>,
237
238    /// Count of unique token IDs in the rule (computed during indexing)
239    pub length_unique: usize,
240
241    /// Count of unique legalese token IDs (tokens with ID < len_legalese)
242    pub high_length_unique: usize,
243
244    /// Total count of legalese token occurrences (with duplicates)
245    pub high_length: usize,
246
247    /// Minimum matched length threshold (occurrences-based)
248    pub min_matched_length: usize,
249
250    /// Minimum high-value token matched length threshold (occurrences-based)
251    pub min_high_matched_length: usize,
252
253    /// Minimum matched length threshold (unique tokens)
254    pub min_matched_length_unique: usize,
255
256    /// Minimum high-value token matched length threshold (unique tokens)
257    pub min_high_matched_length_unique: usize,
258
259    /// True if rule length < SMALL_RULE (15 tokens)
260    pub is_small: bool,
261
262    /// True if rule length < TINY_RULE (6 tokens)
263    pub is_tiny: bool,
264
265    /// True if the rule's first token is "license", "licence", or "licensed"
266    pub starts_with_license: bool,
267
268    /// True if the rule's last token is "license", "licence", or "licensed"
269    pub ends_with_license: bool,
270
271    /// Whether this rule is deprecated
272    pub is_deprecated: bool,
273
274    /// SPDX license identifier if available
275    pub spdx_license_key: Option<String>,
276
277    /// Alternative SPDX license identifiers (aliases)
278    pub other_spdx_license_keys: Vec<String>,
279}
280
281fn serialize_token_ids<S>(token_ids: &[TokenId], serializer: S) -> Result<S::Ok, S::Error>
282where
283    S: serde::Serializer,
284{
285    let raw_ids: Vec<u16> = token_ids.iter().map(|id| id.raw()).collect();
286    <Vec<u16> as serde::Serialize>::serialize(&raw_ids, serializer)
287}
288
289fn deserialize_token_ids<'de, D>(deserializer: D) -> Result<Vec<TokenId>, D::Error>
290where
291    D: serde::Deserializer<'de>,
292{
293    let raw_ids: Vec<u16> = Vec::deserialize(deserializer)?;
294    Ok(raw_ids.into_iter().map(TokenId::new).collect())
295}
296
297impl PartialOrd for Rule {
298    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
299        Some(self.cmp(other))
300    }
301}
302
303impl Ord for Rule {
304    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
305        self.identifier.cmp(&other.identifier)
306    }
307}
308
309impl Rule {
310    pub fn rule_url(&self) -> Option<String> {
311        if self.is_from_license {
312            return (!self.license_expression.is_empty()).then(|| {
313                format!(
314                    "{SCANCODE_LICENSE_URL_BASE}/{}.LICENSE",
315                    self.license_expression
316                )
317            });
318        }
319
320        (!self.identifier.is_empty())
321            .then(|| format!("{SCANCODE_RULE_URL_BASE}/{}", self.identifier))
322    }
323
324    pub const fn kind(&self) -> RuleKind {
325        self.rule_kind
326    }
327
328    pub const fn is_license_text(&self) -> bool {
329        self.rule_kind.is_license_text()
330    }
331
332    /// Returns true if this rule is a license notice pattern.
333    ///
334    /// Note: This method is kept for API completeness and potential future use.
335    /// License matches cannot have `is_license_notice` - only rules can.
336    #[allow(dead_code)]
337    pub const fn is_license_notice(&self) -> bool {
338        self.rule_kind.is_license_notice()
339    }
340
341    pub const fn is_license_reference(&self) -> bool {
342        self.rule_kind.is_license_reference()
343    }
344
345    pub const fn is_license_tag(&self) -> bool {
346        self.rule_kind.is_license_tag()
347    }
348
349    /// Returns true if this rule is a license introduction pattern.
350    ///
351    /// Note: This method is kept for API completeness and potential future use.
352    #[allow(dead_code)]
353    pub const fn is_license_intro(&self) -> bool {
354        self.rule_kind.is_license_intro()
355    }
356
357    pub const fn is_license_clue(&self) -> bool {
358        self.rule_kind.is_license_clue()
359    }
360}