Skip to main content

provenant/license_detection/
mod.rs

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