Skip to main content

provenant/license_detection/
mod.rs

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