Skip to main content

provenant/license_detection/
mod.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//! License Detection Engine
7
8pub mod aho_match;
9pub mod automaton;
10pub mod build_policy;
11pub mod dataset;
12pub(crate) mod detection;
13pub mod embedded;
14pub mod license_cache;
15mod position_set;
16mod token_multiset;
17mod token_set;
18
19#[cfg(test)]
20mod embedded_test;
21pub mod expression;
22#[cfg(feature = "golden-tests")]
23pub mod golden_utils;
24pub mod hash_match;
25pub mod index;
26mod match_refine;
27pub mod models;
28pub mod query;
29pub mod rules;
30pub mod seq_match;
31pub mod spdx_lid;
32pub mod spdx_mapping;
33#[cfg(test)]
34mod test_utils;
35pub mod tokenize;
36pub mod unknown_match;
37
38use bit_set::BitSet;
39use std::collections::HashSet;
40use std::fs;
41use std::path::Path;
42use std::sync::Arc;
43use std::time::Instant;
44
45use anyhow::Result;
46
47use crate::license_detection::build_policy::EMBEDDED_LICENSE_INDEX_SOURCE;
48use crate::license_detection::dataset::{
49    CUSTOM_LICENSE_DATASET_SOURCE, LoadedLicenseDataset, compute_dataset_fingerprint_string,
50    load_license_dataset_from_root,
51};
52use crate::license_detection::embedded::index::{
53    load_embedded_artifact_metadata_from_bytes, load_loader_snapshot_from_bytes,
54};
55use crate::license_detection::index::build_index_from_loaded;
56use crate::license_detection::license_cache::{
57    LicenseCacheConfig, LicenseCacheNamespace, cache_file_size, compute_artifact_fingerprint,
58    compute_rules_fingerprint, delete_cache, load_cached_index, save_cached_index,
59};
60use crate::license_detection::query::Query;
61use crate::license_detection::spdx_mapping::{SpdxMapping, build_spdx_mapping};
62use crate::models::LicenseIndexProvenance;
63use crate::utils::text::strip_utf8_bom_str;
64
65use crate::license_detection::detection::{
66    attach_source_path_to_detections, empty_detection, populate_detection_from_group_with_spdx,
67    split_groups_across_frontmatter_boundary,
68};
69
70/// Path to the license rules directory in the reference scancode-toolkit submodule.
71/// Used by test code and the xtask generate-license-loader-artifact binary.
72#[allow(dead_code)]
73pub const SCANCODE_LICENSES_RULES_PATH: &str =
74    "reference/scancode-toolkit/src/licensedcode/data/rules";
75
76/// Path to the licenses directory in the reference scancode-toolkit submodule.
77/// Used by test code and the xtask generate-license-loader-artifact binary.
78#[allow(dead_code)]
79pub const SCANCODE_LICENSES_LICENSES_PATH: &str =
80    "reference/scancode-toolkit/src/licensedcode/data/licenses";
81
82/// Path to the license data directory in the reference scancode-toolkit submodule.
83/// Used by test code and the xtask generate-license-loader-artifact binary.
84#[allow(dead_code)]
85pub const SCANCODE_LICENSES_DATA_PATH: &str = "reference/scancode-toolkit/src/licensedcode/data";
86
87pub const DEFAULT_LICENSEDB_URL_TEMPLATE: &str = "https://scancode-licensedb.aboutcode.org/{}";
88#[derive(Debug, Clone, thiserror::Error)]
89pub(crate) enum LicenseDetectionError {
90    #[error("license detection timed out")]
91    Timeout,
92}
93
94pub(crate) use detection::{
95    LicenseDetection, group_matches_by_region, post_process_detections, sort_matches_by_line,
96};
97pub use models::LicenseMatch;
98pub use models::MatcherKind;
99
100pub use aho_match::aho_match;
101pub use hash_match::hash_match;
102pub use match_refine::{
103    filter_invalid_contained_unknown_matches, merge_overlapping_matches, refine_matches,
104    refine_matches_without_false_positive_filter, split_weak_matches,
105};
106pub use position_set::PositionSet;
107pub use spdx_lid::spdx_lid_match;
108pub use token_multiset::TokenMultiset;
109pub use token_set::TokenSet;
110pub use unknown_match::unknown_match;
111
112use self::seq_match::{
113    MAX_NEAR_DUPE_CANDIDATES, select_seq_candidates_with_deadline,
114    seq_match_with_candidates_and_deadline,
115};
116
117/// License detection engine that orchestrates the detection pipeline.
118///
119/// The engine loads license rules and builds an index for efficient matching.
120/// It supports multiple matching strategies (hash, SPDX-LID, Aho-Corasick, sequence)
121/// and combines their results into final license detections.
122#[derive(Debug, Clone)]
123pub struct LicenseDetectionEngine {
124    index: Arc<index::LicenseIndex>,
125    spdx_mapping: SpdxMapping,
126    spdx_license_list_version: Option<String>,
127    license_index_provenance: Option<LicenseIndexProvenance>,
128}
129
130const MAX_DETECTION_SIZE: usize = 10 * 1024 * 1024; // 10MB
131const MAX_REGULAR_SEQ_CANDIDATES: usize = 70;
132const MAX_REDUNDANT_SEQ_CONTAINER_BOUNDARY_GAP: usize = 8;
133const MAX_REDUNDANT_SEQ_CONTAINER_UNMATCHED_GAP: usize = 2;
134
135pub(crate) fn deadline_exceeded(deadline: Option<Instant>) -> bool {
136    deadline.is_some_and(|deadline| Instant::now() >= deadline)
137}
138
139pub(crate) fn ensure_within_deadline(
140    deadline: Option<Instant>,
141) -> Result<(), LicenseDetectionError> {
142    if deadline_exceeded(deadline) {
143        Err(LicenseDetectionError::Timeout)
144    } else {
145        Ok(())
146    }
147}
148
149fn truncate_detection_text(clean_text: &str) -> &str {
150    if clean_text.len() <= MAX_DETECTION_SIZE {
151        return clean_text;
152    }
153
154    log::debug!(
155        "Content size {} exceeds limit {}, truncating for detection",
156        clean_text.len(),
157        MAX_DETECTION_SIZE
158    );
159
160    let boundary = clean_text.floor_char_boundary(MAX_DETECTION_SIZE);
161    &clean_text[..boundary]
162}
163
164fn query_span_for_match(m: &LicenseMatch) -> Option<models::PositionSpan> {
165    (!m.query_span().is_empty()).then(|| m.query_span().clone())
166}
167
168fn has_full_match_coverage(m: &LicenseMatch) -> bool {
169    m.coverage() == 100.0
170}
171
172fn is_redundant_same_expression_seq_container(
173    container: &LicenseMatch,
174    candidate_contained_matches: &[LicenseMatch],
175) -> bool {
176    let container_is_redundant_coverage =
177        has_full_match_coverage(container) || container.coverage() >= 99.0;
178    if container.matcher != MatcherKind::Seq || !container_is_redundant_coverage {
179        return false;
180    }
181
182    let container_qspan_set = container.qspan_set();
183
184    let mut contained: Vec<&LicenseMatch> = candidate_contained_matches
185        .iter()
186        .filter(|m| {
187            m.matcher == MatcherKind::Aho
188                && has_full_match_coverage(m)
189                && m.license_expression == container.license_expression
190                && m.overlaps_with(&container_qspan_set)
191        })
192        .collect();
193
194    if contained.len() < 2 {
195        return false;
196    }
197
198    let material_children = contained.iter().filter(|m| m.matched_length > 1).count();
199    if material_children < 2 {
200        return false;
201    }
202
203    contained.sort_by_key(|m| m.qspan_bounds());
204
205    let mut child_union = PositionSet::new();
206    for m in &contained {
207        child_union.extend_from_span(m.query_span());
208    }
209
210    let container_only_positions = container_qspan_set.difference(&child_union);
211    let child_only_positions = child_union.difference(&container_qspan_set);
212
213    let mut bridge_positions = BitSet::new();
214    for pair in contained.windows(2) {
215        let (_, previous_end) = pair[0].qspan_bounds();
216        let (next_start, _) = pair[1].qspan_bounds();
217
218        if next_start < previous_end {
219            return false;
220        }
221
222        for pos in previous_end..next_start {
223            bridge_positions.insert(pos);
224        }
225    }
226
227    let container_only_boundary_positions = container_only_positions
228        .iter()
229        .filter(|&pos| !bridge_positions.contains(pos))
230        .count();
231
232    if container_only_positions.len() == 1
233        && container_only_boundary_positions == 0
234        && child_only_positions.is_empty()
235    {
236        return false;
237    }
238
239    if child_only_positions.is_empty()
240        && container_only_positions.len() == container_only_boundary_positions
241        && container_only_boundary_positions <= 3
242    {
243        let earliest_child = contained
244            .iter()
245            .map(|m| m.qspan_bounds().0)
246            .min()
247            .unwrap_or(usize::MAX);
248        let latest_child = contained
249            .iter()
250            .map(|m| m.qspan_bounds().1.saturating_sub(1))
251            .max()
252            .unwrap_or(0);
253
254        let is_one_sided_boundary = container_only_positions
255            .iter()
256            .all(|pos| pos < earliest_child)
257            || container_only_positions
258                .iter()
259                .all(|pos| pos > latest_child);
260
261        if is_one_sided_boundary {
262            return false;
263        }
264    }
265
266    let max_container_only_positions =
267        MAX_REDUNDANT_SEQ_CONTAINER_BOUNDARY_GAP * contained.len() + 1;
268    let max_container_boundary_positions =
269        MAX_REDUNDANT_SEQ_CONTAINER_BOUNDARY_GAP * (contained.len() - 1);
270    let max_child_only_positions = MAX_REDUNDANT_SEQ_CONTAINER_UNMATCHED_GAP + 1;
271
272    container_only_positions.len() <= max_container_only_positions
273        && container_only_boundary_positions <= max_container_boundary_positions
274        && child_only_positions.len() <= max_child_only_positions
275}
276
277fn filter_redundant_same_expression_seq_containers(
278    seq_matches: Vec<LicenseMatch>,
279    candidate_contained_matches: &[LicenseMatch],
280) -> Vec<LicenseMatch> {
281    seq_matches
282        .into_iter()
283        .filter(|m| !is_redundant_same_expression_seq_container(m, candidate_contained_matches))
284        .collect()
285}
286
287fn is_redundant_low_coverage_composite_seq_wrapper(
288    container: &LicenseMatch,
289    candidate_contained_matches: &[LicenseMatch],
290) -> bool {
291    if container.matcher != seq_match::MATCH_SEQ || container.coverage() >= 30.0 {
292        return false;
293    }
294
295    let container_qspan_set = container.qspan_set();
296
297    let children: Vec<&LicenseMatch> = candidate_contained_matches
298        .iter()
299        .filter(|m| {
300            m.matcher == aho_match::MATCH_AHO
301                && has_full_match_coverage(m)
302                && m.license_expression != container.license_expression
303                && m.overlaps_with(&container_qspan_set)
304        })
305        .collect();
306
307    if children.len() < 2 {
308        return false;
309    }
310
311    let unique_expressions: HashSet<&str> = children
312        .iter()
313        .map(|m| m.license_expression.as_str())
314        .collect();
315    if unique_expressions.len() < 2 {
316        return false;
317    }
318
319    let mut child_union = PositionSet::new();
320    for m in &children {
321        child_union.extend_from_span(m.query_span());
322    }
323
324    let container_only_positions = container_qspan_set.difference(&child_union);
325    let child_only_positions = child_union.difference(&container_qspan_set);
326
327    let mut sorted_children = children;
328    sorted_children.sort_by_key(|m| m.qspan_bounds());
329
330    let mut bridge_positions = BitSet::new();
331    for pair in sorted_children.windows(2) {
332        let (_, previous_end) = pair[0].qspan_bounds();
333        let (next_start, _) = pair[1].qspan_bounds();
334        for pos in previous_end..next_start {
335            bridge_positions.insert(pos);
336        }
337    }
338
339    let container_only_boundary_positions = container_only_positions
340        .iter()
341        .filter(|&pos| !bridge_positions.contains(pos))
342        .count();
343
344    child_only_positions.is_empty()
345        && container_only_positions.len() <= MAX_REDUNDANT_SEQ_CONTAINER_BOUNDARY_GAP
346        && container_only_boundary_positions <= MAX_REDUNDANT_SEQ_CONTAINER_BOUNDARY_GAP
347}
348
349fn filter_redundant_low_coverage_composite_seq_wrappers(
350    seq_matches: Vec<LicenseMatch>,
351    candidate_contained_matches: &[LicenseMatch],
352) -> Vec<LicenseMatch> {
353    seq_matches
354        .into_iter()
355        .filter(|m| {
356            !is_redundant_low_coverage_composite_seq_wrapper(m, candidate_contained_matches)
357        })
358        .collect()
359}
360
361fn subtract_spdx_match_qspans(
362    query: &mut Query<'_>,
363    matched_qspans: &mut Vec<models::PositionSpan>,
364    aho_extra_matchables: &mut PositionSet,
365    spdx_matches: &[LicenseMatch],
366) {
367    for m in spdx_matches {
368        let Some(span) = query_span_for_match(m) else {
369            continue;
370        };
371
372        aho_extra_matchables.extend_from_span(&span);
373        query.subtract(&span);
374
375        if has_full_match_coverage(m) {
376            matched_qspans.push(span);
377        }
378    }
379}
380
381fn merge_and_prepare_aho_matches(
382    index: &index::LicenseIndex,
383    query: &mut Query<'_>,
384    matched_qspans: &mut Vec<models::PositionSpan>,
385    refined_aho: &[LicenseMatch],
386) -> (Vec<LicenseMatch>, bool) {
387    let merged_aho = merge_overlapping_matches(refined_aho);
388    let mut saw_long_exact_license_text_match = false;
389
390    for m in &merged_aho {
391        let Some(span) = query_span_for_match(m) else {
392            continue;
393        };
394
395        if has_full_match_coverage(m) {
396            matched_qspans.push(span.clone());
397        }
398
399        if index.rule(m.rid).is_some_and(|rule| rule.is_license_text())
400            && m.rule_length > 120
401            && m.coverage() > 98.0
402        {
403            query.subtract(&span);
404            saw_long_exact_license_text_match = true;
405        }
406    }
407
408    (merged_aho, saw_long_exact_license_text_match)
409}
410
411fn collect_whole_query_exact_followup_matches(
412    index: &index::LicenseIndex,
413    query: &mut Query<'_>,
414    matched_qspans: &mut Vec<models::PositionSpan>,
415    whole_run: &query::QueryRun<'_>,
416    enable_sequence_matching: bool,
417    deadline: Option<Instant>,
418) -> Result<Vec<LicenseMatch>, LicenseDetectionError> {
419    if !enable_sequence_matching {
420        return Ok(Vec::new());
421    }
422
423    let mut seq_all_matches = Vec::new();
424
425    if whole_run.is_matchable(false, matched_qspans) {
426        let near_dupe_candidates = if deadline.is_some() {
427            select_seq_candidates_with_deadline(
428                index,
429                whole_run,
430                true,
431                MAX_NEAR_DUPE_CANDIDATES,
432                deadline,
433            )?
434        } else {
435            self::seq_match::select_seq_candidates(index, whole_run, true, MAX_NEAR_DUPE_CANDIDATES)
436        };
437
438        if !near_dupe_candidates.is_empty() {
439            let near_dupe_matches = if deadline.is_some() {
440                seq_match_with_candidates_and_deadline(
441                    index,
442                    whole_run,
443                    &near_dupe_candidates,
444                    deadline,
445                )?
446            } else {
447                self::seq_match::seq_match_with_candidates(index, whole_run, &near_dupe_candidates)
448            };
449
450            for m in &near_dupe_matches {
451                if !m.query_span().is_empty() {
452                    let span = m.query_span().clone();
453                    query.subtract(&span);
454                    matched_qspans.push(span);
455                }
456            }
457
458            seq_all_matches.extend(near_dupe_matches);
459        }
460    }
461
462    Ok(seq_all_matches)
463}
464
465fn collect_regular_seq_matches(
466    index: &index::LicenseIndex,
467    query: &Query<'_>,
468    matched_qspans: &[models::PositionSpan],
469    candidate_contained_matches: &[LicenseMatch],
470    deadline: Option<Instant>,
471) -> Result<Vec<LicenseMatch>, LicenseDetectionError> {
472    let mut seq_all_matches = Vec::new();
473
474    for (query_run_index, query_run) in query.query_runs().into_iter().enumerate() {
475        if query_run_index % 8 == 0 {
476            ensure_within_deadline(deadline)?;
477        }
478
479        if !query_run.is_matchable(false, matched_qspans) {
480            continue;
481        }
482
483        let candidates = if deadline.is_some() {
484            select_seq_candidates_with_deadline(
485                index,
486                &query_run,
487                false,
488                MAX_REGULAR_SEQ_CANDIDATES,
489                deadline,
490            )?
491        } else {
492            self::seq_match::select_seq_candidates(
493                index,
494                &query_run,
495                false,
496                MAX_REGULAR_SEQ_CANDIDATES,
497            )
498        };
499        if !candidates.is_empty() {
500            let matches = if deadline.is_some() {
501                seq_match_with_candidates_and_deadline(index, &query_run, &candidates, deadline)?
502            } else {
503                self::seq_match::seq_match_with_candidates(index, &query_run, &candidates)
504            };
505            seq_all_matches.extend(matches);
506        }
507    }
508
509    let merged_seq = merge_overlapping_matches(&seq_all_matches);
510    let filtered_same_expression =
511        filter_redundant_same_expression_seq_containers(merged_seq, candidate_contained_matches);
512    Ok(filter_redundant_low_coverage_composite_seq_wrappers(
513        filtered_same_expression,
514        candidate_contained_matches,
515    ))
516}
517
518impl LicenseDetectionEngine {
519    /// Create a new license detection engine from a pre-built license index.
520    ///
521    /// This is an internal constructor used by `from_directory()` and `from_embedded()`.
522    /// It builds the SPDX mapping from the licenses in the index.
523    fn from_index(
524        index: index::LicenseIndex,
525        spdx_license_list_version: Option<String>,
526        license_index_provenance: Option<LicenseIndexProvenance>,
527    ) -> Result<Self> {
528        let mut license_vec: Vec<_> = index.licenses_by_key.values().cloned().collect();
529        license_vec.sort_by(|a, b| a.key.cmp(&b.key));
530        let spdx_mapping = build_spdx_mapping(&license_vec);
531
532        Ok(Self {
533            index: Arc::new(index),
534            spdx_mapping,
535            spdx_license_list_version,
536            license_index_provenance,
537        })
538    }
539
540    #[cfg(test)]
541    pub(crate) fn from_test_index(index: index::LicenseIndex) -> Self {
542        Self::from_index(index, None, None).expect("test index should build license engine")
543    }
544
545    /// Create a new license detection engine from the embedded license index.
546    ///
547    /// Convenience method that uses the default Provenant cache root and does
548    /// not force a reindex.
549    pub fn from_embedded() -> Result<Self> {
550        let cache_config =
551            LicenseCacheConfig::new(LicenseCacheConfig::default_root_dir(), false, true);
552        Self::from_embedded_with_cache(&cache_config)
553    }
554
555    /// Create a new license detection engine from the embedded license index.
556    ///
557    /// This method loads the build-time embedded license artifact and constructs
558    /// the runtime license index. This eliminates the runtime dependency on the
559    /// ScanCode rules directory.
560    ///
561    /// If a valid cache exists (matching fingerprint), the index is loaded from
562    /// the rkyv cache file instead of being rebuilt from scratch.
563    ///
564    /// # Arguments
565    /// * `cache_config` - Cache configuration (directory and reindex flag)
566    ///
567    /// # Returns
568    /// A Result containing the engine or an error
569    pub fn from_embedded_with_cache(cache_config: &LicenseCacheConfig) -> Result<Self> {
570        let artifact_bytes = include_bytes!("../../resources/license_detection/license_index.zst");
571        let fingerprint = compute_artifact_fingerprint(artifact_bytes);
572        let artifact_metadata = load_embedded_artifact_metadata_from_bytes(artifact_bytes)
573            .map_err(|e| {
574                anyhow::anyhow!("Failed to load embedded license artifact metadata: {}", e)
575            })?;
576        debug_assert_eq!(
577            artifact_metadata.license_index_provenance.source,
578            EMBEDDED_LICENSE_INDEX_SOURCE
579        );
580        let spdx_version = Some(artifact_metadata.spdx_license_list_version.clone());
581        let provenance = Some(artifact_metadata.license_index_provenance.clone());
582
583        if !cache_config.reindex {
584            if let Some(cached) =
585                load_cached_index(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)?
586            {
587                let start = Instant::now();
588                eprintln!(
589                    "License index loaded from rkyv cache in {:.2}s",
590                    start.elapsed().as_secs_f64()
591                );
592                return Self::from_index(cached, spdx_version, provenance);
593            }
594        } else {
595            delete_cache(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)?;
596        }
597
598        let snapshot = load_loader_snapshot_from_bytes(artifact_bytes)
599            .map_err(|e| anyhow::anyhow!("Failed to load embedded license index: {}", e))?;
600        let spdx_version = Some(snapshot.metadata.spdx_license_list_version.clone());
601        let provenance = Some(snapshot.metadata.license_index_provenance.clone());
602
603        let start = Instant::now();
604        let index = build_index_from_loaded(snapshot.rules, snapshot.licenses, false);
605        eprintln!(
606            "License index built from embedded artifact in {:.2}s",
607            start.elapsed().as_secs_f64()
608        );
609
610        let mut index = index;
611        index.spdx_license_list_version = spdx_version.clone();
612        if let Err(e) = save_cached_index(
613            cache_config,
614            LicenseCacheNamespace::Embedded,
615            &index,
616            &fingerprint,
617        ) {
618            eprintln!("Warning: failed to save license index cache: {}", e);
619        } else if let Some(size) =
620            cache_file_size(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)
621        {
622            eprintln!(
623                "License index cache saved ({:.1} MB)",
624                size as f64 / 1_048_576.0
625            );
626        }
627
628        Self::from_index(index, spdx_version, provenance)
629    }
630
631    /// Create a new license detection engine from a license dataset root.
632    ///
633    /// Convenience method that uses the default Provenant cache root and does
634    /// not force a reindex.
635    pub fn from_directory(rules_path: &Path) -> Result<Self> {
636        let cache_config =
637            LicenseCacheConfig::new(LicenseCacheConfig::default_root_dir(), false, true);
638        Self::from_directory_with_cache(rules_path, &cache_config)
639    }
640
641    /// Create a new license detection engine from a directory of license rules.
642    ///
643    /// If a valid cache exists (matching fingerprint of the dataset), the index is
644    /// loaded from the rkyv cache file instead of being rebuilt from scratch.
645    ///
646    /// # Arguments
647    /// * `rules_path` - Path to dataset root containing rules/ and licenses/
648    /// * `cache_config` - Cache configuration (directory and reindex flag)
649    ///
650    /// # Returns
651    /// A Result containing the engine or an error
652    pub fn from_directory_with_cache(
653        rules_path: &Path,
654        cache_config: &LicenseCacheConfig,
655    ) -> Result<Self> {
656        let LoadedLicenseDataset {
657            manifest,
658            rules: loaded_rules,
659            licenses: loaded_licenses,
660        } = load_license_dataset_from_root(rules_path)?;
661
662        let fingerprint = compute_rules_fingerprint(&loaded_rules, &loaded_licenses)?;
663        let provenance = Some(LicenseIndexProvenance {
664            source: CUSTOM_LICENSE_DATASET_SOURCE.to_string(),
665            dataset_fingerprint: compute_dataset_fingerprint_string(
666                &loaded_rules,
667                &loaded_licenses,
668            )?,
669            ignored_rules: vec![],
670            ignored_licenses: vec![],
671            ignored_rules_due_to_licenses: vec![],
672            added_rules: vec![],
673            replaced_rules: vec![],
674            added_licenses: vec![],
675            replaced_licenses: vec![],
676        });
677
678        if !cache_config.reindex {
679            if let Some(cached) = load_cached_index(
680                cache_config,
681                LicenseCacheNamespace::CustomRules,
682                &fingerprint,
683            )? {
684                let start = Instant::now();
685                eprintln!(
686                    "License index loaded from rkyv cache in {:.2}s",
687                    start.elapsed().as_secs_f64()
688                );
689                return Self::from_index(
690                    cached,
691                    Some(manifest.spdx_license_list_version),
692                    provenance,
693                );
694            }
695        } else {
696            delete_cache(
697                cache_config,
698                LicenseCacheNamespace::CustomRules,
699                &fingerprint,
700            )?;
701        }
702
703        let start = Instant::now();
704        let index = build_index_from_loaded(loaded_rules, loaded_licenses, false);
705        eprintln!(
706            "License index built from custom dataset in {:.2}s",
707            start.elapsed().as_secs_f64()
708        );
709
710        if let Err(e) = save_cached_index(
711            cache_config,
712            LicenseCacheNamespace::CustomRules,
713            &index,
714            &fingerprint,
715        ) {
716            eprintln!("Warning: failed to save license index cache: {}", e);
717        } else if let Some(size) = cache_file_size(
718            cache_config,
719            LicenseCacheNamespace::CustomRules,
720            &fingerprint,
721        ) {
722            eprintln!(
723                "License index cache saved ({:.1} MB)",
724                size as f64 / 1_048_576.0
725            );
726        }
727
728        Self::from_index(index, Some(manifest.spdx_license_list_version), provenance)
729    }
730
731    pub fn embedded_spdx_license_list_version() -> Result<String> {
732        let artifact_bytes = include_bytes!("../../resources/license_detection/license_index.zst");
733        Ok(load_embedded_artifact_metadata_from_bytes(artifact_bytes)
734            .map_err(|e| {
735                anyhow::anyhow!("Failed to load embedded license artifact metadata: {}", e)
736            })?
737            .spdx_license_list_version)
738    }
739
740    pub fn detect_with_kind(
741        &self,
742        text: &str,
743        unknown_licenses: bool,
744        binary_derived: bool,
745    ) -> Result<Vec<LicenseDetection>> {
746        self.detect_with_kind_with_score_and_deadline_with_options(
747            text,
748            unknown_licenses,
749            binary_derived,
750            true,
751            0.0,
752            None,
753        )
754        .map_err(Into::into)
755    }
756
757    pub fn detect_with_kind_with_score(
758        &self,
759        text: &str,
760        unknown_licenses: bool,
761        binary_derived: bool,
762        min_score: f32,
763    ) -> Result<Vec<LicenseDetection>> {
764        self.detect_with_kind_with_score_and_deadline_with_options(
765            text,
766            unknown_licenses,
767            binary_derived,
768            true,
769            min_score,
770            None,
771        )
772        .map_err(Into::into)
773    }
774
775    pub(crate) fn detect_with_kind_with_score_and_deadline_with_options(
776        &self,
777        text: &str,
778        unknown_licenses: bool,
779        binary_derived: bool,
780        enable_sequence_matching: bool,
781        min_score: f32,
782        deadline: Option<Instant>,
783    ) -> Result<Vec<LicenseDetection>, LicenseDetectionError> {
784        ensure_within_deadline(deadline)?;
785        let clean_text = strip_utf8_bom_str(text);
786
787        let content = truncate_detection_text(clean_text);
788
789        ensure_within_deadline(deadline)?;
790        let mut query = if deadline.is_some() {
791            Query::from_extracted_text_with_deadline(
792                content,
793                &self.index,
794                binary_derived,
795                deadline,
796            )?
797        } else {
798            Query::from_extracted_text(content, &self.index, binary_derived)?
799        };
800        let whole_query_run = query.whole_query_run();
801
802        let mut all_matches = Vec::new();
803        let mut candidate_contained_matches = Vec::new();
804        let mut aho_extra_matchables = PositionSet::new();
805        let mut matched_qspans: Vec<models::PositionSpan> = Vec::new();
806
807        // Phase 1a: Hash matching
808        // Python returns immediately if hash matches found (index.py:987-991)
809        {
810            ensure_within_deadline(deadline)?;
811            let hash_matches = hash_match(&self.index, &whole_query_run);
812
813            if !hash_matches.is_empty() {
814                let mut matches = hash_matches;
815                sort_matches_by_line(&mut matches);
816
817                let groups = split_groups_across_frontmatter_boundary(
818                    group_matches_by_region(&matches),
819                    Some(content),
820                );
821                let detections: Vec<LicenseDetection> = groups
822                    .iter()
823                    .map(|group| {
824                        let mut detection = empty_detection();
825                        populate_detection_from_group_with_spdx(
826                            &mut detection,
827                            group,
828                            &self.spdx_mapping,
829                            Some(content),
830                        );
831                        detection
832                    })
833                    .collect();
834
835                return Ok(post_process_detections(detections, min_score));
836            }
837        }
838
839        // Phase 1b: SPDX-LID matching
840        {
841            ensure_within_deadline(deadline)?;
842            let spdx_matches = spdx_lid_match(&self.index, &query);
843            subtract_spdx_match_qspans(
844                &mut query,
845                &mut matched_qspans,
846                &mut aho_extra_matchables,
847                &spdx_matches,
848            );
849            all_matches.extend(spdx_matches);
850        }
851
852        // Phase 1c: Aho-Corasick matching
853        {
854            ensure_within_deadline(deadline)?;
855            let aho_matches = if aho_extra_matchables.is_empty() {
856                if deadline.is_some() {
857                    aho_match::aho_match_with_deadline(&self.index, &whole_query_run, deadline)?
858                } else {
859                    aho_match(&self.index, &whole_query_run)
860                }
861            } else {
862                if deadline.is_some() {
863                    aho_match::aho_match_with_extra_matchables(
864                        &self.index,
865                        &whole_query_run,
866                        Some(&aho_extra_matchables),
867                        deadline,
868                    )?
869                } else {
870                    aho_match::aho_match_with_extra_matchables(
871                        &self.index,
872                        &whole_query_run,
873                        Some(&aho_extra_matchables),
874                        None,
875                    )?
876                }
877            };
878
879            // Python's get_exact_matches() calls refine_matches with merge=False
880            // This applies quality filters including required phrase filtering
881            let refined_aho = match_refine::refine_aho_matches(&self.index, aho_matches, &query);
882            candidate_contained_matches.extend(refined_aho.clone());
883            let (merged_aho, _) = merge_and_prepare_aho_matches(
884                &self.index,
885                &mut query,
886                &mut matched_qspans,
887                &refined_aho,
888            );
889            all_matches.extend(merged_aho);
890
891            let whole_query_followup = collect_whole_query_exact_followup_matches(
892                &self.index,
893                &mut query,
894                &mut matched_qspans,
895                &whole_query_run,
896                enable_sequence_matching,
897                deadline,
898            )?;
899            all_matches.extend(whole_query_followup);
900
901            if enable_sequence_matching {
902                let merged_seq = collect_regular_seq_matches(
903                    &self.index,
904                    &query,
905                    &matched_qspans,
906                    &candidate_contained_matches,
907                    deadline,
908                )?;
909                all_matches.extend(merged_seq);
910            }
911        }
912
913        // Step 1: Initial refine WITHOUT false positive filtering
914        // Python: refine_matches with filter_false_positive=False (index.py:1073-1080)
915        ensure_within_deadline(deadline)?;
916        let merged_matches =
917            refine_matches_without_false_positive_filter(&self.index, all_matches, &query);
918
919        // Step 2: Unknown detection and weak match handling
920        // Python: index.py:1079-1118 - only runs when unknown_licenses=True
921        let refined_matches = if unknown_licenses {
922            // Split weak from good - Python: index.py:1083
923            let (good_matches, weak_matches) = split_weak_matches(&self.index, &merged_matches);
924
925            // Unknown detection on uncovered regions - Python: index.py:1093-1114
926            let unknown_matches = unknown_match(&self.index, &query, &good_matches);
927            let filtered_unknown =
928                filter_invalid_contained_unknown_matches(&unknown_matches, &good_matches);
929
930            let mut all_matches = good_matches;
931            all_matches.extend(filtered_unknown);
932            // reinject weak matches and let refine matches keep the bests
933            // Python: index.py:1117-1118
934            all_matches.extend(weak_matches);
935            all_matches
936        } else {
937            merged_matches
938        };
939
940        // Step 5: Final refine WITH false positive filtering - Python: index.py:1130-1145
941        ensure_within_deadline(deadline)?;
942        let refined = refine_matches(&self.index, refined_matches, &query);
943
944        let mut sorted = refined;
945        sort_matches_by_line(&mut sorted);
946
947        let groups = split_groups_across_frontmatter_boundary(
948            group_matches_by_region(&sorted),
949            Some(content),
950        );
951
952        let detections: Vec<LicenseDetection> = groups
953            .iter()
954            .map(|group| {
955                let mut detection = empty_detection();
956                populate_detection_from_group_with_spdx(
957                    &mut detection,
958                    group,
959                    &self.spdx_mapping,
960                    Some(content),
961                );
962                detection
963            })
964            .collect();
965
966        let detections = post_process_detections(detections, min_score);
967
968        ensure_within_deadline(deadline)?;
969        Ok(detections)
970    }
971
972    pub fn detect_with_kind_and_source(
973        &self,
974        text: &str,
975        unknown_licenses: bool,
976        binary_derived: bool,
977        source_path: &str,
978    ) -> Result<Vec<LicenseDetection>> {
979        self.detect_with_kind_and_source_with_deadline_and_options(
980            text,
981            unknown_licenses,
982            binary_derived,
983            source_path,
984            true,
985            None,
986        )
987        .map_err(Into::into)
988    }
989
990    pub(crate) fn detect_with_kind_and_source_with_deadline_and_options(
991        &self,
992        text: &str,
993        unknown_licenses: bool,
994        binary_derived: bool,
995        source_path: &str,
996        enable_sequence_matching: bool,
997        deadline: Option<Instant>,
998    ) -> Result<Vec<LicenseDetection>, LicenseDetectionError> {
999        let mut detections = self.detect_with_kind_with_score_and_deadline_with_options(
1000            text,
1001            unknown_licenses,
1002            binary_derived,
1003            enable_sequence_matching,
1004            0.0,
1005            deadline,
1006        )?;
1007        attach_source_path_to_detections(&mut detections, source_path);
1008        Ok(detections)
1009    }
1010
1011    pub fn detect_with_kind_and_source_with_score(
1012        &self,
1013        text: &str,
1014        unknown_licenses: bool,
1015        binary_derived: bool,
1016        source_path: &str,
1017        min_score: f32,
1018    ) -> Result<Vec<LicenseDetection>> {
1019        self.detect_with_kind_and_source_with_score_options(
1020            text,
1021            unknown_licenses,
1022            binary_derived,
1023            source_path,
1024            true,
1025            min_score,
1026        )
1027    }
1028
1029    pub fn detect_with_kind_and_source_with_options(
1030        &self,
1031        text: &str,
1032        unknown_licenses: bool,
1033        binary_derived: bool,
1034        source_path: &str,
1035        enable_sequence_matching: bool,
1036    ) -> Result<Vec<LicenseDetection>> {
1037        self.detect_with_kind_and_source_with_score_options(
1038            text,
1039            unknown_licenses,
1040            binary_derived,
1041            source_path,
1042            enable_sequence_matching,
1043            0.0,
1044        )
1045    }
1046
1047    pub fn detect_with_kind_and_source_with_score_options(
1048        &self,
1049        text: &str,
1050        unknown_licenses: bool,
1051        binary_derived: bool,
1052        source_path: &str,
1053        enable_sequence_matching: bool,
1054        min_score: f32,
1055    ) -> Result<Vec<LicenseDetection>> {
1056        let mut detections = self.detect_with_kind_with_score_and_deadline_with_options(
1057            text,
1058            unknown_licenses,
1059            binary_derived,
1060            enable_sequence_matching,
1061            min_score,
1062            None,
1063        )?;
1064        attach_source_path_to_detections(&mut detections, source_path);
1065        Ok(detections)
1066    }
1067
1068    /// Detect licenses and return raw matches (like Python's idx.match()).
1069    ///
1070    /// This is primarily used by golden tests and maintenance tooling that need
1071    /// raw match sequences before grouping or post-processing into detections.
1072    #[cfg(any(test, feature = "golden-tests"))]
1073    pub fn detect_matches_with_kind(
1074        &self,
1075        text: &str,
1076        unknown_licenses: bool,
1077        binary_derived: bool,
1078    ) -> Result<Vec<LicenseMatch>> {
1079        let clean_text = strip_utf8_bom_str(text);
1080
1081        let content = truncate_detection_text(clean_text);
1082
1083        let mut query = Query::from_extracted_text(content, &self.index, binary_derived)?;
1084        let whole_query_run = query.whole_query_run();
1085
1086        let mut all_matches = Vec::new();
1087        let mut candidate_contained_matches = Vec::new();
1088        let mut aho_extra_matchables = PositionSet::new();
1089        let mut matched_qspans: Vec<models::PositionSpan> = Vec::new();
1090
1091        // Phase 1a: Hash matching
1092        {
1093            let hash_matches = hash_match(&self.index, &whole_query_run);
1094
1095            if !hash_matches.is_empty() {
1096                let mut matches = hash_matches;
1097                sort_matches_by_line(&mut matches);
1098                return Ok(matches);
1099            }
1100        }
1101
1102        // Phase 1b: SPDX-LID matching
1103        {
1104            let spdx_matches = spdx_lid_match(&self.index, &query);
1105            subtract_spdx_match_qspans(
1106                &mut query,
1107                &mut matched_qspans,
1108                &mut aho_extra_matchables,
1109                &spdx_matches,
1110            );
1111            all_matches.extend(spdx_matches);
1112        }
1113
1114        // Phase 1c: Aho-Corasick matching
1115        {
1116            let aho_matches = if aho_extra_matchables.is_empty() {
1117                aho_match(&self.index, &whole_query_run)
1118            } else {
1119                aho_match::aho_match_with_extra_matchables(
1120                    &self.index,
1121                    &whole_query_run,
1122                    Some(&aho_extra_matchables),
1123                    None,
1124                )?
1125            };
1126            let refined_aho = match_refine::refine_aho_matches(&self.index, aho_matches, &query);
1127            candidate_contained_matches.extend(refined_aho.clone());
1128            let (merged_aho, _) = merge_and_prepare_aho_matches(
1129                &self.index,
1130                &mut query,
1131                &mut matched_qspans,
1132                &refined_aho,
1133            );
1134            all_matches.extend(merged_aho);
1135
1136            let whole_query_followup = collect_whole_query_exact_followup_matches(
1137                &self.index,
1138                &mut query,
1139                &mut matched_qspans,
1140                &whole_query_run,
1141                true,
1142                None,
1143            )?;
1144            all_matches.extend(whole_query_followup);
1145
1146            let merged_seq = collect_regular_seq_matches(
1147                &self.index,
1148                &query,
1149                &matched_qspans,
1150                &candidate_contained_matches,
1151                None,
1152            )?;
1153            all_matches.extend(merged_seq);
1154        }
1155
1156        // Step 1: Initial refine WITHOUT false positive filtering
1157        let merged_matches =
1158            refine_matches_without_false_positive_filter(&self.index, all_matches, &query);
1159
1160        // Step 2: Unknown detection and weak match handling
1161        let refined_matches = if unknown_licenses {
1162            let (good_matches, weak_matches) = split_weak_matches(&self.index, &merged_matches);
1163            let unknown_matches = unknown_match(&self.index, &query, &good_matches);
1164            let filtered_unknown =
1165                filter_invalid_contained_unknown_matches(&unknown_matches, &good_matches);
1166
1167            let mut all_matches = good_matches;
1168            all_matches.extend(filtered_unknown);
1169            all_matches.extend(weak_matches);
1170            all_matches
1171        } else {
1172            merged_matches
1173        };
1174
1175        // Step 3: Final refine WITH false positive filtering - Python: index.py:1130-1145
1176        let refined = refine_matches(&self.index, refined_matches, &query);
1177
1178        let mut sorted = refined;
1179        sort_matches_by_line(&mut sorted);
1180
1181        // Return raw matches (NOT grouped) - this is Python's idx.match() behavior
1182        Ok(sorted)
1183    }
1184
1185    /// Get a reference to the license index.
1186    pub fn index(&self) -> &index::LicenseIndex {
1187        &self.index
1188    }
1189
1190    pub fn spdx_license_list_version(&self) -> Option<&str> {
1191        self.spdx_license_list_version.as_deref()
1192    }
1193
1194    pub fn license_index_provenance(&self) -> Option<&LicenseIndexProvenance> {
1195        self.license_index_provenance.as_ref()
1196    }
1197
1198    /// Get a reference to the SPDX mapping.
1199    #[cfg(test)]
1200    pub fn spdx_mapping(&self) -> &SpdxMapping {
1201        &self.spdx_mapping
1202    }
1203}
1204
1205pub fn detect_scancode_spdx_license_list_version(search_path: &Path) -> Result<Option<String>> {
1206    for ancestor in search_path.ancestors() {
1207        let candidate = ancestor.join("scancode_config.py");
1208        if candidate.is_file() {
1209            let config = fs::read_to_string(&candidate)?;
1210            return Ok(parse_scancode_spdx_license_list_version(&config));
1211        }
1212    }
1213
1214    Ok(None)
1215}
1216
1217fn parse_scancode_spdx_license_list_version(config: &str) -> Option<String> {
1218    config.lines().find_map(|line| {
1219        let trimmed = line.trim();
1220        let (_, value) = trimmed.split_once('=')?;
1221        (trimmed.starts_with("spdx_license_list_version")).then(|| {
1222            value
1223                .trim()
1224                .trim_matches('"')
1225                .trim_matches('\'')
1226                .to_string()
1227        })
1228    })
1229}
1230
1231#[cfg(test)]
1232mod tests;