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