Skip to main content

sbom_tools/matching/
index.rs

1//! Component index for efficient matching.
2//!
3//! This module provides indexing structures to reduce O(n²) fuzzy comparisons
4//! by pre-normalizing and bucketing components for efficient candidate lookup.
5
6use crate::model::{CanonicalId, Component, NormalizedSbom};
7use rayon::prelude::*;
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11/// Pre-computed normalized data for a component.
12#[derive(Debug, Clone)]
13pub struct NormalizedEntry {
14    /// Normalized PURL (if available)
15    pub normalized_purl: Option<String>,
16    /// Normalized component name (lowercase, separators normalized)
17    pub normalized_name: String,
18    /// Length of the normalized name (for length-based filtering)
19    pub name_length: usize,
20    /// Ecosystem extracted from PURL or inferred
21    pub ecosystem: Option<String>,
22    /// First 3 characters of normalized name (for prefix bucketing)
23    pub prefix: String,
24    /// Trigrams (3-character substrings) for fuzzy matching
25    pub trigrams: Vec<String>,
26}
27
28/// Index for efficient component candidate lookup.
29///
30/// Reduces the O(n·m) comparison to O(n·k) where k << m by:
31/// 1. Grouping components by ecosystem
32/// 2. Bucketing by name prefix
33/// 3. Bucketing by trigrams (3-char substrings) for fuzzy matching
34/// 4. Pre-normalizing names for fast comparison
35///
36/// Uses `Arc<CanonicalId>` internally for efficient cloning during index building.
37pub struct ComponentIndex {
38    /// Ecosystem -> list of component IDs in that ecosystem
39    by_ecosystem: HashMap<String, Vec<Arc<CanonicalId>>>,
40    /// Normalized name prefix (first 3 chars) -> component IDs
41    by_prefix: HashMap<String, Vec<Arc<CanonicalId>>>,
42    /// Trigram -> list of component IDs containing that trigram
43    by_trigram: HashMap<String, Vec<Arc<CanonicalId>>>,
44    /// Pre-computed normalized data for each component
45    entries: HashMap<Arc<CanonicalId>, NormalizedEntry>,
46    /// All component IDs (for fallback)
47    all_ids: Vec<Arc<CanonicalId>>,
48}
49
50impl ComponentIndex {
51    /// Build an index from an SBOM.
52    ///
53    /// Uses `Arc<CanonicalId>` internally to avoid expensive cloning of IDs
54    /// across multiple index structures.
55    #[must_use]
56    pub fn build(sbom: &NormalizedSbom) -> Self {
57        let mut by_ecosystem: HashMap<String, Vec<Arc<CanonicalId>>> = HashMap::new();
58        let mut by_prefix: HashMap<String, Vec<Arc<CanonicalId>>> = HashMap::new();
59        let mut by_trigram: HashMap<String, Vec<Arc<CanonicalId>>> = HashMap::new();
60        let mut entries: HashMap<Arc<CanonicalId>, NormalizedEntry> = HashMap::new();
61        let mut all_ids: Vec<Arc<CanonicalId>> = Vec::new();
62
63        for (id, comp) in &sbom.components {
64            let entry = Self::normalize_component(comp);
65            // Wrap ID in Arc once - all subsequent "clones" are cheap reference count increments
66            let arc_id = Arc::new(id.clone());
67
68            // Index by ecosystem
69            if let Some(ref eco) = entry.ecosystem {
70                by_ecosystem
71                    .entry(eco.clone())
72                    .or_default()
73                    .push(Arc::clone(&arc_id));
74            }
75
76            // Index by name prefix
77            if !entry.prefix.is_empty() {
78                by_prefix
79                    .entry(entry.prefix.clone())
80                    .or_default()
81                    .push(Arc::clone(&arc_id));
82            }
83
84            // Index by trigrams
85            for trigram in &entry.trigrams {
86                by_trigram
87                    .entry(trigram.clone())
88                    .or_default()
89                    .push(Arc::clone(&arc_id));
90            }
91
92            entries.insert(Arc::clone(&arc_id), entry);
93            all_ids.push(arc_id);
94        }
95
96        Self {
97            by_ecosystem,
98            by_prefix,
99            by_trigram,
100            entries,
101            all_ids,
102        }
103    }
104
105    /// Normalize a component for indexing.
106    #[must_use]
107    pub fn normalize_component(comp: &Component) -> NormalizedEntry {
108        // Extract ecosystem from PURL
109        let (ecosystem, normalized_purl) = comp.identifiers.purl.as_ref().map_or_else(
110            || {
111                // Try to infer ecosystem from component type or other fields
112                // Convert Ecosystem enum to String for consistent comparison
113                (
114                    comp.ecosystem
115                        .as_ref()
116                        .map(std::string::ToString::to_string),
117                    None,
118                )
119            },
120            |purl| {
121                let eco = Self::extract_ecosystem(purl);
122                let normalized = Self::normalize_purl(purl);
123                (eco, Some(normalized))
124            },
125        );
126
127        // Normalize name
128        let normalized_name = Self::normalize_name(&comp.name, ecosystem.as_deref());
129        let name_length = normalized_name.len();
130        let prefix = normalized_name.chars().take(3).collect::<String>();
131        let trigrams = Self::compute_trigrams(&normalized_name);
132
133        NormalizedEntry {
134            normalized_purl,
135            normalized_name,
136            name_length,
137            ecosystem,
138            prefix,
139            trigrams,
140        }
141    }
142
143    /// Compute trigrams (3-character substrings) for a normalized name.
144    ///
145    /// Trigrams enable finding matches where only the middle or end differs,
146    /// which prefix-based indexing would miss.
147    fn compute_trigrams(name: &str) -> Vec<String> {
148        if name.len() < 3 {
149            // For very short names, use the name itself as a "trigram"
150            return if name.is_empty() {
151                vec![]
152            } else {
153                vec![name.to_string()]
154            };
155        }
156
157        // Fast path: ASCII-only names (common for package names)
158        // Avoids intermediate Vec<char> allocation
159        if name.is_ascii() {
160            return name
161                .as_bytes()
162                .windows(3)
163                .map(|w| {
164                    // SAFETY: name.is_ascii() was checked above, so all bytes are valid
165                    // single-byte UTF-8 characters. Any 3-byte window is valid UTF-8.
166                    unsafe { std::str::from_utf8_unchecked(w) }.to_string()
167                })
168                .collect();
169        }
170
171        // Slow path: Unicode names - need to collect chars first for windows()
172        let chars: Vec<char> = name.chars().collect();
173        if chars.len() < 3 {
174            return vec![name.to_string()];
175        }
176
177        chars
178            .windows(3)
179            .map(|w| w.iter().collect::<String>())
180            .collect()
181    }
182
183    /// Extract ecosystem from a PURL.
184    fn extract_ecosystem(purl: &str) -> Option<String> {
185        // PURL format: pkg:ecosystem/namespace/name@version
186        if let Some(rest) = purl.strip_prefix("pkg:")
187            && let Some(slash_pos) = rest.find('/')
188        {
189            return Some(rest[..slash_pos].to_lowercase());
190        }
191        None
192    }
193
194    /// Normalize a PURL for comparison.
195    fn normalize_purl(purl: &str) -> String {
196        // Basic normalization: lowercase and strip version qualifiers
197        let purl_lower = purl.to_lowercase();
198        // Remove version part for comparison if present
199        if let Some(at_pos) = purl_lower.rfind('@') {
200            purl_lower[..at_pos].to_string()
201        } else {
202            purl_lower
203        }
204    }
205
206    /// Normalize a component name for comparison.
207    ///
208    /// Applies ecosystem-specific normalization rules:
209    /// - `PyPI`: underscores, hyphens, dots are all equivalent (converted to hyphen)
210    /// - Cargo: hyphens and underscores are equivalent (converted to underscore)
211    /// - npm: lowercase only, preserves scope
212    /// - Default: lowercase with underscore to hyphen conversion
213    ///
214    /// This is also used by LSH for consistent shingle computation.
215    #[must_use]
216    pub fn normalize_name(name: &str, ecosystem: Option<&str>) -> String {
217        let mut normalized = name.to_lowercase();
218
219        // Apply ecosystem-specific normalization
220        match ecosystem {
221            Some("pypi") => {
222                // Python: underscores, hyphens, dots are equivalent
223                normalized = normalized.replace(['_', '.'], "-");
224            }
225            Some("cargo") => {
226                // Rust: hyphens and underscores are equivalent
227                normalized = normalized.replace('-', "_");
228            }
229            Some("npm") => {
230                // npm: already lowercase, preserve scope
231                // Nothing special needed
232            }
233            _ => {
234                // Default: just lowercase, normalize common separators
235                normalized = normalized.replace('_', "-");
236            }
237        }
238
239        // Collapse multiple separators
240        while normalized.contains("--") {
241            normalized = normalized.replace("--", "-");
242        }
243
244        normalized
245    }
246
247    /// Get normalized entry for a component.
248    #[must_use]
249    pub fn get_entry(&self, id: &CanonicalId) -> Option<&NormalizedEntry> {
250        // Arc<T>: Borrow<T> allows HashMap lookup with &CanonicalId
251        self.entries.get(id)
252    }
253
254    /// Get components by ecosystem.
255    ///
256    /// Returns cloned `CanonicalIds` for API stability. The internal storage uses Arc
257    /// to avoid expensive cloning during index building.
258    #[must_use]
259    pub fn get_by_ecosystem(&self, ecosystem: &str) -> Option<Vec<CanonicalId>> {
260        self.by_ecosystem
261            .get(ecosystem)
262            .map(|v| v.iter().map(|arc| (**arc).clone()).collect())
263    }
264
265    /// Find candidate matches for a component.
266    ///
267    /// Returns a list of component IDs that are likely matches, ordered by likelihood.
268    /// Uses ecosystem and prefix-based filtering to reduce candidates.
269    ///
270    /// Returns cloned `CanonicalIds` for API stability. The internal storage uses Arc
271    /// to avoid expensive cloning during index building.
272    #[must_use]
273    pub fn find_candidates(
274        &self,
275        source_id: &CanonicalId,
276        source_entry: &NormalizedEntry,
277        max_candidates: usize,
278        max_length_diff: usize,
279    ) -> Vec<CanonicalId> {
280        let mut candidates: Vec<Arc<CanonicalId>> = Vec::new();
281        let mut seen: HashSet<Arc<CanonicalId>> = HashSet::new();
282
283        // Pre-compute the source trigram set once for ranking within buckets.
284        let source_trigrams: HashSet<&str> =
285            source_entry.trigrams.iter().map(String::as_str).collect();
286
287        // Priority 1: Same ecosystem candidates.
288        //
289        // Rank by trigram overlap with the source *before* the global
290        // truncate(max_candidates), so the true match survives when an
291        // ecosystem bucket is larger than max_candidates (insertion order would
292        // otherwise cut the real match purely by where it landed in the SBOM).
293        if let Some(ref eco) = source_entry.ecosystem
294            && let Some(ids) = self.by_ecosystem.get(eco)
295        {
296            let mut ranked: Vec<(usize, &Arc<CanonicalId>)> = Vec::new();
297            for id in ids {
298                if id.as_ref() != source_id
299                    && !seen.contains(id)
300                    && let Some(entry) = self.entries.get(id.as_ref())
301                {
302                    let len_diff = (source_entry.name_length as i32 - entry.name_length as i32)
303                        .unsigned_abs() as usize;
304                    if len_diff <= max_length_diff {
305                        let overlap = entry
306                            .trigrams
307                            .iter()
308                            .filter(|t| source_trigrams.contains(t.as_str()))
309                            .count();
310                        ranked.push((overlap, id));
311                    }
312                }
313            }
314            // Higher overlap first; ties broken by ID for deterministic output.
315            // Only the top max_candidates survive the final truncate, so
316            // select-then-sort just that prefix instead of sorting (and
317            // Arc-cloning) the entire ecosystem bucket — for mono-ecosystem
318            // SBOMs the bucket is the whole component set and the full sort
319            // made candidate generation O(n² log n) per diff.
320            let rank_order = |a: &(usize, &Arc<CanonicalId>), b: &(usize, &Arc<CanonicalId>)| {
321                b.0.cmp(&a.0).then_with(|| a.1.value().cmp(b.1.value()))
322            };
323            let keep = max_candidates.min(ranked.len());
324            if keep > 0 {
325                if keep < ranked.len() {
326                    ranked.select_nth_unstable_by(keep - 1, rank_order);
327                    ranked.truncate(keep);
328                }
329                ranked.sort_by(rank_order);
330                for (_, id) in ranked {
331                    candidates.push(Arc::clone(id));
332                    seen.insert(Arc::clone(id));
333                }
334            }
335        }
336
337        // Priority 2: Same prefix candidates (cross-ecosystem fallback)
338        if candidates.len() < max_candidates
339            && !source_entry.prefix.is_empty()
340            && let Some(ids) = self.by_prefix.get(&source_entry.prefix)
341        {
342            for id in ids {
343                if id.as_ref() != source_id
344                    && !seen.contains(id)
345                    && let Some(entry) = self.entries.get(id.as_ref())
346                {
347                    let len_diff = (source_entry.name_length as i32 - entry.name_length as i32)
348                        .unsigned_abs() as usize;
349                    if len_diff <= max_length_diff {
350                        candidates.push(Arc::clone(id));
351                        seen.insert(Arc::clone(id));
352                    }
353                }
354                if candidates.len() >= max_candidates {
355                    break;
356                }
357            }
358        }
359
360        // Priority 3: Similar prefixes (1-char difference in prefix)
361        // Iterated in sorted prefix order so truncation is deterministic
362        if candidates.len() < max_candidates && source_entry.prefix.len() >= 2 {
363            let prefix_2 = &source_entry.prefix[..2.min(source_entry.prefix.len())];
364            let mut similar_prefixes: Vec<_> = self
365                .by_prefix
366                .iter()
367                .filter(|(prefix, _)| {
368                    prefix.starts_with(prefix_2) && *prefix != &source_entry.prefix
369                })
370                .collect();
371            similar_prefixes.sort_by(|a, b| a.0.cmp(b.0));
372
373            for (_prefix, ids) in similar_prefixes {
374                for id in ids {
375                    if id.as_ref() != source_id
376                        && !seen.contains(id)
377                        && let Some(entry) = self.entries.get(id.as_ref())
378                    {
379                        let len_diff = (source_entry.name_length as i32 - entry.name_length as i32)
380                            .unsigned_abs() as usize;
381                        if len_diff <= max_length_diff {
382                            candidates.push(Arc::clone(id));
383                            seen.insert(Arc::clone(id));
384                        }
385                    }
386                    if candidates.len() >= max_candidates {
387                        break;
388                    }
389                }
390                if candidates.len() >= max_candidates {
391                    break;
392                }
393            }
394        }
395
396        // Priority 4: Trigram-based matching (catches middle/end differences)
397        // Find components that share multiple trigrams with the source
398        if candidates.len() < max_candidates && !source_entry.trigrams.is_empty() {
399            // Count trigram overlap for each candidate
400            let mut trigram_scores: HashMap<Arc<CanonicalId>, usize> = HashMap::new();
401
402            for trigram in &source_entry.trigrams {
403                if let Some(ids) = self.by_trigram.get(trigram) {
404                    for id in ids {
405                        if id.as_ref() != source_id && !seen.contains(id) {
406                            *trigram_scores.entry(Arc::clone(id)).or_default() += 1;
407                        }
408                    }
409                }
410            }
411
412            // Require at least 2 shared trigrams (or 1 for very short names)
413            let min_shared = if source_entry.trigrams.len() <= 2 {
414                1
415            } else {
416                2
417            };
418
419            // Sort by trigram overlap count (descending), breaking ties by ID
420            // so truncation is deterministic
421            let mut scored: Vec<_> = trigram_scores
422                .into_iter()
423                .filter(|(_, count)| *count >= min_shared)
424                .collect();
425            scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.value().cmp(b.0.value())));
426
427            for (id, _score) in scored {
428                if candidates.len() >= max_candidates {
429                    break;
430                }
431                if let Some(entry) = self.entries.get(id.as_ref()) {
432                    let len_diff = (source_entry.name_length as i32 - entry.name_length as i32)
433                        .unsigned_abs() as usize;
434                    if len_diff <= max_length_diff {
435                        candidates.push(Arc::clone(&id));
436                        seen.insert(id);
437                    }
438                }
439            }
440        }
441
442        // Truncate to max_candidates and convert to owned CanonicalIds
443        candidates.truncate(max_candidates);
444        candidates.into_iter().map(|arc| (*arc).clone()).collect()
445    }
446
447    /// Get all component IDs (for fallback full scan).
448    ///
449    /// Returns cloned `CanonicalIds` for API stability.
450    #[must_use]
451    pub fn all_ids(&self) -> Vec<CanonicalId> {
452        self.all_ids.iter().map(|arc| (**arc).clone()).collect()
453    }
454
455    /// Get the number of indexed components.
456    #[must_use]
457    pub fn len(&self) -> usize {
458        self.entries.len()
459    }
460
461    /// Check if the index is empty.
462    #[must_use]
463    pub fn is_empty(&self) -> bool {
464        self.entries.is_empty()
465    }
466
467    /// Find candidates for multiple source components in parallel.
468    ///
469    /// This is significantly faster than calling `find_candidates` sequentially
470    /// for large SBOMs (1000+ components). Uses rayon for parallel iteration.
471    ///
472    /// Returns a vector of (`source_id`, candidates) pairs in the same order as input.
473    #[must_use]
474    pub fn find_candidates_parallel<'a>(
475        &self,
476        sources: &[(&'a CanonicalId, &NormalizedEntry)],
477        max_candidates: usize,
478        max_length_diff: usize,
479    ) -> Vec<(&'a CanonicalId, Vec<CanonicalId>)> {
480        sources
481            .par_iter()
482            .map(|(source_id, source_entry)| {
483                let candidates =
484                    self.find_candidates(source_id, source_entry, max_candidates, max_length_diff);
485                (*source_id, candidates)
486            })
487            .collect()
488    }
489
490    /// Find candidates for all components in another index in parallel.
491    ///
492    /// Useful for diffing two SBOMs: build an index from the new SBOM,
493    /// then find candidates for all components from the old SBOM.
494    #[must_use]
495    pub fn find_all_candidates_from(
496        &self,
497        other: &Self,
498        max_candidates: usize,
499        max_length_diff: usize,
500    ) -> Vec<(CanonicalId, Vec<CanonicalId>)> {
501        let sources: Vec<_> = other.entries.iter().collect();
502
503        sources
504            .par_iter()
505            .map(|(source_id, source_entry)| {
506                let candidates =
507                    self.find_candidates(source_id, source_entry, max_candidates, max_length_diff);
508                // Clone the inner CanonicalId from the Arc
509                ((*source_id).as_ref().clone(), candidates)
510            })
511            .collect::<Vec<_>>()
512    }
513
514    /// Get statistics about the index.
515    pub fn stats(&self) -> IndexStats {
516        let ecosystems = self.by_ecosystem.len();
517        let prefixes = self.by_prefix.len();
518        let trigrams = self.by_trigram.len();
519        let avg_per_ecosystem = if ecosystems > 0 {
520            self.by_ecosystem
521                .values()
522                .map(std::vec::Vec::len)
523                .sum::<usize>()
524                / ecosystems
525        } else {
526            0
527        };
528        let avg_per_prefix = if prefixes > 0 {
529            self.by_prefix
530                .values()
531                .map(std::vec::Vec::len)
532                .sum::<usize>()
533                / prefixes
534        } else {
535            0
536        };
537        let avg_per_trigram = if trigrams > 0 {
538            self.by_trigram
539                .values()
540                .map(std::vec::Vec::len)
541                .sum::<usize>()
542                / trigrams
543        } else {
544            0
545        };
546
547        IndexStats {
548            total_components: self.entries.len(),
549            ecosystems,
550            prefixes,
551            trigrams,
552            avg_per_ecosystem,
553            avg_per_prefix,
554            avg_per_trigram,
555        }
556    }
557
558    /// Compute trigram similarity between two entries (Jaccard coefficient).
559    ///
560    /// Returns a value between 0.0 and 1.0 where 1.0 means identical trigram sets.
561    #[must_use]
562    pub fn trigram_similarity(entry_a: &NormalizedEntry, entry_b: &NormalizedEntry) -> f64 {
563        if entry_a.trigrams.is_empty() || entry_b.trigrams.is_empty() {
564            return 0.0;
565        }
566
567        let set_a: HashSet<_> = entry_a.trigrams.iter().collect();
568        let set_b: HashSet<_> = entry_b.trigrams.iter().collect();
569
570        let intersection = set_a.intersection(&set_b).count();
571        let union = set_a.union(&set_b).count();
572
573        if union == 0 {
574            0.0
575        } else {
576            intersection as f64 / union as f64
577        }
578    }
579}
580
581/// Statistics about the component index.
582#[derive(Debug, Clone)]
583pub struct IndexStats {
584    /// Total number of indexed components
585    pub total_components: usize,
586    /// Number of unique ecosystems
587    pub ecosystems: usize,
588    /// Number of unique prefixes
589    pub prefixes: usize,
590    /// Number of unique trigrams
591    pub trigrams: usize,
592    /// Average components per ecosystem
593    pub avg_per_ecosystem: usize,
594    /// Average components per prefix
595    pub avg_per_prefix: usize,
596    /// Average components per trigram
597    pub avg_per_trigram: usize,
598}
599
600/// Batch candidate generator that combines multiple indexing strategies.
601///
602/// For best recall, combines:
603/// 1. `ComponentIndex` (ecosystem, prefix, trigram-based)
604/// 2. LSH index (for large SBOMs, catches approximate matches)
605/// 3. Cross-ecosystem mappings (optional)
606///
607/// The candidates from each source are deduplicated and merged under a
608/// shared per-source budget: `max_candidates` bounds the TOTAL across all
609/// three strategies. Ranked index candidates fill the budget first; LSH and
610/// cross-ecosystem only top up whatever room remains. (Letting each strategy
611/// add its own quota on top of a full index list flooded downstream scoring
612/// with up to 1.75× the configured budget — measured as a 30×+ slowdown on
613/// large single-ecosystem SBOMs.)
614pub struct BatchCandidateGenerator {
615    /// Primary component index
616    component_index: ComponentIndex,
617    /// Optional LSH index for large SBOMs
618    lsh_index: Option<super::lsh::LshIndex>,
619    /// Optional cross-ecosystem database
620    cross_ecosystem_db: Option<super::cross_ecosystem::CrossEcosystemDb>,
621    /// Configuration
622    config: BatchCandidateConfig,
623}
624
625/// Configuration for batch candidate generation.
626#[derive(Debug, Clone)]
627pub struct BatchCandidateConfig {
628    /// Maximum total candidates per source component across all strategies
629    /// (index + LSH + cross-ecosystem combined)
630    pub max_candidates: usize,
631    /// Maximum name length difference
632    pub max_length_diff: usize,
633    /// Minimum SBOM size to enable LSH (smaller SBOMs don't benefit)
634    pub lsh_threshold: usize,
635    /// Enable cross-ecosystem matching
636    pub enable_cross_ecosystem: bool,
637}
638
639impl Default for BatchCandidateConfig {
640    fn default() -> Self {
641        Self {
642            // Keep in lockstep with LargeSbomConfig::default().max_candidates
643            // so standalone generator users get the engine's budget.
644            max_candidates: 50,
645            max_length_diff: 5,
646            lsh_threshold: 500, // Only use LSH for SBOMs with 500+ components
647            enable_cross_ecosystem: true,
648        }
649    }
650}
651
652/// Result of batch candidate generation.
653#[derive(Debug)]
654pub struct BatchCandidateResult {
655    /// Source component ID
656    pub source_id: CanonicalId,
657    /// Candidates from component index
658    pub index_candidates: Vec<CanonicalId>,
659    /// Additional candidates from LSH (not in `index_candidates`)
660    pub lsh_candidates: Vec<CanonicalId>,
661    /// Cross-ecosystem candidates (if different ecosystems)
662    pub cross_ecosystem_candidates: Vec<CanonicalId>,
663    /// Total unique candidates
664    pub total_unique: usize,
665}
666
667impl BatchCandidateGenerator {
668    /// Create a new batch candidate generator from an SBOM.
669    #[must_use]
670    pub fn build(sbom: &NormalizedSbom, config: BatchCandidateConfig) -> Self {
671        let component_index = ComponentIndex::build(sbom);
672
673        // Only build LSH index for large SBOMs
674        let lsh_index = if sbom.component_count() >= config.lsh_threshold {
675            Some(super::lsh::LshIndex::build(
676                sbom,
677                super::lsh::LshConfig::default(),
678            ))
679        } else {
680            None
681        };
682
683        // Optionally load cross-ecosystem database
684        let cross_ecosystem_db = if config.enable_cross_ecosystem {
685            Some(super::cross_ecosystem::CrossEcosystemDb::with_builtin_mappings())
686        } else {
687            None
688        };
689
690        Self {
691            component_index,
692            lsh_index,
693            cross_ecosystem_db,
694            config,
695        }
696    }
697
698    /// Generate candidates for a single component.
699    pub fn find_candidates(
700        &self,
701        source_id: &CanonicalId,
702        source_component: &Component,
703    ) -> BatchCandidateResult {
704        let mut seen: HashSet<CanonicalId> = HashSet::new();
705
706        // Get normalized entry for the source
707        let source_entry = self.component_index.get_entry(source_id).map_or_else(
708            || {
709                // Build entry on the fly if not in our index (source from different SBOM)
710                ComponentIndex::normalize_component(source_component)
711            },
712            NormalizedEntry::clone,
713        );
714
715        // 1. Component index candidates
716        let index_candidates = self.component_index.find_candidates(
717            source_id,
718            &source_entry,
719            self.config.max_candidates,
720            self.config.max_length_diff,
721        );
722        for id in &index_candidates {
723            seen.insert(id.clone());
724        }
725
726        // 2. LSH candidates (additional ones not found by component index).
727        // Only fill whatever room the ranked index candidates left in the
728        // per-source budget, still bounded by the historical max/2 share.
729        let lsh_budget = self
730            .config
731            .max_candidates
732            .saturating_sub(seen.len())
733            .min(self.config.max_candidates / 2);
734        let lsh_candidates: Vec<CanonicalId> =
735            self.lsh_index.as_ref().map_or_else(Vec::new, |lsh| {
736                let candidates: Vec<_> = lsh
737                    .find_candidates(source_component)
738                    .into_iter()
739                    .filter(|id| id != source_id && !seen.contains(id))
740                    .take(lsh_budget)
741                    .collect();
742                for id in &candidates {
743                    seen.insert(id.clone());
744                }
745                candidates
746            });
747
748        // 3. Cross-ecosystem candidates, from the budget room still left,
749        // bounded by the historical max/4 share.
750        let cross_eco_budget = self
751            .config
752            .max_candidates
753            .saturating_sub(seen.len())
754            .min(self.config.max_candidates / 4);
755        let cross_ecosystem_candidates: Vec<CanonicalId> = if let (Some(db), Some(eco)) =
756            (&self.cross_ecosystem_db, &source_component.ecosystem)
757        {
758            let candidates: Vec<_> = db
759                .find_equivalents(eco, &source_component.name)
760                .into_iter()
761                .flat_map(|m| {
762                    // Look up components with these names in our index
763                    let target_eco_str = m.target_ecosystem.to_string().to_lowercase();
764                    self.component_index
765                        .get_by_ecosystem(&target_eco_str)
766                        .unwrap_or_default()
767                })
768                .filter(|id| id != source_id && !seen.contains(id))
769                .take(cross_eco_budget)
770                .collect();
771            for id in &candidates {
772                seen.insert(id.clone());
773            }
774            candidates
775        } else {
776            Vec::new()
777        };
778
779        let total_unique = seen.len();
780
781        BatchCandidateResult {
782            source_id: source_id.clone(),
783            index_candidates,
784            lsh_candidates,
785            cross_ecosystem_candidates,
786            total_unique,
787        }
788    }
789
790    /// Generate candidates for multiple components in parallel.
791    #[must_use]
792    pub fn find_candidates_batch(
793        &self,
794        sources: &[(&CanonicalId, &Component)],
795    ) -> Vec<BatchCandidateResult> {
796        sources
797            .par_iter()
798            .map(|(id, comp)| self.find_candidates(id, comp))
799            .collect()
800    }
801
802    /// Get all unique candidates (deduplicated across all strategies).
803    #[must_use]
804    pub fn all_candidates(
805        &self,
806        source_id: &CanonicalId,
807        source_component: &Component,
808    ) -> Vec<CanonicalId> {
809        let result = self.find_candidates(source_id, source_component);
810        let mut all: Vec<_> = result.index_candidates;
811        all.extend(result.lsh_candidates);
812        all.extend(result.cross_ecosystem_candidates);
813        all
814    }
815
816    /// Get the underlying component index.
817    #[must_use]
818    pub const fn component_index(&self) -> &ComponentIndex {
819        &self.component_index
820    }
821
822    /// Check if LSH is enabled.
823    #[must_use]
824    pub const fn has_lsh(&self) -> bool {
825        self.lsh_index.is_some()
826    }
827
828    /// Check if cross-ecosystem matching is enabled.
829    #[must_use]
830    pub const fn has_cross_ecosystem(&self) -> bool {
831        self.cross_ecosystem_db.is_some()
832    }
833
834    /// Get statistics about the generator.
835    pub fn stats(&self) -> BatchCandidateStats {
836        BatchCandidateStats {
837            index_stats: self.component_index.stats(),
838            lsh_enabled: self.lsh_index.is_some(),
839            lsh_stats: self.lsh_index.as_ref().map(super::lsh::LshIndex::stats),
840            cross_ecosystem_enabled: self.cross_ecosystem_db.is_some(),
841        }
842    }
843}
844
845/// Statistics about the batch candidate generator.
846#[derive(Debug)]
847pub struct BatchCandidateStats {
848    /// Component index statistics
849    pub index_stats: IndexStats,
850    /// Whether LSH is enabled
851    pub lsh_enabled: bool,
852    /// LSH statistics (if enabled)
853    pub lsh_stats: Option<super::lsh::LshIndexStats>,
854    /// Whether cross-ecosystem matching is enabled
855    pub cross_ecosystem_enabled: bool,
856}
857
858/// A lazily-built component index that only constructs the index on first use.
859///
860/// This is useful when the index might not be needed (e.g., when doing simple
861/// exact-match only comparisons), or when construction should be deferred.
862pub struct LazyComponentIndex {
863    /// The SBOM to index (stored for deferred building)
864    sbom: Option<std::sync::Arc<NormalizedSbom>>,
865    /// The built index (populated on first access)
866    index: std::sync::OnceLock<ComponentIndex>,
867}
868
869impl LazyComponentIndex {
870    /// Create a new lazy index that will build from the given SBOM on first access.
871    #[must_use]
872    pub const fn new(sbom: std::sync::Arc<NormalizedSbom>) -> Self {
873        Self {
874            sbom: Some(sbom),
875            index: std::sync::OnceLock::new(),
876        }
877    }
878
879    /// Create a lazy index from an already-built `ComponentIndex`.
880    #[must_use]
881    pub fn from_index(index: ComponentIndex) -> Self {
882        let lazy = Self {
883            sbom: None,
884            index: std::sync::OnceLock::new(),
885        };
886        let _ = lazy.index.set(index);
887        lazy
888    }
889
890    /// Get the index, building it if necessary.
891    ///
892    /// This is safe to call from multiple threads - the index will only
893    /// be built once.
894    pub fn get(&self) -> &ComponentIndex {
895        self.index.get_or_init(|| {
896            self.sbom.as_ref().map_or_else(
897                || {
898                    // Empty index as fallback (shouldn't happen in normal use)
899                    ComponentIndex::build(&NormalizedSbom::default())
900                },
901                |sbom| ComponentIndex::build(sbom),
902            )
903        })
904    }
905
906    /// Check if the index has been built yet.
907    pub fn is_built(&self) -> bool {
908        self.index.get().is_some()
909    }
910
911    /// Get the index if already built, without triggering a build.
912    pub fn try_get(&self) -> Option<&ComponentIndex> {
913        self.index.get()
914    }
915}
916
917impl std::ops::Deref for LazyComponentIndex {
918    type Target = ComponentIndex;
919
920    fn deref(&self) -> &Self::Target {
921        self.get()
922    }
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928    use crate::model::{DocumentMetadata, Ecosystem};
929
930    fn make_component(name: &str, purl: Option<&str>) -> Component {
931        let mut comp = Component::new(name.to_string(), format!("test-{}", name));
932        comp.version = Some("1.0.0".to_string());
933        comp.identifiers.purl = purl.map(|s| s.to_string());
934        // Convert extracted ecosystem string to Ecosystem enum
935        comp.ecosystem = purl
936            .and_then(ComponentIndex::extract_ecosystem)
937            .map(|eco_str| Ecosystem::from_purl_type(&eco_str));
938        comp
939    }
940
941    #[test]
942    fn test_extract_ecosystem() {
943        assert_eq!(
944            ComponentIndex::extract_ecosystem("pkg:pypi/requests@2.28.0"),
945            Some("pypi".to_string())
946        );
947        assert_eq!(
948            ComponentIndex::extract_ecosystem("pkg:npm/@angular/core@14.0.0"),
949            Some("npm".to_string())
950        );
951        assert_eq!(
952            ComponentIndex::extract_ecosystem("pkg:cargo/serde@1.0.0"),
953            Some("cargo".to_string())
954        );
955    }
956
957    #[test]
958    fn test_normalize_name_pypi() {
959        assert_eq!(
960            ComponentIndex::normalize_name("Python_Dateutil", Some("pypi")),
961            "python-dateutil"
962        );
963        assert_eq!(
964            ComponentIndex::normalize_name("Some.Package", Some("pypi")),
965            "some-package"
966        );
967    }
968
969    #[test]
970    fn test_normalize_name_cargo() {
971        assert_eq!(
972            ComponentIndex::normalize_name("serde-json", Some("cargo")),
973            "serde_json"
974        );
975    }
976
977    #[test]
978    fn test_build_index() {
979        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
980
981        let comp1 = make_component("requests", Some("pkg:pypi/requests@2.28.0"));
982        let comp2 = make_component("urllib3", Some("pkg:pypi/urllib3@1.26.0"));
983        let comp3 = make_component("serde", Some("pkg:cargo/serde@1.0.0"));
984
985        sbom.add_component(comp1);
986        sbom.add_component(comp2);
987        sbom.add_component(comp3);
988
989        let index = ComponentIndex::build(&sbom);
990
991        assert_eq!(index.len(), 3);
992        assert_eq!(index.by_ecosystem.get("pypi").map(|v| v.len()), Some(2));
993        assert_eq!(index.by_ecosystem.get("cargo").map(|v| v.len()), Some(1));
994    }
995
996    #[test]
997    fn test_find_candidates_same_ecosystem() {
998        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
999
1000        let comp1 = make_component("requests", Some("pkg:pypi/requests@2.28.0"));
1001        let comp2 = make_component("urllib3", Some("pkg:pypi/urllib3@1.26.0"));
1002        let comp3 = make_component("flask", Some("pkg:pypi/flask@2.0.0"));
1003        let comp4 = make_component("serde", Some("pkg:cargo/serde@1.0.0"));
1004
1005        sbom.add_component(comp1.clone());
1006        sbom.add_component(comp2);
1007        sbom.add_component(comp3);
1008        sbom.add_component(comp4);
1009
1010        let index = ComponentIndex::build(&sbom);
1011
1012        // Get the ID for requests
1013        let requests_id = sbom
1014            .components
1015            .keys()
1016            .find(|id| {
1017                sbom.components
1018                    .get(*id)
1019                    .map(|c| c.name == "requests")
1020                    .unwrap_or(false)
1021            })
1022            .unwrap();
1023
1024        let entry = index.get_entry(requests_id).unwrap();
1025        let candidates = index.find_candidates(requests_id, entry, 10, 5);
1026
1027        // Should find pypi packages, not cargo packages
1028        assert!(candidates.len() >= 2);
1029        for cand_id in &candidates {
1030            let cand_entry = index.get_entry(cand_id).unwrap();
1031            assert_eq!(cand_entry.ecosystem, Some("pypi".to_string()));
1032        }
1033    }
1034
1035    #[test]
1036    fn test_compute_trigrams() {
1037        // Normal case
1038        let trigrams = ComponentIndex::compute_trigrams("lodash");
1039        assert_eq!(trigrams, vec!["lod", "oda", "das", "ash"]);
1040
1041        // Short name (< 3 chars)
1042        let trigrams = ComponentIndex::compute_trigrams("ab");
1043        assert_eq!(trigrams, vec!["ab"]);
1044
1045        // Empty name
1046        let trigrams = ComponentIndex::compute_trigrams("");
1047        assert!(trigrams.is_empty());
1048
1049        // Exactly 3 chars
1050        let trigrams = ComponentIndex::compute_trigrams("abc");
1051        assert_eq!(trigrams, vec!["abc"]);
1052    }
1053
1054    #[test]
1055    fn test_trigram_similarity() {
1056        let entry_a = NormalizedEntry {
1057            normalized_purl: None,
1058            normalized_name: "lodash".to_string(),
1059            name_length: 6,
1060            ecosystem: None,
1061            prefix: "lod".to_string(),
1062            trigrams: vec![
1063                "lod".to_string(),
1064                "oda".to_string(),
1065                "das".to_string(),
1066                "ash".to_string(),
1067            ],
1068        };
1069
1070        let entry_b = NormalizedEntry {
1071            normalized_purl: None,
1072            normalized_name: "lodash-es".to_string(),
1073            name_length: 9,
1074            ecosystem: None,
1075            prefix: "lod".to_string(),
1076            trigrams: vec![
1077                "lod".to_string(),
1078                "oda".to_string(),
1079                "das".to_string(),
1080                "ash".to_string(),
1081                "sh-".to_string(),
1082                "h-e".to_string(),
1083                "-es".to_string(),
1084            ],
1085        };
1086
1087        let similarity = ComponentIndex::trigram_similarity(&entry_a, &entry_b);
1088        // lodash has 4 trigrams, lodash-es has 7, they share 4
1089        // Jaccard = 4 / 7 ≈ 0.57
1090        assert!(
1091            similarity > 0.5 && similarity < 0.6,
1092            "Expected ~0.57, got {}",
1093            similarity
1094        );
1095
1096        // Identical entries should have similarity 1.0
1097        let same_similarity = ComponentIndex::trigram_similarity(&entry_a, &entry_a);
1098        assert!((same_similarity - 1.0).abs() < f64::EPSILON);
1099
1100        // Completely different entries should have low similarity
1101        let entry_c = NormalizedEntry {
1102            normalized_purl: None,
1103            normalized_name: "react".to_string(),
1104            name_length: 5,
1105            ecosystem: None,
1106            prefix: "rea".to_string(),
1107            trigrams: vec!["rea".to_string(), "eac".to_string(), "act".to_string()],
1108        };
1109
1110        let diff_similarity = ComponentIndex::trigram_similarity(&entry_a, &entry_c);
1111        assert!(
1112            diff_similarity < 0.1,
1113            "Expected low similarity, got {}",
1114            diff_similarity
1115        );
1116    }
1117
1118    #[test]
1119    fn test_trigram_index_find_similar_suffix() {
1120        // Test that trigram indexing can find packages with different prefixes but similar content
1121        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1122
1123        // These packages share trigrams in the middle/end
1124        let comp1 = make_component("react-dom", Some("pkg:npm/react-dom@18.0.0"));
1125        let comp2 = make_component("preact-dom", Some("pkg:npm/preact-dom@10.0.0")); // shares "act", "-do", "dom"
1126        let comp3 = make_component("angular", Some("pkg:npm/angular@15.0.0")); // completely different
1127
1128        sbom.add_component(comp1.clone());
1129        sbom.add_component(comp2);
1130        sbom.add_component(comp3);
1131
1132        let index = ComponentIndex::build(&sbom);
1133
1134        // Find ID for react-dom
1135        let react_id = sbom
1136            .components
1137            .keys()
1138            .find(|id| {
1139                sbom.components
1140                    .get(*id)
1141                    .map(|c| c.name == "react-dom")
1142                    .unwrap_or(false)
1143            })
1144            .unwrap();
1145
1146        let entry = index.get_entry(react_id).unwrap();
1147
1148        // Should find preact-dom via trigram matching even though prefix differs
1149        let candidates = index.find_candidates(react_id, entry, 10, 5);
1150
1151        let preact_found = candidates.iter().any(|id| {
1152            index
1153                .get_entry(id)
1154                .map(|e| e.normalized_name.contains("preact"))
1155                .unwrap_or(false)
1156        });
1157
1158        assert!(preact_found, "Should find preact-dom via trigram matching");
1159    }
1160
1161    /// `max_candidates` is a TOTAL per-source budget: LSH and cross-ecosystem
1162    /// may only top up what the index candidates left, never stack their own
1163    /// quotas on top of a full index list (which previously produced up to
1164    /// 1.75x the configured budget and flooded downstream scoring).
1165    #[test]
1166    fn test_batch_generator_enforces_total_candidate_budget() {
1167        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1168        // 80 similarly named components: both the index and LSH will each
1169        // find far more than the budget on their own.
1170        for i in 0..80 {
1171            let name = format!("libfoo-{i:03}");
1172            let purl = format!("pkg:npm/{name}@1.0.0");
1173            sbom.add_component(make_component(&name, Some(&purl)));
1174        }
1175
1176        let max_candidates = 20;
1177        let generator = BatchCandidateGenerator::build(
1178            &sbom,
1179            BatchCandidateConfig {
1180                max_candidates,
1181                max_length_diff: 10,
1182                lsh_threshold: 1, // force the LSH index on
1183                enable_cross_ecosystem: true,
1184            },
1185        );
1186
1187        // Source from a different SBOM, similar to every indexed component.
1188        let source = make_component("libfoo-100", Some("pkg:npm/libfoo-100@1.0.0"));
1189        let result = generator.find_candidates(&source.canonical_id, &source);
1190
1191        let total = result.index_candidates.len()
1192            + result.lsh_candidates.len()
1193            + result.cross_ecosystem_candidates.len();
1194        assert!(
1195            total <= max_candidates,
1196            "candidate strategies must share one budget: got {} (index {} + lsh {} + cross-eco {}) > {}",
1197            total,
1198            result.index_candidates.len(),
1199            result.lsh_candidates.len(),
1200            result.cross_ecosystem_candidates.len(),
1201            max_candidates
1202        );
1203        assert!(result.total_unique <= max_candidates);
1204        // The index alone can fill the budget here, so it should have.
1205        assert_eq!(result.index_candidates.len(), max_candidates);
1206    }
1207
1208    /// The engine-level and generator-level defaults must stay in lockstep:
1209    /// a standalone `BatchCandidateGenerator` user should get the same
1210    /// per-source budget the diff engine uses, and the value itself (50, the
1211    /// budget the sub-lsh_threshold path always used) is what keeps candidate
1212    /// volume flat across the size gate.
1213    #[test]
1214    fn test_default_candidate_budgets_agree() {
1215        let generator_default = BatchCandidateConfig::default().max_candidates;
1216        let engine_default = crate::diff::LargeSbomConfig::default().max_candidates;
1217        assert_eq!(generator_default, engine_default);
1218        assert_eq!(engine_default, 50);
1219    }
1220}