Skip to main content

provenant/license_detection/
mod.rs

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