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)]
34pub(crate) mod 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    #[cfg(test)]
546    pub(crate) fn from_test_index_with_provenance(
547        index: index::LicenseIndex,
548        license_index_provenance: LicenseIndexProvenance,
549    ) -> Self {
550        Self::from_index(index, None, Some(license_index_provenance))
551            .expect("test index should build license engine")
552    }
553
554    /// Create a new license detection engine from the embedded license index.
555    ///
556    /// Convenience method that uses the default Provenant cache root and does
557    /// not force a reindex.
558    pub fn from_embedded() -> Result<Self> {
559        let cache_config =
560            LicenseCacheConfig::new(LicenseCacheConfig::default_root_dir(), false, true);
561        Self::from_embedded_with_cache(&cache_config)
562    }
563
564    /// Create a new license detection engine from the embedded license index.
565    ///
566    /// This method loads the build-time embedded license artifact and constructs
567    /// the runtime license index. This eliminates the runtime dependency on the
568    /// ScanCode rules directory.
569    ///
570    /// If a valid cache exists (matching fingerprint), the index is loaded from
571    /// the rkyv cache file instead of being rebuilt from scratch.
572    ///
573    /// # Arguments
574    /// * `cache_config` - Cache configuration (directory and reindex flag)
575    ///
576    /// # Returns
577    /// A Result containing the engine or an error
578    pub fn from_embedded_with_cache(cache_config: &LicenseCacheConfig) -> Result<Self> {
579        let artifact_bytes = include_bytes!("../../resources/license_detection/license_index.zst");
580        let fingerprint = compute_artifact_fingerprint(artifact_bytes);
581        let artifact_metadata = load_embedded_artifact_metadata_from_bytes(artifact_bytes)
582            .map_err(|e| {
583                anyhow::anyhow!("Failed to load embedded license artifact metadata: {}", e)
584            })?;
585        debug_assert_eq!(
586            artifact_metadata.license_index_provenance.source,
587            EMBEDDED_LICENSE_INDEX_SOURCE
588        );
589        let spdx_version = Some(artifact_metadata.spdx_license_list_version.clone());
590        let provenance = Some(artifact_metadata.license_index_provenance.clone());
591
592        if !cache_config.reindex {
593            let start = Instant::now();
594            if let Some(cached) =
595                load_cached_index(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)?
596            {
597                eprintln!(
598                    "License index loaded from rkyv cache in {:.2}s",
599                    start.elapsed().as_secs_f64()
600                );
601                return Self::from_index(cached, spdx_version, provenance);
602            }
603        } else {
604            delete_cache(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)?;
605        }
606
607        let snapshot = load_loader_snapshot_from_bytes(artifact_bytes)
608            .map_err(|e| anyhow::anyhow!("Failed to load embedded license index: {}", e))?;
609        let spdx_version = Some(snapshot.metadata.spdx_license_list_version.clone());
610        let provenance = Some(snapshot.metadata.license_index_provenance.clone());
611
612        let start = Instant::now();
613        let index = build_index_from_loaded(snapshot.rules, snapshot.licenses, false);
614        eprintln!(
615            "License index built from embedded artifact in {:.2}s",
616            start.elapsed().as_secs_f64()
617        );
618
619        let mut index = index;
620        index.spdx_license_list_version = spdx_version.clone();
621        if let Err(e) = save_cached_index(
622            cache_config,
623            LicenseCacheNamespace::Embedded,
624            &index,
625            &fingerprint,
626        ) {
627            eprintln!("Warning: failed to save license index cache: {}", e);
628        } else if let Some(size) =
629            cache_file_size(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)
630        {
631            eprintln!(
632                "License index cache saved ({:.1} MB)",
633                size as f64 / 1_048_576.0
634            );
635        }
636
637        Self::from_index(index, spdx_version, provenance)
638    }
639
640    /// Create a new license detection engine from a license dataset root.
641    ///
642    /// Convenience method that uses the default Provenant cache root and does
643    /// not force a reindex.
644    pub fn from_directory(rules_path: &Path) -> Result<Self> {
645        let cache_config =
646            LicenseCacheConfig::new(LicenseCacheConfig::default_root_dir(), false, true);
647        Self::from_directory_with_cache(rules_path, &cache_config)
648    }
649
650    /// Create a new license detection engine from a directory of license rules.
651    ///
652    /// If a valid cache exists (matching fingerprint of the dataset), the index is
653    /// loaded from the rkyv cache file instead of being rebuilt from scratch.
654    ///
655    /// # Arguments
656    /// * `rules_path` - Path to dataset root containing rules/ and licenses/
657    /// * `cache_config` - Cache configuration (directory and reindex flag)
658    ///
659    /// # Returns
660    /// A Result containing the engine or an error
661    pub fn from_directory_with_cache(
662        rules_path: &Path,
663        cache_config: &LicenseCacheConfig,
664    ) -> Result<Self> {
665        let LoadedLicenseDataset {
666            manifest,
667            rules: loaded_rules,
668            licenses: loaded_licenses,
669        } = load_license_dataset_from_root(rules_path)?;
670
671        let fingerprint = compute_rules_fingerprint(&loaded_rules, &loaded_licenses)?;
672        let provenance = Some(LicenseIndexProvenance {
673            source: CUSTOM_LICENSE_DATASET_SOURCE.to_string(),
674            dataset_fingerprint: compute_dataset_fingerprint_string(
675                &loaded_rules,
676                &loaded_licenses,
677            )?,
678            ignored_rules: vec![],
679            ignored_licenses: vec![],
680            ignored_rules_due_to_licenses: vec![],
681            added_rules: vec![],
682            replaced_rules: vec![],
683            added_licenses: vec![],
684            replaced_licenses: vec![],
685        });
686
687        if !cache_config.reindex {
688            if let Some(cached) = load_cached_index(
689                cache_config,
690                LicenseCacheNamespace::CustomRules,
691                &fingerprint,
692            )? {
693                let start = Instant::now();
694                eprintln!(
695                    "License index loaded from rkyv cache in {:.2}s",
696                    start.elapsed().as_secs_f64()
697                );
698                return Self::from_index(
699                    cached,
700                    Some(manifest.spdx_license_list_version),
701                    provenance,
702                );
703            }
704        } else {
705            delete_cache(
706                cache_config,
707                LicenseCacheNamespace::CustomRules,
708                &fingerprint,
709            )?;
710        }
711
712        let start = Instant::now();
713        let index = build_index_from_loaded(loaded_rules, loaded_licenses, false);
714        eprintln!(
715            "License index built from custom dataset in {:.2}s",
716            start.elapsed().as_secs_f64()
717        );
718
719        if let Err(e) = save_cached_index(
720            cache_config,
721            LicenseCacheNamespace::CustomRules,
722            &index,
723            &fingerprint,
724        ) {
725            eprintln!("Warning: failed to save license index cache: {}", e);
726        } else if let Some(size) = cache_file_size(
727            cache_config,
728            LicenseCacheNamespace::CustomRules,
729            &fingerprint,
730        ) {
731            eprintln!(
732                "License index cache saved ({:.1} MB)",
733                size as f64 / 1_048_576.0
734            );
735        }
736
737        Self::from_index(index, Some(manifest.spdx_license_list_version), provenance)
738    }
739
740    pub fn embedded_spdx_license_list_version() -> Result<String> {
741        let artifact_bytes = include_bytes!("../../resources/license_detection/license_index.zst");
742        Ok(load_embedded_artifact_metadata_from_bytes(artifact_bytes)
743            .map_err(|e| {
744                anyhow::anyhow!("Failed to load embedded license artifact metadata: {}", e)
745            })?
746            .spdx_license_list_version)
747    }
748
749    pub fn detect_with_kind(
750        &self,
751        text: &str,
752        unknown_licenses: bool,
753        binary_derived: bool,
754    ) -> Result<Vec<LicenseDetection>> {
755        self.detect_with_kind_with_score_and_deadline_with_options(
756            text,
757            unknown_licenses,
758            binary_derived,
759            true,
760            0.0,
761            None,
762        )
763        .map_err(Into::into)
764    }
765
766    pub fn detect_with_kind_with_score(
767        &self,
768        text: &str,
769        unknown_licenses: bool,
770        binary_derived: bool,
771        min_score: f32,
772    ) -> Result<Vec<LicenseDetection>> {
773        self.detect_with_kind_with_score_and_deadline_with_options(
774            text,
775            unknown_licenses,
776            binary_derived,
777            true,
778            min_score,
779            None,
780        )
781        .map_err(Into::into)
782    }
783
784    pub(crate) fn detect_with_kind_with_score_and_deadline_with_options(
785        &self,
786        text: &str,
787        unknown_licenses: bool,
788        binary_derived: bool,
789        enable_sequence_matching: bool,
790        min_score: f32,
791        deadline: Option<Instant>,
792    ) -> Result<Vec<LicenseDetection>, LicenseDetectionError> {
793        ensure_within_deadline(deadline)?;
794        let clean_text = strip_utf8_bom_str(text);
795
796        let content = truncate_detection_text(clean_text);
797
798        ensure_within_deadline(deadline)?;
799        let mut query = if deadline.is_some() {
800            Query::from_extracted_text_with_deadline(
801                content,
802                &self.index,
803                binary_derived,
804                deadline,
805            )?
806        } else {
807            Query::from_extracted_text(content, &self.index, binary_derived)?
808        };
809        let whole_query_run = query.whole_query_run();
810
811        let mut all_matches = Vec::new();
812        let mut candidate_contained_matches = Vec::new();
813        let mut aho_extra_matchables = PositionSet::new();
814        let mut matched_qspans: Vec<models::PositionSpan> = Vec::new();
815
816        // Phase 1a: Hash matching
817        // Python returns immediately if hash matches found (index.py:987-991)
818        {
819            ensure_within_deadline(deadline)?;
820            let hash_matches = hash_match(&self.index, &whole_query_run);
821
822            if !hash_matches.is_empty() {
823                let mut matches = hash_matches;
824                sort_matches_by_line(&mut matches);
825
826                let groups = split_groups_across_frontmatter_boundary(
827                    group_matches_by_region(&matches),
828                    Some(content),
829                );
830                let detections: Vec<LicenseDetection> = groups
831                    .iter()
832                    .map(|group| {
833                        let mut detection = empty_detection();
834                        populate_detection_from_group_with_spdx(
835                            &mut detection,
836                            group,
837                            &self.spdx_mapping,
838                            Some(content),
839                        );
840                        detection
841                    })
842                    .collect();
843
844                return Ok(post_process_detections(detections, min_score));
845            }
846        }
847
848        // Phase 1b: SPDX-LID matching
849        {
850            ensure_within_deadline(deadline)?;
851            let spdx_matches = spdx_lid_match(&self.index, &query);
852            subtract_spdx_match_qspans(
853                &mut query,
854                &mut matched_qspans,
855                &mut aho_extra_matchables,
856                &spdx_matches,
857            );
858            all_matches.extend(spdx_matches);
859        }
860
861        // Phase 1c: Aho-Corasick matching
862        {
863            ensure_within_deadline(deadline)?;
864            let aho_matches = if aho_extra_matchables.is_empty() {
865                if deadline.is_some() {
866                    aho_match::aho_match_with_deadline(&self.index, &whole_query_run, deadline)?
867                } else {
868                    aho_match(&self.index, &whole_query_run)
869                }
870            } else {
871                if deadline.is_some() {
872                    aho_match::aho_match_with_extra_matchables(
873                        &self.index,
874                        &whole_query_run,
875                        Some(&aho_extra_matchables),
876                        deadline,
877                    )?
878                } else {
879                    aho_match::aho_match_with_extra_matchables(
880                        &self.index,
881                        &whole_query_run,
882                        Some(&aho_extra_matchables),
883                        None,
884                    )?
885                }
886            };
887
888            // Python's get_exact_matches() calls refine_matches with merge=False
889            // This applies quality filters including required phrase filtering
890            let refined_aho = match_refine::refine_aho_matches(&self.index, aho_matches, &query);
891            candidate_contained_matches.extend(refined_aho.clone());
892            let (merged_aho, _) = merge_and_prepare_aho_matches(
893                &self.index,
894                &mut query,
895                &mut matched_qspans,
896                &refined_aho,
897            );
898            all_matches.extend(merged_aho);
899
900            let whole_query_followup = collect_whole_query_exact_followup_matches(
901                &self.index,
902                &mut query,
903                &mut matched_qspans,
904                &whole_query_run,
905                enable_sequence_matching,
906                deadline,
907            )?;
908            all_matches.extend(whole_query_followup);
909
910            if enable_sequence_matching {
911                let merged_seq = collect_regular_seq_matches(
912                    &self.index,
913                    &query,
914                    &matched_qspans,
915                    &candidate_contained_matches,
916                    deadline,
917                )?;
918                all_matches.extend(merged_seq);
919            }
920        }
921
922        // Step 1: Initial refine WITHOUT false positive filtering
923        // Python: refine_matches with filter_false_positive=False (index.py:1073-1080)
924        ensure_within_deadline(deadline)?;
925        let merged_matches =
926            refine_matches_without_false_positive_filter(&self.index, all_matches, &query);
927
928        // Step 2: Unknown detection and weak match handling
929        // Python: index.py:1079-1118 - only runs when unknown_licenses=True
930        let refined_matches = if unknown_licenses {
931            // Split weak from good - Python: index.py:1083
932            let (good_matches, weak_matches) = split_weak_matches(&self.index, &merged_matches);
933
934            // Unknown detection on uncovered regions - Python: index.py:1093-1114
935            let unknown_matches = unknown_match(&self.index, &query, &good_matches);
936            let filtered_unknown =
937                filter_invalid_contained_unknown_matches(&unknown_matches, &good_matches);
938
939            let mut all_matches = good_matches;
940            all_matches.extend(filtered_unknown);
941            // reinject weak matches and let refine matches keep the bests
942            // Python: index.py:1117-1118
943            all_matches.extend(weak_matches);
944            all_matches
945        } else {
946            merged_matches
947        };
948
949        // Step 5: Final refine WITH false positive filtering - Python: index.py:1130-1145
950        ensure_within_deadline(deadline)?;
951        let refined = refine_matches(&self.index, refined_matches, &query);
952
953        let mut sorted = refined;
954        sort_matches_by_line(&mut sorted);
955
956        let groups = split_groups_across_frontmatter_boundary(
957            group_matches_by_region(&sorted),
958            Some(content),
959        );
960
961        let detections: Vec<LicenseDetection> = groups
962            .iter()
963            .map(|group| {
964                let mut detection = empty_detection();
965                populate_detection_from_group_with_spdx(
966                    &mut detection,
967                    group,
968                    &self.spdx_mapping,
969                    Some(content),
970                );
971                detection
972            })
973            .collect();
974
975        let detections = post_process_detections(detections, min_score);
976
977        ensure_within_deadline(deadline)?;
978        Ok(detections)
979    }
980
981    pub fn detect_with_kind_and_source(
982        &self,
983        text: &str,
984        unknown_licenses: bool,
985        binary_derived: bool,
986        source_path: &str,
987    ) -> Result<Vec<LicenseDetection>> {
988        self.detect_with_kind_and_source_with_deadline_and_options(
989            text,
990            unknown_licenses,
991            binary_derived,
992            source_path,
993            true,
994            None,
995        )
996        .map_err(Into::into)
997    }
998
999    pub(crate) fn detect_with_kind_and_source_with_deadline_and_options(
1000        &self,
1001        text: &str,
1002        unknown_licenses: bool,
1003        binary_derived: bool,
1004        source_path: &str,
1005        enable_sequence_matching: bool,
1006        deadline: Option<Instant>,
1007    ) -> Result<Vec<LicenseDetection>, LicenseDetectionError> {
1008        let mut detections = self.detect_with_kind_with_score_and_deadline_with_options(
1009            text,
1010            unknown_licenses,
1011            binary_derived,
1012            enable_sequence_matching,
1013            0.0,
1014            deadline,
1015        )?;
1016        attach_source_path_to_detections(&mut detections, source_path);
1017        Ok(detections)
1018    }
1019
1020    pub fn detect_with_kind_and_source_with_score(
1021        &self,
1022        text: &str,
1023        unknown_licenses: bool,
1024        binary_derived: bool,
1025        source_path: &str,
1026        min_score: f32,
1027    ) -> Result<Vec<LicenseDetection>> {
1028        self.detect_with_kind_and_source_with_score_options(
1029            text,
1030            unknown_licenses,
1031            binary_derived,
1032            source_path,
1033            true,
1034            min_score,
1035        )
1036    }
1037
1038    pub fn detect_with_kind_and_source_with_options(
1039        &self,
1040        text: &str,
1041        unknown_licenses: bool,
1042        binary_derived: bool,
1043        source_path: &str,
1044        enable_sequence_matching: bool,
1045    ) -> Result<Vec<LicenseDetection>> {
1046        self.detect_with_kind_and_source_with_score_options(
1047            text,
1048            unknown_licenses,
1049            binary_derived,
1050            source_path,
1051            enable_sequence_matching,
1052            0.0,
1053        )
1054    }
1055
1056    pub fn detect_with_kind_and_source_with_score_options(
1057        &self,
1058        text: &str,
1059        unknown_licenses: bool,
1060        binary_derived: bool,
1061        source_path: &str,
1062        enable_sequence_matching: bool,
1063        min_score: f32,
1064    ) -> Result<Vec<LicenseDetection>> {
1065        let mut detections = self.detect_with_kind_with_score_and_deadline_with_options(
1066            text,
1067            unknown_licenses,
1068            binary_derived,
1069            enable_sequence_matching,
1070            min_score,
1071            None,
1072        )?;
1073        attach_source_path_to_detections(&mut detections, source_path);
1074        Ok(detections)
1075    }
1076
1077    /// Detect licenses and return raw matches (like Python's idx.match()).
1078    ///
1079    /// This is primarily used by golden tests and maintenance tooling that need
1080    /// raw match sequences before grouping or post-processing into detections.
1081    #[cfg(any(test, feature = "golden-tests"))]
1082    pub fn detect_matches_with_kind(
1083        &self,
1084        text: &str,
1085        unknown_licenses: bool,
1086        binary_derived: bool,
1087    ) -> Result<Vec<LicenseMatch>> {
1088        let clean_text = strip_utf8_bom_str(text);
1089
1090        let content = truncate_detection_text(clean_text);
1091
1092        let mut query = Query::from_extracted_text(content, &self.index, binary_derived)?;
1093        let whole_query_run = query.whole_query_run();
1094
1095        let mut all_matches = Vec::new();
1096        let mut candidate_contained_matches = Vec::new();
1097        let mut aho_extra_matchables = PositionSet::new();
1098        let mut matched_qspans: Vec<models::PositionSpan> = Vec::new();
1099
1100        // Phase 1a: Hash matching
1101        {
1102            let hash_matches = hash_match(&self.index, &whole_query_run);
1103
1104            if !hash_matches.is_empty() {
1105                let mut matches = hash_matches;
1106                sort_matches_by_line(&mut matches);
1107                return Ok(matches);
1108            }
1109        }
1110
1111        // Phase 1b: SPDX-LID matching
1112        {
1113            let spdx_matches = spdx_lid_match(&self.index, &query);
1114            subtract_spdx_match_qspans(
1115                &mut query,
1116                &mut matched_qspans,
1117                &mut aho_extra_matchables,
1118                &spdx_matches,
1119            );
1120            all_matches.extend(spdx_matches);
1121        }
1122
1123        // Phase 1c: Aho-Corasick matching
1124        {
1125            let aho_matches = if aho_extra_matchables.is_empty() {
1126                aho_match(&self.index, &whole_query_run)
1127            } else {
1128                aho_match::aho_match_with_extra_matchables(
1129                    &self.index,
1130                    &whole_query_run,
1131                    Some(&aho_extra_matchables),
1132                    None,
1133                )?
1134            };
1135            let refined_aho = match_refine::refine_aho_matches(&self.index, aho_matches, &query);
1136            candidate_contained_matches.extend(refined_aho.clone());
1137            let (merged_aho, _) = merge_and_prepare_aho_matches(
1138                &self.index,
1139                &mut query,
1140                &mut matched_qspans,
1141                &refined_aho,
1142            );
1143            all_matches.extend(merged_aho);
1144
1145            let whole_query_followup = collect_whole_query_exact_followup_matches(
1146                &self.index,
1147                &mut query,
1148                &mut matched_qspans,
1149                &whole_query_run,
1150                true,
1151                None,
1152            )?;
1153            all_matches.extend(whole_query_followup);
1154
1155            let merged_seq = collect_regular_seq_matches(
1156                &self.index,
1157                &query,
1158                &matched_qspans,
1159                &candidate_contained_matches,
1160                None,
1161            )?;
1162            all_matches.extend(merged_seq);
1163        }
1164
1165        // Step 1: Initial refine WITHOUT false positive filtering
1166        let merged_matches =
1167            refine_matches_without_false_positive_filter(&self.index, all_matches, &query);
1168
1169        // Step 2: Unknown detection and weak match handling
1170        let refined_matches = if unknown_licenses {
1171            let (good_matches, weak_matches) = split_weak_matches(&self.index, &merged_matches);
1172            let unknown_matches = unknown_match(&self.index, &query, &good_matches);
1173            let filtered_unknown =
1174                filter_invalid_contained_unknown_matches(&unknown_matches, &good_matches);
1175
1176            let mut all_matches = good_matches;
1177            all_matches.extend(filtered_unknown);
1178            all_matches.extend(weak_matches);
1179            all_matches
1180        } else {
1181            merged_matches
1182        };
1183
1184        // Step 3: Final refine WITH false positive filtering - Python: index.py:1130-1145
1185        let refined = refine_matches(&self.index, refined_matches, &query);
1186
1187        let mut sorted = refined;
1188        sort_matches_by_line(&mut sorted);
1189
1190        // Return raw matches (NOT grouped) - this is Python's idx.match() behavior
1191        Ok(sorted)
1192    }
1193
1194    /// Get a reference to the license index.
1195    pub fn index(&self) -> &index::LicenseIndex {
1196        &self.index
1197    }
1198
1199    pub fn spdx_license_list_version(&self) -> Option<&str> {
1200        self.spdx_license_list_version.as_deref()
1201    }
1202
1203    pub fn license_index_provenance(&self) -> Option<&LicenseIndexProvenance> {
1204        self.license_index_provenance.as_ref()
1205    }
1206
1207    /// Get a reference to the SPDX mapping.
1208    #[cfg(test)]
1209    pub fn spdx_mapping(&self) -> &SpdxMapping {
1210        &self.spdx_mapping
1211    }
1212}
1213
1214pub fn detect_scancode_spdx_license_list_version(search_path: &Path) -> Result<Option<String>> {
1215    for ancestor in search_path.ancestors() {
1216        let candidate = ancestor.join("scancode_config.py");
1217        if candidate.is_file() {
1218            let config = fs::read_to_string(&candidate)?;
1219            return Ok(parse_scancode_spdx_license_list_version(&config));
1220        }
1221    }
1222
1223    Ok(None)
1224}
1225
1226fn parse_scancode_spdx_license_list_version(config: &str) -> Option<String> {
1227    config.lines().find_map(|line| {
1228        let trimmed = line.trim();
1229        let (_, value) = trimmed.split_once('=')?;
1230        (trimmed.starts_with("spdx_license_list_version")).then(|| {
1231            value
1232                .trim()
1233                .trim_matches('"')
1234                .trim_matches('\'')
1235                .to_string()
1236        })
1237    })
1238}
1239
1240#[cfg(test)]
1241mod tests;