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::{HighBitset, 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    // Provenant-specific tuning (no ScanCode equivalent): only very-low-coverage seq
292    // wrappers are eligible for redundant-composite dropping. A seq match at or above
293    // 30.0 coverage carries enough of its own signal to keep regardless of the contained
294    // exact matches it spans.
295    if container.matcher != seq_match::MATCH_SEQ || container.coverage() >= 30.0 {
296        return false;
297    }
298
299    let container_qspan_set = container.qspan_set();
300
301    let children: Vec<&LicenseMatch> = candidate_contained_matches
302        .iter()
303        .filter(|m| {
304            m.matcher == aho_match::MATCH_AHO
305                && has_full_match_coverage(m)
306                && m.license_expression != container.license_expression
307                && m.overlaps_with(&container_qspan_set)
308        })
309        .collect();
310
311    if children.len() < 2 {
312        return false;
313    }
314
315    let unique_expressions: HashSet<&str> = children
316        .iter()
317        .map(|m| m.license_expression.as_str())
318        .collect();
319    if unique_expressions.len() < 2 {
320        return false;
321    }
322
323    let mut child_union = PositionSet::new();
324    for m in &children {
325        child_union.extend_from_span(m.query_span());
326    }
327
328    let container_only_positions = container_qspan_set.difference(&child_union);
329    let child_only_positions = child_union.difference(&container_qspan_set);
330
331    let mut sorted_children = children;
332    sorted_children.sort_by_key(|m| m.qspan_bounds());
333
334    let mut bridge_positions = BitSet::new();
335    for pair in sorted_children.windows(2) {
336        let (_, previous_end) = pair[0].qspan_bounds();
337        let (next_start, _) = pair[1].qspan_bounds();
338        for pos in previous_end..next_start {
339            bridge_positions.insert(pos);
340        }
341    }
342
343    let container_only_boundary_positions = container_only_positions
344        .iter()
345        .filter(|&pos| !bridge_positions.contains(pos))
346        .count();
347
348    child_only_positions.is_empty()
349        && container_only_positions.len() <= MAX_REDUNDANT_SEQ_CONTAINER_BOUNDARY_GAP
350        && container_only_boundary_positions <= MAX_REDUNDANT_SEQ_CONTAINER_BOUNDARY_GAP
351}
352
353fn filter_redundant_low_coverage_composite_seq_wrappers(
354    seq_matches: Vec<LicenseMatch>,
355    candidate_contained_matches: &[LicenseMatch],
356) -> Vec<LicenseMatch> {
357    seq_matches
358        .into_iter()
359        .filter(|m| {
360            !is_redundant_low_coverage_composite_seq_wrapper(m, candidate_contained_matches)
361        })
362        .collect()
363}
364
365fn subtract_spdx_match_qspans(
366    query: &mut Query<'_>,
367    matched_qspans: &mut Vec<models::PositionSpan>,
368    aho_extra_matchables: &mut PositionSet,
369    spdx_matches: &[LicenseMatch],
370) {
371    for m in spdx_matches {
372        let Some(span) = query_span_for_match(m) else {
373            continue;
374        };
375
376        aho_extra_matchables.extend_from_span(&span);
377        query.subtract(&span);
378
379        if has_full_match_coverage(m) {
380            matched_qspans.push(span);
381        }
382    }
383}
384
385fn merge_and_prepare_aho_matches(
386    index: &index::LicenseIndex,
387    query: &mut Query<'_>,
388    matched_qspans: &mut Vec<models::PositionSpan>,
389    refined_aho: &[LicenseMatch],
390) -> (Vec<LicenseMatch>, bool) {
391    let merged_aho = merge_overlapping_matches(refined_aho);
392    let mut saw_long_exact_license_text_match = false;
393
394    for m in &merged_aho {
395        let Some(span) = query_span_for_match(m) else {
396            continue;
397        };
398
399        if has_full_match_coverage(m) {
400            matched_qspans.push(span.clone());
401        }
402
403        if index.rule(m.rid).is_some_and(|rule| rule.is_license_text())
404            && m.rule_length > 120
405            && m.coverage() > 98.0
406        {
407            query.subtract(&span);
408            saw_long_exact_license_text_match = true;
409        }
410    }
411
412    (merged_aho, saw_long_exact_license_text_match)
413}
414
415fn collect_whole_query_exact_followup_matches(
416    index: &index::LicenseIndex,
417    query: &mut Query<'_>,
418    matched_qspans: &mut Vec<models::PositionSpan>,
419    whole_run: &query::QueryRun<'_>,
420    enable_sequence_matching: bool,
421    deadline: Option<Instant>,
422) -> Result<Vec<LicenseMatch>, LicenseDetectionError> {
423    if !enable_sequence_matching {
424        return Ok(Vec::new());
425    }
426
427    let mut seq_all_matches = Vec::new();
428
429    if whole_run.is_matchable(false, matched_qspans) {
430        let near_dupe_candidates = if deadline.is_some() {
431            select_seq_candidates_with_deadline(
432                index,
433                whole_run,
434                true,
435                MAX_NEAR_DUPE_CANDIDATES,
436                deadline,
437            )?
438        } else {
439            self::seq_match::select_seq_candidates(index, whole_run, true, MAX_NEAR_DUPE_CANDIDATES)
440        };
441
442        if !near_dupe_candidates.is_empty() {
443            let near_dupe_matches = if deadline.is_some() {
444                seq_match_with_candidates_and_deadline(
445                    index,
446                    whole_run,
447                    &near_dupe_candidates,
448                    deadline,
449                )?
450            } else {
451                self::seq_match::seq_match_with_candidates(index, whole_run, &near_dupe_candidates)
452            };
453
454            for m in &near_dupe_matches {
455                if !m.query_span().is_empty() {
456                    let span = m.query_span().clone();
457                    query.subtract(&span);
458                    matched_qspans.push(span);
459                }
460            }
461
462            seq_all_matches.extend(near_dupe_matches);
463        }
464    }
465
466    Ok(seq_all_matches)
467}
468
469fn collect_regular_seq_matches(
470    index: &index::LicenseIndex,
471    query: &Query<'_>,
472    matched_qspans: &[models::PositionSpan],
473    candidate_contained_matches: &[LicenseMatch],
474    deadline: Option<Instant>,
475) -> Result<Vec<LicenseMatch>, LicenseDetectionError> {
476    let mut seq_all_matches = Vec::new();
477
478    for (query_run_index, query_run) in query.query_runs().into_iter().enumerate() {
479        if query_run_index % 8 == 0 {
480            ensure_within_deadline(deadline)?;
481        }
482
483        if !query_run.is_matchable(false, matched_qspans) {
484            continue;
485        }
486
487        let candidates = if deadline.is_some() {
488            select_seq_candidates_with_deadline(
489                index,
490                &query_run,
491                false,
492                MAX_REGULAR_SEQ_CANDIDATES,
493                deadline,
494            )?
495        } else {
496            self::seq_match::select_seq_candidates(
497                index,
498                &query_run,
499                false,
500                MAX_REGULAR_SEQ_CANDIDATES,
501            )
502        };
503        if !candidates.is_empty() {
504            let matches = if deadline.is_some() {
505                seq_match_with_candidates_and_deadline(index, &query_run, &candidates, deadline)?
506            } else {
507                self::seq_match::seq_match_with_candidates(index, &query_run, &candidates)
508            };
509            seq_all_matches.extend(matches);
510        }
511    }
512
513    let merged_seq = merge_overlapping_matches(&seq_all_matches);
514    let filtered_same_expression =
515        filter_redundant_same_expression_seq_containers(merged_seq, candidate_contained_matches);
516    Ok(filter_redundant_low_coverage_composite_seq_wrappers(
517        filtered_same_expression,
518        candidate_contained_matches,
519    ))
520}
521
522impl LicenseDetectionEngine {
523    /// Create a new license detection engine from a pre-built license index.
524    ///
525    /// This is an internal constructor used by `from_directory()` and `from_embedded()`.
526    /// It builds the SPDX mapping from the licenses in the index.
527    fn from_index(
528        index: index::LicenseIndex,
529        spdx_license_list_version: Option<String>,
530        license_index_provenance: Option<LicenseIndexProvenance>,
531    ) -> Result<Self> {
532        let mut license_vec: Vec<_> = index.licenses_by_key.values().cloned().collect();
533        license_vec.sort_by(|a, b| a.key.cmp(&b.key));
534        let spdx_mapping = build_spdx_mapping(&license_vec);
535
536        Ok(Self {
537            index: Arc::new(index),
538            spdx_mapping,
539            spdx_license_list_version,
540            license_index_provenance,
541        })
542    }
543
544    #[cfg(test)]
545    pub(crate) fn from_test_index(index: index::LicenseIndex) -> Self {
546        Self::from_index(index, None, None).expect("test index should build license engine")
547    }
548
549    #[cfg(test)]
550    pub(crate) fn from_test_index_with_provenance(
551        index: index::LicenseIndex,
552        license_index_provenance: LicenseIndexProvenance,
553    ) -> Self {
554        Self::from_index(index, None, Some(license_index_provenance))
555            .expect("test index should build license engine")
556    }
557
558    /// Create a new license detection engine from the embedded license index.
559    ///
560    /// Convenience method that uses the default Provenant cache root and does
561    /// not force a reindex.
562    pub fn from_embedded() -> Result<Self> {
563        let cache_config =
564            LicenseCacheConfig::new(LicenseCacheConfig::default_root_dir(), false, true);
565        Self::from_embedded_with_cache(&cache_config)
566    }
567
568    /// Create a new license detection engine from the embedded license index.
569    ///
570    /// This method loads the build-time embedded license artifact and constructs
571    /// the runtime license index. This eliminates the runtime dependency on the
572    /// ScanCode rules directory.
573    ///
574    /// If a valid cache exists (matching fingerprint), the index is loaded from
575    /// the rkyv cache file instead of being rebuilt from scratch.
576    ///
577    /// # Arguments
578    /// * `cache_config` - Cache configuration (directory and reindex flag)
579    ///
580    /// # Returns
581    /// A Result containing the engine or an error
582    pub fn from_embedded_with_cache(cache_config: &LicenseCacheConfig) -> Result<Self> {
583        let artifact_bytes = include_bytes!("../../resources/license_detection/license_index.zst");
584        let fingerprint = compute_artifact_fingerprint(artifact_bytes);
585        let artifact_metadata = load_embedded_artifact_metadata_from_bytes(artifact_bytes)
586            .map_err(|e| {
587                anyhow::anyhow!("Failed to load embedded license artifact metadata: {}", e)
588            })?;
589        debug_assert_eq!(
590            artifact_metadata.license_index_provenance.source,
591            EMBEDDED_LICENSE_INDEX_SOURCE
592        );
593        let spdx_version = Some(artifact_metadata.spdx_license_list_version.clone());
594        let provenance = Some(artifact_metadata.license_index_provenance.clone());
595
596        if !cache_config.reindex {
597            let start = Instant::now();
598            if let Some(cached) =
599                load_cached_index(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)?
600            {
601                eprintln!(
602                    "License index loaded from rkyv cache in {:.2}s",
603                    start.elapsed().as_secs_f64()
604                );
605                return Self::from_index(cached, spdx_version, provenance);
606            }
607        } else {
608            delete_cache(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)?;
609        }
610
611        let snapshot = load_loader_snapshot_from_bytes(artifact_bytes)
612            .map_err(|e| anyhow::anyhow!("Failed to load embedded license index: {}", e))?;
613        let spdx_version = Some(snapshot.metadata.spdx_license_list_version.clone());
614        let provenance = Some(snapshot.metadata.license_index_provenance.clone());
615
616        let start = Instant::now();
617        let index = build_index_from_loaded(snapshot.rules, snapshot.licenses, false);
618        eprintln!(
619            "License index built from embedded artifact in {:.2}s",
620            start.elapsed().as_secs_f64()
621        );
622
623        let mut index = index;
624        index.spdx_license_list_version = spdx_version.clone();
625        if let Err(e) = save_cached_index(
626            cache_config,
627            LicenseCacheNamespace::Embedded,
628            &index,
629            &fingerprint,
630        ) {
631            eprintln!("Warning: failed to save license index cache: {}", e);
632        } else if let Some(size) =
633            cache_file_size(cache_config, LicenseCacheNamespace::Embedded, &fingerprint)
634        {
635            eprintln!(
636                "License index cache saved ({:.1} MB)",
637                size as f64 / 1_048_576.0
638            );
639        }
640
641        Self::from_index(index, spdx_version, provenance)
642    }
643
644    /// Create a new license detection engine from a license dataset root.
645    ///
646    /// Convenience method that uses the default Provenant cache root and does
647    /// not force a reindex.
648    pub fn from_directory(rules_path: &Path) -> Result<Self> {
649        let cache_config =
650            LicenseCacheConfig::new(LicenseCacheConfig::default_root_dir(), false, true);
651        Self::from_directory_with_cache(rules_path, &cache_config)
652    }
653
654    /// Create a new license detection engine from a directory of license rules.
655    ///
656    /// If a valid cache exists (matching fingerprint of the dataset), the index is
657    /// loaded from the rkyv cache file instead of being rebuilt from scratch.
658    ///
659    /// # Arguments
660    /// * `rules_path` - Path to dataset root containing rules/ and licenses/
661    /// * `cache_config` - Cache configuration (directory and reindex flag)
662    ///
663    /// # Returns
664    /// A Result containing the engine or an error
665    pub fn from_directory_with_cache(
666        rules_path: &Path,
667        cache_config: &LicenseCacheConfig,
668    ) -> Result<Self> {
669        let LoadedLicenseDataset {
670            manifest,
671            rules: loaded_rules,
672            licenses: loaded_licenses,
673        } = load_license_dataset_from_root(rules_path)?;
674
675        let fingerprint = compute_rules_fingerprint(&loaded_rules, &loaded_licenses)?;
676        let provenance = Some(LicenseIndexProvenance {
677            source: CUSTOM_LICENSE_DATASET_SOURCE.to_string(),
678            dataset_fingerprint: compute_dataset_fingerprint_string(
679                &loaded_rules,
680                &loaded_licenses,
681            )?,
682            ignored_rules: vec![],
683            ignored_licenses: vec![],
684            ignored_rules_due_to_licenses: vec![],
685            added_rules: vec![],
686            replaced_rules: vec![],
687            added_licenses: vec![],
688            replaced_licenses: vec![],
689        });
690
691        if !cache_config.reindex {
692            if let Some(cached) = load_cached_index(
693                cache_config,
694                LicenseCacheNamespace::CustomRules,
695                &fingerprint,
696            )? {
697                let start = Instant::now();
698                eprintln!(
699                    "License index loaded from rkyv cache in {:.2}s",
700                    start.elapsed().as_secs_f64()
701                );
702                return Self::from_index(
703                    cached,
704                    Some(manifest.spdx_license_list_version),
705                    provenance,
706                );
707            }
708        } else {
709            delete_cache(
710                cache_config,
711                LicenseCacheNamespace::CustomRules,
712                &fingerprint,
713            )?;
714        }
715
716        let start = Instant::now();
717        let index = build_index_from_loaded(loaded_rules, loaded_licenses, false);
718        eprintln!(
719            "License index built from custom dataset in {:.2}s",
720            start.elapsed().as_secs_f64()
721        );
722
723        if let Err(e) = save_cached_index(
724            cache_config,
725            LicenseCacheNamespace::CustomRules,
726            &index,
727            &fingerprint,
728        ) {
729            eprintln!("Warning: failed to save license index cache: {}", e);
730        } else if let Some(size) = cache_file_size(
731            cache_config,
732            LicenseCacheNamespace::CustomRules,
733            &fingerprint,
734        ) {
735            eprintln!(
736                "License index cache saved ({:.1} MB)",
737                size as f64 / 1_048_576.0
738            );
739        }
740
741        Self::from_index(index, Some(manifest.spdx_license_list_version), provenance)
742    }
743
744    pub fn embedded_spdx_license_list_version() -> Result<String> {
745        let artifact_bytes = include_bytes!("../../resources/license_detection/license_index.zst");
746        Ok(load_embedded_artifact_metadata_from_bytes(artifact_bytes)
747            .map_err(|e| {
748                anyhow::anyhow!("Failed to load embedded license artifact metadata: {}", e)
749            })?
750            .spdx_license_list_version)
751    }
752
753    pub fn detect_with_kind(
754        &self,
755        text: &str,
756        unknown_licenses: bool,
757        binary_derived: bool,
758    ) -> Result<Vec<LicenseDetection>> {
759        self.detect_with_kind_with_score_and_deadline_with_options(
760            text,
761            unknown_licenses,
762            binary_derived,
763            true,
764            0.0,
765            None,
766        )
767        .map_err(Into::into)
768    }
769
770    pub fn detect_with_kind_with_score(
771        &self,
772        text: &str,
773        unknown_licenses: bool,
774        binary_derived: bool,
775        min_score: f32,
776    ) -> Result<Vec<LicenseDetection>> {
777        self.detect_with_kind_with_score_and_deadline_with_options(
778            text,
779            unknown_licenses,
780            binary_derived,
781            true,
782            min_score,
783            None,
784        )
785        .map_err(Into::into)
786    }
787
788    pub(crate) fn detect_with_kind_with_score_and_deadline_with_options(
789        &self,
790        text: &str,
791        unknown_licenses: bool,
792        binary_derived: bool,
793        enable_sequence_matching: bool,
794        min_score: f32,
795        deadline: Option<Instant>,
796    ) -> Result<Vec<LicenseDetection>, LicenseDetectionError> {
797        ensure_within_deadline(deadline)?;
798        let clean_text = strip_utf8_bom_str(text);
799
800        let content = truncate_detection_text(clean_text);
801
802        ensure_within_deadline(deadline)?;
803        let mut query = if deadline.is_some() {
804            Query::from_extracted_text_with_deadline(
805                content,
806                &self.index,
807                binary_derived,
808                deadline,
809            )?
810        } else {
811            Query::from_extracted_text(content, &self.index, binary_derived)?
812        };
813        let whole_query_run = query.whole_query_run();
814
815        let mut all_matches = Vec::new();
816        let mut candidate_contained_matches = Vec::new();
817        let mut aho_extra_matchables = PositionSet::new();
818        let mut matched_qspans: Vec<models::PositionSpan> = Vec::new();
819
820        // Phase 1a: Hash matching
821        // Python returns immediately if hash matches found (index.py:987-991)
822        {
823            ensure_within_deadline(deadline)?;
824            let hash_matches = hash_match(&self.index, &whole_query_run);
825
826            if !hash_matches.is_empty() {
827                let mut matches = hash_matches;
828                sort_matches_by_line(&mut matches);
829
830                let groups = split_groups_across_frontmatter_boundary(
831                    group_matches_by_region(&matches),
832                    Some(content),
833                );
834                let detections: Vec<LicenseDetection> = groups
835                    .iter()
836                    .map(|group| {
837                        let mut detection = empty_detection();
838                        populate_detection_from_group_with_spdx(
839                            &mut detection,
840                            group,
841                            &self.spdx_mapping,
842                            Some(content),
843                        );
844                        detection
845                    })
846                    .collect();
847
848                return Ok(post_process_detections(detections, min_score));
849            }
850        }
851
852        // Phase 1b: SPDX-LID matching
853        {
854            ensure_within_deadline(deadline)?;
855            let spdx_matches = spdx_lid_match(&self.index, &query);
856            subtract_spdx_match_qspans(
857                &mut query,
858                &mut matched_qspans,
859                &mut aho_extra_matchables,
860                &spdx_matches,
861            );
862            all_matches.extend(spdx_matches);
863        }
864
865        // Phase 1c: Aho-Corasick matching
866        {
867            ensure_within_deadline(deadline)?;
868            let aho_matches = if aho_extra_matchables.is_empty() {
869                if deadline.is_some() {
870                    aho_match::aho_match_with_deadline(&self.index, &whole_query_run, deadline)?
871                } else {
872                    aho_match(&self.index, &whole_query_run)
873                }
874            } else {
875                if deadline.is_some() {
876                    aho_match::aho_match_with_extra_matchables(
877                        &self.index,
878                        &whole_query_run,
879                        Some(&aho_extra_matchables),
880                        deadline,
881                    )?
882                } else {
883                    aho_match::aho_match_with_extra_matchables(
884                        &self.index,
885                        &whole_query_run,
886                        Some(&aho_extra_matchables),
887                        None,
888                    )?
889                }
890            };
891
892            // Python's get_exact_matches() calls refine_matches with merge=False
893            // This applies quality filters including required phrase filtering
894            let refined_aho = match_refine::refine_aho_matches(&self.index, aho_matches, &query);
895            candidate_contained_matches.extend(refined_aho.clone());
896            let (merged_aho, _) = merge_and_prepare_aho_matches(
897                &self.index,
898                &mut query,
899                &mut matched_qspans,
900                &refined_aho,
901            );
902            all_matches.extend(merged_aho);
903
904            let whole_query_followup = collect_whole_query_exact_followup_matches(
905                &self.index,
906                &mut query,
907                &mut matched_qspans,
908                &whole_query_run,
909                enable_sequence_matching,
910                deadline,
911            )?;
912            all_matches.extend(whole_query_followup);
913
914            if enable_sequence_matching {
915                let merged_seq = collect_regular_seq_matches(
916                    &self.index,
917                    &query,
918                    &matched_qspans,
919                    &candidate_contained_matches,
920                    deadline,
921                )?;
922                all_matches.extend(merged_seq);
923            }
924        }
925
926        // Step 1: Initial refine WITHOUT false positive filtering
927        // Python: refine_matches with filter_false_positive=False (index.py:1073-1080)
928        ensure_within_deadline(deadline)?;
929        let merged_matches =
930            refine_matches_without_false_positive_filter(&self.index, all_matches, &query);
931
932        // Step 2: Unknown detection and weak match handling
933        // Python: index.py:1079-1118 - only runs when unknown_licenses=True
934        let refined_matches = if unknown_licenses {
935            // Split weak from good - Python: index.py:1083
936            let (good_matches, weak_matches) = split_weak_matches(&self.index, &merged_matches);
937
938            // Unknown detection on uncovered regions - Python: index.py:1093-1114
939            let unknown_matches = unknown_match(&self.index, &query, &good_matches);
940            let filtered_unknown =
941                filter_invalid_contained_unknown_matches(&unknown_matches, &good_matches);
942
943            let mut all_matches = good_matches;
944            all_matches.extend(filtered_unknown);
945            // reinject weak matches and let refine matches keep the bests
946            // Python: index.py:1117-1118
947            all_matches.extend(weak_matches);
948            all_matches
949        } else {
950            merged_matches
951        };
952
953        // Step 5: Final refine WITH false positive filtering - Python: index.py:1130-1145
954        ensure_within_deadline(deadline)?;
955        let refined = refine_matches(&self.index, refined_matches, &query);
956
957        let mut sorted = refined;
958        sort_matches_by_line(&mut sorted);
959
960        let groups = split_groups_across_frontmatter_boundary(
961            group_matches_by_region(&sorted),
962            Some(content),
963        );
964
965        let detections: Vec<LicenseDetection> = groups
966            .iter()
967            .map(|group| {
968                let mut detection = empty_detection();
969                populate_detection_from_group_with_spdx(
970                    &mut detection,
971                    group,
972                    &self.spdx_mapping,
973                    Some(content),
974                );
975                detection
976            })
977            .collect();
978
979        let detections = post_process_detections(detections, min_score);
980
981        ensure_within_deadline(deadline)?;
982        Ok(detections)
983    }
984
985    pub fn detect_with_kind_and_source(
986        &self,
987        text: &str,
988        unknown_licenses: bool,
989        binary_derived: bool,
990        source_path: &str,
991    ) -> Result<Vec<LicenseDetection>> {
992        self.detect_with_kind_and_source_with_deadline_and_options(
993            text,
994            unknown_licenses,
995            binary_derived,
996            source_path,
997            true,
998            None,
999        )
1000        .map_err(Into::into)
1001    }
1002
1003    pub(crate) fn detect_with_kind_and_source_with_deadline_and_options(
1004        &self,
1005        text: &str,
1006        unknown_licenses: bool,
1007        binary_derived: bool,
1008        source_path: &str,
1009        enable_sequence_matching: bool,
1010        deadline: Option<Instant>,
1011    ) -> Result<Vec<LicenseDetection>, LicenseDetectionError> {
1012        let mut detections = self.detect_with_kind_with_score_and_deadline_with_options(
1013            text,
1014            unknown_licenses,
1015            binary_derived,
1016            enable_sequence_matching,
1017            0.0,
1018            deadline,
1019        )?;
1020        attach_source_path_to_detections(&mut detections, source_path);
1021        Ok(detections)
1022    }
1023
1024    pub fn detect_with_kind_and_source_with_score(
1025        &self,
1026        text: &str,
1027        unknown_licenses: bool,
1028        binary_derived: bool,
1029        source_path: &str,
1030        min_score: f32,
1031    ) -> Result<Vec<LicenseDetection>> {
1032        self.detect_with_kind_and_source_with_score_options(
1033            text,
1034            unknown_licenses,
1035            binary_derived,
1036            source_path,
1037            true,
1038            min_score,
1039        )
1040    }
1041
1042    pub fn detect_with_kind_and_source_with_options(
1043        &self,
1044        text: &str,
1045        unknown_licenses: bool,
1046        binary_derived: bool,
1047        source_path: &str,
1048        enable_sequence_matching: bool,
1049    ) -> Result<Vec<LicenseDetection>> {
1050        self.detect_with_kind_and_source_with_score_options(
1051            text,
1052            unknown_licenses,
1053            binary_derived,
1054            source_path,
1055            enable_sequence_matching,
1056            0.0,
1057        )
1058    }
1059
1060    pub fn detect_with_kind_and_source_with_score_options(
1061        &self,
1062        text: &str,
1063        unknown_licenses: bool,
1064        binary_derived: bool,
1065        source_path: &str,
1066        enable_sequence_matching: bool,
1067        min_score: f32,
1068    ) -> Result<Vec<LicenseDetection>> {
1069        let mut detections = self.detect_with_kind_with_score_and_deadline_with_options(
1070            text,
1071            unknown_licenses,
1072            binary_derived,
1073            enable_sequence_matching,
1074            min_score,
1075            None,
1076        )?;
1077        attach_source_path_to_detections(&mut detections, source_path);
1078        Ok(detections)
1079    }
1080
1081    /// Detect licenses and return raw matches (like Python's idx.match()).
1082    ///
1083    /// This is primarily used by golden tests and maintenance tooling that need
1084    /// raw match sequences before grouping or post-processing into detections.
1085    #[cfg(any(test, feature = "golden-tests"))]
1086    pub fn detect_matches_with_kind(
1087        &self,
1088        text: &str,
1089        unknown_licenses: bool,
1090        binary_derived: bool,
1091    ) -> Result<Vec<LicenseMatch>> {
1092        let clean_text = strip_utf8_bom_str(text);
1093
1094        let content = truncate_detection_text(clean_text);
1095
1096        let mut query = Query::from_extracted_text(content, &self.index, binary_derived)?;
1097        let whole_query_run = query.whole_query_run();
1098
1099        let mut all_matches = Vec::new();
1100        let mut candidate_contained_matches = Vec::new();
1101        let mut aho_extra_matchables = PositionSet::new();
1102        let mut matched_qspans: Vec<models::PositionSpan> = Vec::new();
1103
1104        // Phase 1a: Hash matching
1105        {
1106            let hash_matches = hash_match(&self.index, &whole_query_run);
1107
1108            if !hash_matches.is_empty() {
1109                let mut matches = hash_matches;
1110                sort_matches_by_line(&mut matches);
1111                return Ok(matches);
1112            }
1113        }
1114
1115        // Phase 1b: SPDX-LID matching
1116        {
1117            let spdx_matches = spdx_lid_match(&self.index, &query);
1118            subtract_spdx_match_qspans(
1119                &mut query,
1120                &mut matched_qspans,
1121                &mut aho_extra_matchables,
1122                &spdx_matches,
1123            );
1124            all_matches.extend(spdx_matches);
1125        }
1126
1127        // Phase 1c: Aho-Corasick matching
1128        {
1129            let aho_matches = if aho_extra_matchables.is_empty() {
1130                aho_match(&self.index, &whole_query_run)
1131            } else {
1132                aho_match::aho_match_with_extra_matchables(
1133                    &self.index,
1134                    &whole_query_run,
1135                    Some(&aho_extra_matchables),
1136                    None,
1137                )?
1138            };
1139            let refined_aho = match_refine::refine_aho_matches(&self.index, aho_matches, &query);
1140            candidate_contained_matches.extend(refined_aho.clone());
1141            let (merged_aho, _) = merge_and_prepare_aho_matches(
1142                &self.index,
1143                &mut query,
1144                &mut matched_qspans,
1145                &refined_aho,
1146            );
1147            all_matches.extend(merged_aho);
1148
1149            let whole_query_followup = collect_whole_query_exact_followup_matches(
1150                &self.index,
1151                &mut query,
1152                &mut matched_qspans,
1153                &whole_query_run,
1154                true,
1155                None,
1156            )?;
1157            all_matches.extend(whole_query_followup);
1158
1159            let merged_seq = collect_regular_seq_matches(
1160                &self.index,
1161                &query,
1162                &matched_qspans,
1163                &candidate_contained_matches,
1164                None,
1165            )?;
1166            all_matches.extend(merged_seq);
1167        }
1168
1169        // Step 1: Initial refine WITHOUT false positive filtering
1170        let merged_matches =
1171            refine_matches_without_false_positive_filter(&self.index, all_matches, &query);
1172
1173        // Step 2: Unknown detection and weak match handling
1174        let refined_matches = if unknown_licenses {
1175            let (good_matches, weak_matches) = split_weak_matches(&self.index, &merged_matches);
1176            let unknown_matches = unknown_match(&self.index, &query, &good_matches);
1177            let filtered_unknown =
1178                filter_invalid_contained_unknown_matches(&unknown_matches, &good_matches);
1179
1180            let mut all_matches = good_matches;
1181            all_matches.extend(filtered_unknown);
1182            all_matches.extend(weak_matches);
1183            all_matches
1184        } else {
1185            merged_matches
1186        };
1187
1188        // Step 3: Final refine WITH false positive filtering - Python: index.py:1130-1145
1189        let refined = refine_matches(&self.index, refined_matches, &query);
1190
1191        let mut sorted = refined;
1192        sort_matches_by_line(&mut sorted);
1193
1194        // Return raw matches (NOT grouped) - this is Python's idx.match() behavior
1195        Ok(sorted)
1196    }
1197
1198    /// Get a reference to the license index.
1199    pub fn index(&self) -> &index::LicenseIndex {
1200        &self.index
1201    }
1202
1203    pub fn spdx_license_list_version(&self) -> Option<&str> {
1204        self.spdx_license_list_version.as_deref()
1205    }
1206
1207    pub fn license_index_provenance(&self) -> Option<&LicenseIndexProvenance> {
1208        self.license_index_provenance.as_ref()
1209    }
1210
1211    /// Get a reference to the SPDX mapping.
1212    #[cfg(test)]
1213    pub fn spdx_mapping(&self) -> &SpdxMapping {
1214        &self.spdx_mapping
1215    }
1216}
1217
1218pub fn detect_scancode_spdx_license_list_version(search_path: &Path) -> Result<Option<String>> {
1219    for ancestor in search_path.ancestors() {
1220        let candidate = ancestor.join("scancode_config.py");
1221        if candidate.is_file() {
1222            let config = fs::read_to_string(&candidate)?;
1223            return Ok(parse_scancode_spdx_license_list_version(&config));
1224        }
1225    }
1226
1227    Ok(None)
1228}
1229
1230fn parse_scancode_spdx_license_list_version(config: &str) -> Option<String> {
1231    config.lines().find_map(|line| {
1232        let trimmed = line.trim();
1233        let (_, value) = trimmed.split_once('=')?;
1234        (trimmed.starts_with("spdx_license_list_version")).then(|| {
1235            value
1236                .trim()
1237                .trim_matches('"')
1238                .trim_matches('\'')
1239                .to_string()
1240        })
1241    })
1242}
1243
1244#[cfg(test)]
1245mod tests;