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