Skip to main content

sbom_tools/matching/
mod.rs

1//! Fuzzy matching engine for cross-ecosystem package correlation.
2//!
3//! This module provides multi-tier matching strategies for correlating
4//! components across different ecosystems and naming conventions.
5//!
6//! # Architecture
7//!
8//! The matching system is built on the [`ComponentMatcher`] trait, which
9//! provides a pluggable interface for different matching strategies:
10//!
11//! - [`FuzzyMatcher`]: Multi-tier fuzzy matching (default)
12//! - [`CompositeMatcher`]: Combines multiple matchers
13//! - [`CachedMatcher`]: Wraps any matcher with caching
14//!
15//! # Example
16//!
17//! ```ignore
18//! use sbom_tools::matching::{ComponentMatcher, FuzzyMatcher, FuzzyMatchConfig};
19//!
20//! // Use the trait for dependency injection
21//! fn diff_with_matcher(matcher: &dyn ComponentMatcher) {
22//!     let score = matcher.match_score(&comp_a, &comp_b);
23//! }
24//!
25//! let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
26//! diff_with_matcher(&matcher);
27//! ```
28
29pub mod adaptive;
30mod aliases;
31mod config;
32pub mod cross_ecosystem;
33pub mod custom_rules;
34pub mod ecosystem_config;
35pub mod index;
36pub mod lsh;
37mod purl;
38pub mod rule_engine;
39mod rules;
40pub mod scoring;
41pub mod string_similarity;
42mod traits;
43
44pub use adaptive::{
45    AdaptiveMatching, AdaptiveMethod, AdaptiveThreshold, AdaptiveThresholdConfig,
46    AdaptiveThresholdResult, ScoreStats,
47};
48pub use aliases::AliasTable;
49pub use config::{CrossEcosystemConfig, FuzzyMatchConfig, MultiFieldWeights};
50pub use cross_ecosystem::{CrossEcosystemDb, CrossEcosystemMatch, PackageFamily};
51pub use custom_rules::{
52    AliasPattern, EquivalenceGroup, ExclusionRule, MatchingRulesConfig, RulePrecedence,
53    RulesSummary,
54};
55pub use ecosystem_config::{
56    ConfigError, CustomEquivalence, CustomRules, EcosystemConfig, EcosystemRulesConfig,
57    GlobalSettings, GroupMigration, ImportMapping, NormalizationConfig, PackageGroup,
58    ScopeHandling, SecurityConfig, TyposquatEntry, VersionSpec, VersioningConfig,
59};
60pub use index::{
61    BatchCandidateConfig, BatchCandidateGenerator, BatchCandidateResult, BatchCandidateStats,
62    ComponentIndex, IndexStats, LazyComponentIndex, NormalizedEntry,
63};
64pub use lsh::{LshConfig, LshIndex, LshIndexStats, MinHashSignature};
65pub use purl::PurlNormalizer;
66pub use rule_engine::{AppliedRule, AppliedRuleType, RuleApplicationResult, RuleEngine};
67pub use rules::EcosystemRules;
68pub use scoring::{MultiFieldScoreResult, SemverParts};
69pub use traits::{
70    CacheConfig, CacheStats, CachedMatcher, ComponentMatcher, CompositeMatcher,
71    CompositeMatcherBuilder, MatchExplanation, MatchMetadata, MatchResult, MatchTier,
72    ScoreComponent,
73};
74
75use crate::model::Component;
76use strsim::{jaro_winkler, levenshtein};
77
78/// Score ceiling for pairs whose (lowercased) names differ.
79///
80/// Identical-name pairs score 1.0 through the tier-level name-identity
81/// anchor (ecosystem-normalized or raw), which fires before fuzzy AND
82/// multi-field scoring — so with this cap no near-miss neighbor can tie or
83/// beat an exact-name counterpart in any scoring mode. Without it, a
84/// one-character-different name plus the version boost reaches 1.0 and the
85/// optimal assignment pairs components with the wrong neighbor instead of
86/// their exact-name counterpart.
87const NON_IDENTICAL_NAME_CAP: f64 = 0.99;
88
89/// Fuzzy matcher for component correlation.
90#[must_use]
91pub struct FuzzyMatcher {
92    config: FuzzyMatchConfig,
93    alias_table: AliasTable,
94    purl_normalizer: PurlNormalizer,
95    ecosystem_rules: EcosystemRules,
96    /// Cross-ecosystem match policy: components from known-different
97    /// ecosystems only ever match through the curated equivalence DB, with
98    /// `score_penalty` and `min_score` applied uniformly — regardless of
99    /// which candidate-generation strategy surfaced the pair.
100    cross_ecosystem: CrossEcosystemConfig,
101    cross_ecosystem_db: Option<CrossEcosystemDb>,
102}
103
104impl FuzzyMatcher {
105    /// Create a new fuzzy matcher with the given configuration.
106    ///
107    /// Cross-ecosystem policy defaults to [`CrossEcosystemConfig::default`]
108    /// (enabled, builtin equivalence DB); override with
109    /// [`with_cross_ecosystem`](Self::with_cross_ecosystem).
110    pub fn new(config: FuzzyMatchConfig) -> Self {
111        let cross_ecosystem = CrossEcosystemConfig::default();
112        let cross_ecosystem_db = cross_ecosystem
113            .enabled
114            .then(CrossEcosystemDb::with_builtin_mappings);
115        Self {
116            config,
117            alias_table: AliasTable::default(),
118            purl_normalizer: PurlNormalizer::new(),
119            ecosystem_rules: EcosystemRules::new(),
120            cross_ecosystem,
121            cross_ecosystem_db,
122        }
123    }
124
125    /// Get the current configuration.
126    #[must_use]
127    pub const fn config(&self) -> &FuzzyMatchConfig {
128        &self.config
129    }
130
131    /// Create a matcher with a custom alias table
132    pub fn with_alias_table(mut self, table: AliasTable) -> Self {
133        self.alias_table = table;
134        self
135    }
136
137    /// Set the cross-ecosystem match policy (builds the builtin equivalence
138    /// DB when enabled; a previously supplied custom DB is kept).
139    pub fn with_cross_ecosystem(mut self, config: CrossEcosystemConfig) -> Self {
140        if config.enabled && self.cross_ecosystem_db.is_none() {
141            self.cross_ecosystem_db = Some(CrossEcosystemDb::with_builtin_mappings());
142        }
143        self.cross_ecosystem = config;
144        self
145    }
146
147    /// Use a custom cross-ecosystem equivalence DB instead of the builtins.
148    pub fn with_cross_ecosystem_db(mut self, db: CrossEcosystemDb) -> Self {
149        self.cross_ecosystem_db = Some(db);
150        self
151    }
152
153    /// Match two components and return a confidence score (0.0 - 1.0)
154    #[must_use]
155    pub fn match_components(&self, a: &Component, b: &Component) -> f64 {
156        let scored = self.score_pair(a, b);
157        match scored.outcome {
158            // Identifier/identity/alias/cross-ecosystem tiers carry their own
159            // acceptance criteria and bypass the fuzzy threshold.
160            TierOutcome::ExactPurl
161            | TierOutcome::Alias
162            | TierOutcome::EcosystemRule
163            | TierOutcome::NameIdentity
164            | TierOutcome::CrossEcosystem { .. } => scored.score,
165            TierOutcome::Fuzzy { .. } | TierOutcome::MultiField { .. } => {
166                if scored.score >= self.config.threshold {
167                    scored.score
168                } else {
169                    0.0
170                }
171            }
172            TierOutcome::CrossEcosystemRejected { .. } => 0.0,
173        }
174    }
175
176    /// The tiered scoring pipeline — the single source of truth behind
177    /// [`match_score`](ComponentMatcher::match_score),
178    /// [`match_detailed`](ComponentMatcher::match_detailed), and
179    /// [`explain_match`](ComponentMatcher::explain_match), so the three views
180    /// can never disagree about a pair.
181    ///
182    /// Tiers, in priority order:
183    /// 1. Exact normalized PURL → 1.0
184    /// 2. Cross-ecosystem gate: KNOWN-different ecosystems match only via
185    ///    the curated equivalence DB, penalized and floored per config
186    ///    (`Unknown`/`Generic` ecosystems don't engage the gate — a
187    ///    non-canonical purl type from another tool is missing information,
188    ///    not evidence of a different ecosystem)
189    /// 3. Name identity: ecosystem-normalized equality (same ecosystem) or
190    ///    case-insensitive raw equality → 1.0
191    /// 4. Alias table (opt-in) → 0.95
192    /// 5. Fuzzy / multi-field scoring, capped at
193    ///    [`NON_IDENTICAL_NAME_CAP`] for differing names
194    ///
195    /// The returned score is the raw tier score; threshold gating is the
196    /// caller's concern (fuzzy/multi-field tiers only).
197    fn score_pair(&self, a: &Component, b: &Component) -> ScoredPair {
198        // Tier 1: Exact PURL match
199        if let (Some(purl_a), Some(purl_b)) = (&a.identifiers.purl, &b.identifiers.purl) {
200            let norm_a = self.purl_normalizer.normalize(purl_a);
201            let norm_b = self.purl_normalizer.normalize(purl_b);
202            if norm_a == norm_b {
203                return ScoredPair {
204                    score: 1.0,
205                    outcome: TierOutcome::ExactPurl,
206                };
207            }
208        }
209
210        let identical_names = a.name.to_lowercase() == b.name.to_lowercase();
211
212        // Tier 2: cross-ecosystem gate. Same-name packages in different
213        // ecosystems are usually DIFFERENT packages (npm/redis vs pypi/redis)
214        // — hiding an ecosystem substitution as a version bump is the worst
215        // failure mode for a supply-chain diff, so only curated equivalences
216        // may cross this line, and they carry the configured penalty. The
217        // gate requires BOTH ecosystems to be canonically known: an
218        // Unknown("rubygems") from another tool's purl spelling vs RubyGems
219        // is missing information, not a substitution.
220        if let (Some(eco_a), Some(eco_b)) = (&a.ecosystem, &b.ecosystem)
221            && eco_a != eco_b
222            && is_known_ecosystem(eco_a)
223            && is_known_ecosystem(eco_b)
224        {
225            if !self.cross_ecosystem.enabled {
226                return ScoredPair::rejected(CrossEcoRejection::Disabled);
227            }
228            let Some(db) = &self.cross_ecosystem_db else {
229                return ScoredPair::rejected(CrossEcoRejection::Disabled);
230            };
231            return match db.equivalence(eco_a, &a.name, eco_b, &b.name) {
232                None => ScoredPair::rejected(CrossEcoRejection::NotEquivalent),
233                Some(info) if self.cross_ecosystem.verified_only && !info.verified => {
234                    ScoredPair::rejected(CrossEcoRejection::Unverified)
235                }
236                Some(info) => {
237                    let base = self.fuzzy_or_multi_field(a, b, identical_names).score;
238                    let penalized = (base - self.cross_ecosystem.score_penalty).max(0.0);
239                    // `<= 0.0` matters: with a user-configured min_score of
240                    // 0.0, a fully-penalized pair must still be a rejection,
241                    // not a "matched at 0.0" that the three views would
242                    // disagree about.
243                    if penalized <= 0.0 || penalized < self.cross_ecosystem.min_score {
244                        ScoredPair::rejected(CrossEcoRejection::BelowFloor { penalized })
245                    } else {
246                        ScoredPair {
247                            score: penalized,
248                            outcome: TierOutcome::CrossEcosystem {
249                                base,
250                                family: info.family_name,
251                            },
252                        }
253                    }
254                }
255            };
256        }
257
258        // Tier 3: name identity — the strongest name evidence available, and
259        // it must outrank the alias tier (0.95) and every fuzzy neighbor.
260        // Scoring identity below 1.0 (the old flat 0.90) let near-miss fuzzy
261        // neighbors (~0.95) outbid a component's own exact-name counterpart
262        // in the assignment. The anchor is tier-level rather than inside the
263        // fuzzy scorer so it holds in multi-field mode too.
264        if self.config.use_ecosystem_rules && self.ecosystem_normalized_names_equal(a, b) {
265            return ScoredPair {
266                score: 1.0,
267                outcome: TierOutcome::EcosystemRule,
268            };
269        }
270        if identical_names {
271            return ScoredPair {
272                score: 1.0,
273                outcome: TierOutcome::NameIdentity,
274            };
275        }
276
277        // Tier 4: Alias table lookup (table is empty unless installed)
278        if self.config.use_aliases && self.check_alias_match(a, b) {
279            return ScoredPair {
280                score: 0.95,
281                outcome: TierOutcome::Alias,
282            };
283        }
284
285        // Tier 5: fuzzy / multi-field scoring
286        self.fuzzy_or_multi_field(a, b, identical_names)
287    }
288
289    /// Fuzzy or multi-field scoring (per config), with the non-identical-name
290    /// cap applied.
291    fn fuzzy_or_multi_field(
292        &self,
293        a: &Component,
294        b: &Component,
295        identical_names: bool,
296    ) -> ScoredPair {
297        if let Some(ref weights) = self.config.field_weights {
298            let mut result = self.compute_multi_field_score(a, b, weights);
299            let capped = !identical_names && result.total > NON_IDENTICAL_NAME_CAP;
300            if capped {
301                result.total = NON_IDENTICAL_NAME_CAP;
302            }
303            ScoredPair {
304                score: result.total,
305                outcome: TierOutcome::MultiField { result, capped },
306            }
307        } else {
308            let breakdown = self.fuzzy_breakdown(a, b, identical_names);
309            ScoredPair {
310                score: breakdown.total,
311                outcome: TierOutcome::Fuzzy { breakdown },
312            }
313        }
314    }
315
316    /// Check if components match via alias table
317    fn check_alias_match(&self, a: &Component, b: &Component) -> bool {
318        // The default table is empty; don't pay two Vec<String> allocations
319        // per candidate pair to consult it.
320        if self.alias_table.is_empty() {
321            return false;
322        }
323
324        // Check if either component's name is an alias of the other
325        let names_a = self.get_all_names(a);
326        let names_b = self.get_all_names(b);
327
328        for name_a in &names_a {
329            if let Some(canonical) = self.alias_table.get_canonical(name_a) {
330                for name_b in &names_b {
331                    if self.alias_table.is_alias(&canonical, name_b) {
332                        return true;
333                    }
334                }
335            }
336        }
337
338        false
339    }
340
341    /// Get all possible names for a component
342    fn get_all_names(&self, comp: &Component) -> Vec<String> {
343        let mut names = vec![comp.name.clone()];
344        names.extend(comp.identifiers.aliases.clone());
345
346        // Extract name from PURL if available
347        if let Some(purl) = &comp.identifiers.purl
348            && let Some(name) = self.extract_name_from_purl(purl)
349        {
350            names.push(name);
351        }
352
353        names
354    }
355
356    /// Extract the package name from a PURL
357    fn extract_name_from_purl(&self, purl: &str) -> Option<String> {
358        // pkg:type/namespace/name@version?qualifiers#subpath
359        let without_pkg = purl.strip_prefix("pkg:")?;
360        let parts: Vec<&str> = without_pkg.split('/').collect();
361
362        if parts.len() >= 2 {
363            let name_part = parts.last()?;
364            // Remove version and qualifiers
365            let name = name_part.split('@').next()?;
366            Some(name.to_string())
367        } else {
368            None
369        }
370    }
371
372    /// Check whether both components live in the same ecosystem and their
373    /// names are equal after ecosystem-official normalization.
374    fn ecosystem_normalized_names_equal(&self, a: &Component, b: &Component) -> bool {
375        let (Some(ecosystem_a), Some(ecosystem_b)) = (a.ecosystem.as_ref(), b.ecosystem.as_ref())
376        else {
377            return false;
378        };
379        if ecosystem_a != ecosystem_b {
380            return false;
381        }
382        self.ecosystem_rules.normalize_name(&a.name, ecosystem_a)
383            == self.ecosystem_rules.normalize_name(&b.name, ecosystem_b)
384    }
385
386    /// Compute fuzzy string similarity score (uncapped; used as the name
387    /// component of multi-field scoring).
388    fn compute_fuzzy_score(&self, a: &Component, b: &Component) -> f64 {
389        // Names being identical exempts a pair from the cap anyway, so the
390        // uncapped total is what a raw-similarity consumer wants.
391        self.fuzzy_breakdown(a, b, true).total
392    }
393
394    /// Fuzzy string similarity with the full component breakdown retained
395    /// for explanations. Applies [`NON_IDENTICAL_NAME_CAP`] unless
396    /// `identical_names`.
397    fn fuzzy_breakdown(
398        &self,
399        a: &Component,
400        b: &Component,
401        identical_names: bool,
402    ) -> FuzzyBreakdown {
403        let name_a = a.name.to_lowercase();
404        let name_b = b.name.to_lowercase();
405
406        // Jaro-Winkler similarity
407        let jw_score = jaro_winkler(&name_a, &name_b);
408
409        // Normalized Levenshtein distance
410        let max_len = name_a.len().max(name_b.len());
411        let lev_distance = levenshtein(&name_a, &name_b);
412        let lev_score = if max_len > 0 {
413            1.0 - (lev_distance as f64 / max_len as f64)
414        } else {
415            1.0
416        };
417
418        // Token-based similarity (catches reordered names like "react-dom" vs "dom-react")
419        let token_score = Self::compute_token_similarity(&name_a, &name_b);
420
421        // Phonetic similarity (catches typos like "color" vs "colour")
422        let phonetic_score = Self::compute_phonetic_similarity(&name_a, &name_b);
423
424        // Weighted combination of character-based scores
425        let char_score = jw_score.mul_add(
426            self.config.jaro_winkler_weight,
427            lev_score * self.config.levenshtein_weight,
428        );
429
430        // Use the MAXIMUM of character, token, and phonetic scores
431        // This allows each method to catch different types of variations
432        let combined = char_score.max(token_score).max(phonetic_score * 0.85);
433
434        // Version-aware boost (semantic version similarity)
435        let version_boost =
436            Self::compute_version_similarity(a.version.as_ref(), b.version.as_ref());
437
438        let uncapped = (combined + version_boost).min(1.0);
439        let capped = !identical_names && uncapped > NON_IDENTICAL_NAME_CAP;
440        let total = if capped {
441            NON_IDENTICAL_NAME_CAP
442        } else {
443            uncapped
444        };
445
446        FuzzyBreakdown {
447            jw_score,
448            lev_score,
449            lev_distance,
450            max_len,
451            token_score,
452            phonetic_score,
453            char_score,
454            version_boost,
455            capped,
456            total,
457        }
458    }
459
460    /// Compute token-based similarity using Jaccard index on name tokens.
461    fn compute_token_similarity(name_a: &str, name_b: &str) -> f64 {
462        string_similarity::compute_token_similarity(name_a, name_b)
463    }
464
465    /// Compute version similarity with semantic awareness.
466    fn compute_version_similarity(va: Option<&String>, vb: Option<&String>) -> f64 {
467        string_similarity::compute_version_similarity(va, vb)
468    }
469
470    /// Compute phonetic similarity using Soundex.
471    #[must_use]
472    pub fn compute_phonetic_similarity(name_a: &str, name_b: &str) -> f64 {
473        string_similarity::compute_phonetic_similarity(name_a, name_b)
474    }
475
476    /// Compute multi-field weighted score.
477    ///
478    /// Combines scores from multiple component fields based on configured weights.
479    #[must_use]
480    pub fn compute_multi_field_score(
481        &self,
482        a: &Component,
483        b: &Component,
484        weights: &config::MultiFieldWeights,
485    ) -> scoring::MultiFieldScoreResult {
486        use std::collections::HashSet;
487
488        let mut result = scoring::MultiFieldScoreResult::default();
489
490        // 1. Name similarity (using fuzzy scoring). Note: the fuzzy score
491        // includes the graduated version boost, so version evidence also
492        // contributes here in addition to the weighted version field below —
493        // long-standing behavior, kept for score stability.
494        let name_score = self.compute_fuzzy_score(a, b);
495        result.name_score = name_score;
496        result.total += name_score * weights.name;
497
498        // 2. Version match (graduated or binary scoring)
499        let version_score = if weights.version_divergence_enabled {
500            scoring::compute_version_divergence_score(&a.version, &b.version, weights)
501        } else {
502            // Legacy binary scoring
503            match (&a.version, &b.version) {
504                (Some(va), Some(vb)) if va == vb => 1.0,
505                (None, None) => 0.5, // Both missing = neutral
506                _ => 0.0,
507            }
508        };
509        result.version_score = version_score;
510        result.total += version_score * weights.version;
511
512        // 3. Ecosystem match (exact match = 1.0, mismatch applies penalty)
513        let (ecosystem_score, ecosystem_penalty) = match (&a.ecosystem, &b.ecosystem) {
514            (Some(ea), Some(eb)) if ea == eb => (1.0, 0.0),
515            (None, None) => (0.5, 0.0), // Both missing = neutral, no penalty
516            (Some(_), Some(_)) => (0.0, weights.ecosystem_mismatch_penalty), // Different ecosystems = penalty
517            _ => (0.0, 0.0), // One missing = no match but no penalty
518        };
519        result.ecosystem_score = ecosystem_score;
520        result.total += ecosystem_score.mul_add(weights.ecosystem, ecosystem_penalty);
521
522        // 4. License overlap (Jaccard similarity on declared licenses)
523        let licenses_a: HashSet<_> = a
524            .licenses
525            .declared
526            .iter()
527            .map(|l| l.expression.as_str())
528            .collect();
529        let licenses_b: HashSet<_> = b
530            .licenses
531            .declared
532            .iter()
533            .map(|l| l.expression.as_str())
534            .collect();
535        let license_score = if licenses_a.is_empty() && licenses_b.is_empty() {
536            0.5 // Both empty = neutral
537        } else if licenses_a.is_empty() || licenses_b.is_empty() {
538            0.0 // One empty = no match
539        } else {
540            let intersection = licenses_a.intersection(&licenses_b).count();
541            let union = licenses_a.union(&licenses_b).count();
542            if union > 0 {
543                intersection as f64 / union as f64
544            } else {
545                0.0
546            }
547        };
548        result.license_score = license_score;
549        result.total += license_score * weights.licenses;
550
551        // 5. Supplier match (exact match on supplier organization name)
552        let supplier_score = match (&a.supplier, &b.supplier) {
553            (Some(sa), Some(sb)) if sa.name.to_lowercase() == sb.name.to_lowercase() => 1.0,
554            (None, None) => 0.5, // Both missing = neutral
555            _ => 0.0,
556        };
557        result.supplier_score = supplier_score;
558        result.total += supplier_score * weights.supplier;
559
560        // 6. Group/namespace match
561        let group_score = match (&a.group, &b.group) {
562            (Some(ga), Some(gb)) if ga.to_lowercase() == gb.to_lowercase() => 1.0,
563            (None, None) => 0.5, // Both missing = neutral
564            _ => 0.0,
565        };
566        result.group_score = group_score;
567        result.total += group_score * weights.group;
568
569        // Clamp total to [0.0, 1.0] after penalty application
570        result.total = result.total.clamp(0.0, 1.0);
571
572        result
573    }
574}
575
576impl Default for FuzzyMatcher {
577    fn default() -> Self {
578        Self::new(FuzzyMatchConfig::balanced())
579    }
580}
581
582/// A scored pair plus which tier produced the score — enough to build the
583/// detailed and explained views without re-deriving anything.
584struct ScoredPair {
585    score: f64,
586    outcome: TierOutcome,
587}
588
589impl ScoredPair {
590    const fn rejected(reason: CrossEcoRejection) -> Self {
591        Self {
592            score: 0.0,
593            outcome: TierOutcome::CrossEcosystemRejected { reason },
594        }
595    }
596}
597
598/// Which tier of the scoring pipeline decided a pair's score.
599enum TierOutcome {
600    ExactPurl,
601    Alias,
602    EcosystemRule,
603    /// Case-insensitive raw-name equality when ecosystems are not
604    /// known-different (missing/Unknown ecosystem info on either side)
605    NameIdentity,
606    CrossEcosystem {
607        /// Layer-four score before the cross-ecosystem penalty
608        base: f64,
609        /// Equivalence family that allowed the pair across the gate
610        family: String,
611    },
612    CrossEcosystemRejected {
613        reason: CrossEcoRejection,
614    },
615    Fuzzy {
616        breakdown: FuzzyBreakdown,
617    },
618    MultiField {
619        result: scoring::MultiFieldScoreResult,
620        capped: bool,
621    },
622}
623
624/// Why the cross-ecosystem gate rejected a pair.
625enum CrossEcoRejection {
626    Disabled,
627    NotEquivalent,
628    Unverified,
629    BelowFloor { penalized: f64 },
630}
631
632/// Full fuzzy-scoring breakdown, retained for explanations.
633struct FuzzyBreakdown {
634    jw_score: f64,
635    lev_score: f64,
636    lev_distance: usize,
637    max_len: usize,
638    token_score: f64,
639    phonetic_score: f64,
640    char_score: f64,
641    version_boost: f64,
642    capped: bool,
643    total: f64,
644}
645
646impl ComponentMatcher for FuzzyMatcher {
647    fn match_score(&self, a: &Component, b: &Component) -> f64 {
648        self.match_components(a, b)
649    }
650
651    fn match_detailed(&self, a: &Component, b: &Component) -> MatchResult {
652        let scored = self.score_pair(a, b);
653        match scored.outcome {
654            TierOutcome::ExactPurl => MatchResult::with_metadata(
655                scored.score,
656                MatchTier::ExactIdentifier,
657                MatchMetadata {
658                    matched_fields: vec!["purl".to_string()],
659                    normalization: Some("purl_normalized".to_string()),
660                    rule_id: None,
661                },
662            ),
663            TierOutcome::Alias => MatchResult::with_metadata(
664                scored.score,
665                MatchTier::Alias,
666                MatchMetadata {
667                    matched_fields: vec!["name".to_string()],
668                    normalization: Some("alias_table".to_string()),
669                    rule_id: None,
670                },
671            ),
672            TierOutcome::EcosystemRule => MatchResult::with_metadata(
673                scored.score,
674                MatchTier::EcosystemRule,
675                MatchMetadata {
676                    matched_fields: vec!["name".to_string(), "ecosystem".to_string()],
677                    normalization: Some("ecosystem_rules".to_string()),
678                    rule_id: None,
679                },
680            ),
681            TierOutcome::NameIdentity => MatchResult::with_metadata(
682                scored.score,
683                MatchTier::NameIdentity,
684                MatchMetadata {
685                    matched_fields: vec!["name".to_string()],
686                    normalization: Some("case_insensitive_name".to_string()),
687                    rule_id: None,
688                },
689            ),
690            TierOutcome::CrossEcosystem { family, .. } => MatchResult::with_metadata(
691                scored.score,
692                MatchTier::CrossEcosystem,
693                MatchMetadata {
694                    matched_fields: vec!["name".to_string(), "ecosystem".to_string()],
695                    normalization: Some("cross_ecosystem_db".to_string()),
696                    rule_id: Some(family),
697                },
698            ),
699            TierOutcome::Fuzzy { .. } if scored.score >= self.config.threshold => {
700                MatchResult::with_metadata(
701                    scored.score,
702                    MatchTier::Fuzzy,
703                    MatchMetadata {
704                        matched_fields: vec!["name".to_string()],
705                        normalization: Some("fuzzy_similarity".to_string()),
706                        rule_id: None,
707                    },
708                )
709            }
710            TierOutcome::MultiField { .. } if scored.score >= self.config.threshold => {
711                MatchResult::with_metadata(
712                    scored.score,
713                    MatchTier::Fuzzy,
714                    MatchMetadata {
715                        matched_fields: vec![
716                            "name".to_string(),
717                            "version".to_string(),
718                            "ecosystem".to_string(),
719                            "licenses".to_string(),
720                            "supplier".to_string(),
721                            "group".to_string(),
722                        ],
723                        normalization: Some("multi_field".to_string()),
724                        rule_id: None,
725                    },
726                )
727            }
728            TierOutcome::Fuzzy { .. }
729            | TierOutcome::MultiField { .. }
730            | TierOutcome::CrossEcosystemRejected { .. } => MatchResult::no_match(),
731        }
732    }
733
734    fn name(&self) -> &'static str {
735        "FuzzyMatcher"
736    }
737
738    fn threshold(&self) -> f64 {
739        self.config.threshold
740    }
741
742    fn explain_match(&self, a: &Component, b: &Component) -> MatchExplanation {
743        let scored = self.score_pair(a, b);
744        match scored.outcome {
745            TierOutcome::ExactPurl => {
746                let purl_a = a.identifiers.purl.as_deref().unwrap_or_default();
747                let purl_b = b.identifiers.purl.as_deref().unwrap_or_default();
748                MatchExplanation::matched(
749                    MatchTier::ExactIdentifier,
750                    scored.score,
751                    format!("Exact PURL match: '{purl_a}' equals '{purl_b}' after normalization"),
752                )
753                .with_normalization("purl_normalized")
754            }
755            TierOutcome::Alias => MatchExplanation::matched(
756                MatchTier::Alias,
757                scored.score,
758                format!(
759                    "'{}' and '{}' are known aliases of the same package",
760                    a.name, b.name
761                ),
762            )
763            .with_normalization("alias_table"),
764            TierOutcome::EcosystemRule => {
765                let ecosystem = a
766                    .ecosystem
767                    .as_ref()
768                    .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
769                MatchExplanation::matched(
770                    MatchTier::EcosystemRule,
771                    scored.score,
772                    format!(
773                        "Names match after {} ecosystem normalization: '{}' -> '{}'",
774                        ecosystem, a.name, b.name
775                    ),
776                )
777                .with_normalization(format!("{ecosystem}_normalization"))
778            }
779            TierOutcome::NameIdentity => MatchExplanation::matched(
780                MatchTier::NameIdentity,
781                scored.score,
782                format!("Identical component names: '{}'", a.name),
783            )
784            .with_normalization("case_insensitive_name"),
785            TierOutcome::CrossEcosystem { base, family } => MatchExplanation::matched(
786                MatchTier::CrossEcosystem,
787                scored.score,
788                format!(
789                    "'{}' ({}) and '{}' ({}) are curated equivalents (family '{}'): base score {:.2} − {:.2} cross-ecosystem penalty",
790                    a.name,
791                    ecosystem_label(a),
792                    b.name,
793                    ecosystem_label(b),
794                    family,
795                    base,
796                    self.cross_ecosystem.score_penalty,
797                ),
798            )
799            .with_score_component(ScoreComponent {
800                name: "Cross-ecosystem penalty".to_string(),
801                weight: 1.0,
802                raw_score: -self.cross_ecosystem.score_penalty,
803                weighted_score: -self.cross_ecosystem.score_penalty,
804                description: format!("applied to base score {base:.2}"),
805            })
806            .with_normalization("cross_ecosystem_db"),
807            TierOutcome::CrossEcosystemRejected { reason } => {
808                MatchExplanation::no_match(match reason {
809                    CrossEcoRejection::Disabled => format!(
810                        "'{}' ({}) and '{}' ({}) are from different ecosystems and cross-ecosystem matching is disabled",
811                        a.name,
812                        ecosystem_label(a),
813                        b.name,
814                        ecosystem_label(b),
815                    ),
816                    CrossEcoRejection::NotEquivalent => format!(
817                        "'{}' ({}) and '{}' ({}) are from different ecosystems with no curated equivalence — same-name packages in different ecosystems are treated as different packages",
818                        a.name,
819                        ecosystem_label(a),
820                        b.name,
821                        ecosystem_label(b),
822                    ),
823                    CrossEcoRejection::Unverified => format!(
824                        "'{}' and '{}' have an unverified cross-ecosystem equivalence and verified_only is set",
825                        a.name, b.name,
826                    ),
827                    CrossEcoRejection::BelowFloor { penalized } => format!(
828                        "cross-ecosystem score {:.2} below the configured floor {:.2} after the {:.2} penalty",
829                        penalized, self.cross_ecosystem.min_score, self.cross_ecosystem.score_penalty,
830                    ),
831                })
832            }
833            TierOutcome::Fuzzy { breakdown } => {
834                let is_match = scored.score >= self.config.threshold;
835                let mut explanation = if is_match {
836                    MatchExplanation::matched(
837                        MatchTier::Fuzzy,
838                        scored.score,
839                        format!(
840                            "Fuzzy match: '{}' ~ '{}' with {:.0}% similarity",
841                            a.name,
842                            b.name,
843                            scored.score * 100.0
844                        ),
845                    )
846                } else {
847                    MatchExplanation::no_match(format!(
848                        "Fuzzy similarity {:.2} below threshold {:.2}",
849                        scored.score, self.config.threshold
850                    ))
851                };
852
853                explanation = explanation
854                    .with_score_component(ScoreComponent {
855                        name: "Jaro-Winkler".to_string(),
856                        weight: self.config.jaro_winkler_weight,
857                        raw_score: breakdown.jw_score,
858                        weighted_score: breakdown.jw_score * self.config.jaro_winkler_weight,
859                        description: format!(
860                            "'{}' vs '{}' = {:.2}",
861                            a.name.to_lowercase(),
862                            b.name.to_lowercase(),
863                            breakdown.jw_score
864                        ),
865                    })
866                    .with_score_component(ScoreComponent {
867                        name: "Levenshtein".to_string(),
868                        weight: self.config.levenshtein_weight,
869                        raw_score: breakdown.lev_score,
870                        weighted_score: breakdown.lev_score * self.config.levenshtein_weight,
871                        description: format!(
872                            "edit distance {} / max_len {} = {:.2}",
873                            breakdown.lev_distance, breakdown.max_len, breakdown.lev_score
874                        ),
875                    })
876                    .with_score_component(ScoreComponent {
877                        name: "Token overlap".to_string(),
878                        weight: 1.0,
879                        raw_score: breakdown.token_score,
880                        weighted_score: breakdown.token_score,
881                        description: format!(
882                            "Jaccard on name tokens = {:.2}; final = max(char blend {:.2}, token, phonetic × 0.85)",
883                            breakdown.token_score, breakdown.char_score
884                        ),
885                    })
886                    .with_score_component(ScoreComponent {
887                        name: "Phonetic".to_string(),
888                        weight: 0.85,
889                        raw_score: breakdown.phonetic_score,
890                        weighted_score: breakdown.phonetic_score * 0.85,
891                        description: format!("Soundex similarity = {:.2}", breakdown.phonetic_score),
892                    });
893
894                if breakdown.version_boost > 0.0 {
895                    explanation = explanation.with_score_component(ScoreComponent {
896                        name: "Version boost".to_string(),
897                        weight: 1.0,
898                        raw_score: breakdown.version_boost,
899                        weighted_score: breakdown.version_boost,
900                        description: format!(
901                            "graduated semver similarity: {:?} vs {:?}",
902                            a.version, b.version
903                        ),
904                    });
905                }
906
907                if breakdown.capped {
908                    explanation =
909                        explanation.with_normalization("non_identical_name_cap_0.99");
910                }
911
912                explanation.with_normalization("lowercase")
913            }
914            TierOutcome::MultiField { result, capped } => {
915                let is_match = scored.score >= self.config.threshold;
916                let weights = self
917                    .config
918                    .field_weights
919                    .as_ref()
920                    .cloned()
921                    .unwrap_or_default();
922                let mut explanation = if is_match {
923                    MatchExplanation::matched(
924                        MatchTier::Fuzzy,
925                        scored.score,
926                        format!(
927                            "Multi-field match: '{}' ~ '{}' scoring {:.0}% across name/version/ecosystem/licenses/supplier/group",
928                            a.name,
929                            b.name,
930                            scored.score * 100.0
931                        ),
932                    )
933                } else {
934                    MatchExplanation::no_match(format!(
935                        "Multi-field score {:.2} below threshold {:.2}",
936                        scored.score, self.config.threshold
937                    ))
938                };
939
940                for (name, weight, raw) in [
941                    ("Name", weights.name, result.name_score),
942                    ("Version", weights.version, result.version_score),
943                    ("Ecosystem", weights.ecosystem, result.ecosystem_score),
944                    ("Licenses", weights.licenses, result.license_score),
945                    ("Supplier", weights.supplier, result.supplier_score),
946                    ("Group", weights.group, result.group_score),
947                ] {
948                    explanation = explanation.with_score_component(ScoreComponent {
949                        name: name.to_string(),
950                        weight,
951                        raw_score: raw,
952                        weighted_score: raw * weight,
953                        description: format!("{name} similarity = {raw:.2}"),
954                    });
955                }
956
957                if capped {
958                    explanation =
959                        explanation.with_normalization("non_identical_name_cap_0.99");
960                }
961
962                explanation.with_normalization("multi_field")
963            }
964        }
965    }
966}
967
968/// Ecosystem display label for explanation messages.
969fn ecosystem_label(comp: &Component) -> String {
970    comp.ecosystem
971        .as_ref()
972        .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string)
973}
974
975/// Whether an ecosystem value is canonically known — `Unknown(_)` and
976/// `Generic` carry no cross-ecosystem evidence and must not engage the gate.
977const fn is_known_ecosystem(eco: &crate::model::Ecosystem) -> bool {
978    !matches!(
979        eco,
980        crate::model::Ecosystem::Unknown(_) | crate::model::Ecosystem::Generic
981    )
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987
988    #[test]
989    fn test_exact_purl_match() {
990        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
991
992        let mut a = Component::new("lodash".to_string(), "comp-1".to_string());
993        a.identifiers.purl = Some("pkg:npm/lodash@4.17.21".to_string());
994
995        let mut b = Component::new("lodash".to_string(), "comp-2".to_string());
996        b.identifiers.purl = Some("pkg:npm/lodash@4.17.21".to_string());
997
998        assert_eq!(matcher.match_components(&a, &b), 1.0);
999    }
1000
1001    #[test]
1002    fn test_fuzzy_name_match() {
1003        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::permissive());
1004
1005        // Similar names should have some fuzzy match score
1006        let a = Component::new("lodash-es".to_string(), "comp-1".to_string());
1007        let b = Component::new("lodash".to_string(), "comp-2".to_string());
1008
1009        let score = matcher.match_components(&a, &b);
1010        // With permissive threshold (0.70), similar names should match
1011        assert!(
1012            score >= 0.70,
1013            "lodash-es vs lodash should have score >= 0.70, got {}",
1014            score
1015        );
1016    }
1017
1018    #[test]
1019    fn test_different_names_low_score() {
1020        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::strict());
1021
1022        let a = Component::new("react".to_string(), "comp-1".to_string());
1023        let b = Component::new("angular".to_string(), "comp-2".to_string());
1024
1025        let score = matcher.match_components(&a, &b);
1026        assert!(
1027            score < 0.5,
1028            "react vs angular should have low score, got {}",
1029            score
1030        );
1031    }
1032
1033    #[test]
1034    fn test_multi_field_weights_normalized() {
1035        let weights = config::MultiFieldWeights::balanced();
1036        assert!(
1037            weights.is_normalized(),
1038            "Balanced weights should be normalized"
1039        );
1040
1041        let weights = config::MultiFieldWeights::name_focused();
1042        assert!(
1043            weights.is_normalized(),
1044            "Name-focused weights should be normalized"
1045        );
1046
1047        let weights = config::MultiFieldWeights::security_focused();
1048        assert!(
1049            weights.is_normalized(),
1050            "Security-focused weights should be normalized"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_multi_field_scoring_same_component() {
1056        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced_multi_field());
1057        let weights = config::MultiFieldWeights::balanced();
1058
1059        let mut a = Component::new("lodash".to_string(), "comp-1".to_string());
1060        a.version = Some("4.17.21".to_string());
1061        a.ecosystem = Some(crate::model::Ecosystem::Npm);
1062
1063        // Identical component should score very high
1064        // Note: empty licenses/supplier/group get neutral 0.5 score, so total won't be 1.0
1065        let result = matcher.compute_multi_field_score(&a, &a, &weights);
1066        assert!(
1067            result.total > 0.90,
1068            "Same component should score > 0.90, got {}",
1069            result.total
1070        );
1071        assert_eq!(result.name_score, 1.0);
1072        assert_eq!(result.version_score, 1.0);
1073        assert_eq!(result.ecosystem_score, 1.0);
1074        // Empty fields get neutral 0.5 score
1075        assert_eq!(
1076            result.license_score, 0.5,
1077            "Empty licenses should be neutral"
1078        );
1079        assert_eq!(
1080            result.supplier_score, 0.5,
1081            "Empty supplier should be neutral"
1082        );
1083        assert_eq!(result.group_score, 0.5, "Empty group should be neutral");
1084    }
1085
1086    #[test]
1087    fn test_multi_field_scoring_different_versions() {
1088        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced_multi_field());
1089        let weights = config::MultiFieldWeights::balanced();
1090
1091        let mut a = Component::new("lodash".to_string(), "comp-1".to_string());
1092        a.version = Some("4.17.21".to_string());
1093        a.ecosystem = Some(crate::model::Ecosystem::Npm);
1094
1095        let mut b = Component::new("lodash".to_string(), "comp-2".to_string());
1096        b.version = Some("4.17.20".to_string()); // Different patch version
1097        b.ecosystem = Some(crate::model::Ecosystem::Npm);
1098
1099        let result = matcher.compute_multi_field_score(&a, &b, &weights);
1100
1101        // Name matches perfectly
1102        assert!(result.name_score > 0.9, "Name score should be > 0.9");
1103
1104        // Graduated version scoring: same major.minor gives high score
1105        // 4.17.21 vs 4.17.20 = same major.minor, patch diff of 1
1106        // Expected: 0.8 - 0.01 * 1 = 0.79
1107        assert!(
1108            result.version_score > 0.7,
1109            "Same major.minor with patch diff should score high, got {}",
1110            result.version_score
1111        );
1112
1113        // Ecosystem matches
1114        assert_eq!(
1115            result.ecosystem_score, 1.0,
1116            "Same ecosystem should score 1.0"
1117        );
1118
1119        // Total should be high due to name, ecosystem, and graduated version score
1120        assert!(
1121            result.total > 0.8,
1122            "Total should be > 0.8, got {}",
1123            result.total
1124        );
1125    }
1126
1127    #[test]
1128    fn test_multi_field_scoring_different_major_versions() {
1129        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced_multi_field());
1130        let weights = config::MultiFieldWeights::balanced();
1131
1132        let mut a = Component::new("lodash".to_string(), "comp-1".to_string());
1133        a.version = Some("4.17.21".to_string());
1134        a.ecosystem = Some(crate::model::Ecosystem::Npm);
1135
1136        let mut b = Component::new("lodash".to_string(), "comp-2".to_string());
1137        b.version = Some("3.10.0".to_string()); // Different major version
1138        b.ecosystem = Some(crate::model::Ecosystem::Npm);
1139
1140        let result = matcher.compute_multi_field_score(&a, &b, &weights);
1141
1142        // Graduated version scoring: different major gives low score
1143        // 4 vs 3 = major diff of 1
1144        // Expected: 0.3 - 0.10 * 1 = 0.20
1145        assert!(
1146            result.version_score < 0.3,
1147            "Different major versions should score low, got {}",
1148            result.version_score
1149        );
1150    }
1151
1152    #[test]
1153    fn test_multi_field_scoring_legacy_weights() {
1154        // Test that legacy weights disable graduated scoring
1155        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced_multi_field());
1156        let weights = config::MultiFieldWeights::legacy();
1157
1158        let mut a = Component::new("lodash".to_string(), "comp-1".to_string());
1159        a.version = Some("4.17.21".to_string());
1160        a.ecosystem = Some(crate::model::Ecosystem::Npm);
1161
1162        let mut b = Component::new("lodash".to_string(), "comp-2".to_string());
1163        b.version = Some("4.17.20".to_string());
1164        b.ecosystem = Some(crate::model::Ecosystem::Npm);
1165
1166        let result = matcher.compute_multi_field_score(&a, &b, &weights);
1167
1168        // Legacy mode: binary version scoring (exact match or 0)
1169        assert_eq!(
1170            result.version_score, 0.0,
1171            "Legacy mode: different versions should score 0"
1172        );
1173    }
1174
1175    #[test]
1176    fn test_multi_field_config_preset() {
1177        let config = FuzzyMatchConfig::from_preset("balanced-multi").unwrap();
1178        assert!(config.field_weights.is_some());
1179
1180        let config = FuzzyMatchConfig::from_preset("strict_multi").unwrap();
1181        assert!(config.field_weights.is_some());
1182    }
1183
1184    #[test]
1185    fn test_multi_field_score_result_summary() {
1186        let result = MultiFieldScoreResult {
1187            total: 0.85,
1188            name_score: 1.0,
1189            version_score: 0.0,
1190            ecosystem_score: 1.0,
1191            license_score: 0.5,
1192            supplier_score: 0.5,
1193            group_score: 0.5,
1194        };
1195
1196        let summary = result.summary();
1197        assert!(summary.contains("0.85"));
1198        assert!(summary.contains("name: 1.00"));
1199    }
1200
1201    #[test]
1202    fn test_token_similarity_exact() {
1203        let score = string_similarity::compute_token_similarity("react-dom", "react-dom");
1204        assert_eq!(score, 1.0);
1205    }
1206
1207    #[test]
1208    fn test_token_similarity_reordered() {
1209        // Reordered tokens should have high similarity
1210        let score = string_similarity::compute_token_similarity("react-dom", "dom-react");
1211        assert_eq!(score, 1.0, "Reordered tokens should match perfectly");
1212    }
1213
1214    #[test]
1215    fn test_token_similarity_partial() {
1216        // Partial token overlap
1217        let score = string_similarity::compute_token_similarity("react-dom-utils", "react-dom");
1218        // Jaccard: 2 common / 3 total = 0.667
1219        assert!(
1220            (score - 0.667).abs() < 0.01,
1221            "Partial overlap should be ~0.67, got {}",
1222            score
1223        );
1224    }
1225
1226    #[test]
1227    fn test_token_similarity_different_delimiters() {
1228        // Different delimiters should still work
1229        let score =
1230            string_similarity::compute_token_similarity("my_package_name", "my-package-name");
1231        assert_eq!(score, 1.0, "Different delimiters should match");
1232    }
1233
1234    #[test]
1235    fn test_token_similarity_no_overlap() {
1236        let score = string_similarity::compute_token_similarity("react", "angular");
1237        assert_eq!(score, 0.0, "No common tokens should score 0");
1238    }
1239
1240    #[test]
1241    fn test_version_similarity_exact() {
1242        let v1 = "1.2.3".to_string();
1243        let v2 = "1.2.3".to_string();
1244        let score = FuzzyMatcher::compute_version_similarity(Some(&v1), Some(&v2));
1245        assert_eq!(score, 0.10, "Exact version match should give max boost");
1246    }
1247
1248    #[test]
1249    fn test_version_similarity_same_major_minor() {
1250        let v1 = "1.2.3".to_string();
1251        let v2 = "1.2.4".to_string();
1252        let score = FuzzyMatcher::compute_version_similarity(Some(&v1), Some(&v2));
1253        assert_eq!(score, 0.07, "Same major.minor should give 0.07 boost");
1254    }
1255
1256    #[test]
1257    fn test_version_similarity_same_major() {
1258        let v1 = "1.2.3".to_string();
1259        let v2 = "1.5.0".to_string();
1260        let score = FuzzyMatcher::compute_version_similarity(Some(&v1), Some(&v2));
1261        assert_eq!(score, 0.04, "Same major should give 0.04 boost");
1262    }
1263
1264    #[test]
1265    fn test_version_similarity_different_major() {
1266        let v1 = "1.2.3".to_string();
1267        let v2 = "2.0.0".to_string();
1268        let score = FuzzyMatcher::compute_version_similarity(Some(&v1), Some(&v2));
1269        assert_eq!(score, 0.0, "Different major versions should give no boost");
1270    }
1271
1272    #[test]
1273    fn test_version_similarity_prerelease() {
1274        // Handle prerelease versions like "1.2.3-beta"
1275        let v1 = "1.2.3-beta".to_string();
1276        let v2 = "1.2.4".to_string();
1277        let score = FuzzyMatcher::compute_version_similarity(Some(&v1), Some(&v2));
1278        assert_eq!(score, 0.07, "Prerelease should still match major.minor");
1279    }
1280
1281    #[test]
1282    fn test_version_similarity_missing() {
1283        let v = "1.0.0".to_string();
1284        let score = FuzzyMatcher::compute_version_similarity(None, Some(&v));
1285        assert_eq!(score, 0.0, "Missing version should give no boost");
1286
1287        let score = FuzzyMatcher::compute_version_similarity(None, None);
1288        assert_eq!(score, 0.0, "Both missing should give no boost");
1289    }
1290
1291    #[test]
1292    fn test_fuzzy_match_with_reordered_tokens() {
1293        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::permissive());
1294
1295        let a = Component::new("react-dom".to_string(), "comp-1".to_string());
1296        let b = Component::new("dom-react".to_string(), "comp-2".to_string());
1297
1298        let score = matcher.match_components(&a, &b);
1299        // Token similarity is 1.0, blended with character similarity
1300        assert!(
1301            score > 0.5,
1302            "Reordered names should still match, got {}",
1303            score
1304        );
1305    }
1306
1307    #[test]
1308    fn test_fuzzy_match_version_boost() {
1309        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::permissive());
1310
1311        // Use slightly different names so we rely on fuzzy matching, not exact match
1312        let mut a = Component::new("lodash-utils".to_string(), "comp-1".to_string());
1313        a.version = Some("4.17.21".to_string());
1314
1315        let mut b = Component::new("lodash-util".to_string(), "comp-2".to_string());
1316        b.version = Some("4.17.20".to_string()); // Same major.minor -> +0.07 boost
1317
1318        let mut c = Component::new("lodash-util".to_string(), "comp-3".to_string());
1319        c.version = Some("5.0.0".to_string()); // Different major -> +0.0 boost
1320
1321        let score_same_minor = matcher.match_components(&a, &b);
1322        let score_diff_major = matcher.match_components(&a, &c);
1323
1324        // Both should match (fuzzy), but same_minor should have version boost
1325        assert!(score_same_minor > 0.0, "Same minor should match");
1326        assert!(score_diff_major > 0.0, "Different major should still match");
1327        assert!(
1328            score_same_minor > score_diff_major,
1329            "Same minor version should score higher: {} vs {}",
1330            score_same_minor,
1331            score_diff_major
1332        );
1333    }
1334
1335    #[test]
1336    fn test_soundex_basic() {
1337        // Test basic Soundex encoding
1338        assert_eq!(string_similarity::soundex("Robert"), "R163");
1339        assert_eq!(string_similarity::soundex("Rupert"), "R163"); // Same as Robert
1340        assert_eq!(string_similarity::soundex("Smith"), "S530");
1341        assert_eq!(string_similarity::soundex("Smyth"), "S530"); // Same as Smith
1342    }
1343
1344    #[test]
1345    fn test_soundex_empty() {
1346        assert_eq!(string_similarity::soundex(""), "");
1347        assert_eq!(string_similarity::soundex("123"), ""); // No letters
1348    }
1349
1350    #[test]
1351    fn test_phonetic_similarity_exact() {
1352        let score = string_similarity::compute_phonetic_similarity("color", "colour");
1353        assert_eq!(score, 1.0, "color and colour should match phonetically");
1354    }
1355
1356    #[test]
1357    fn test_phonetic_similarity_different() {
1358        let score = string_similarity::compute_phonetic_similarity("react", "angular");
1359        assert!(
1360            score < 0.5,
1361            "Different names should have low phonetic similarity"
1362        );
1363    }
1364
1365    #[test]
1366    fn test_phonetic_similarity_compound() {
1367        // Test compound names where tokens match phonetically
1368        let score = string_similarity::compute_phonetic_similarity("json-parser", "jayson-parser");
1369        assert!(
1370            score > 0.5,
1371            "Similar sounding compound names should match: {}",
1372            score
1373        );
1374    }
1375
1376    #[test]
1377    fn test_fuzzy_match_with_phonetic() {
1378        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::permissive());
1379
1380        let a = Component::new("color-utils".to_string(), "comp-1".to_string());
1381        let b = Component::new("colour-utils".to_string(), "comp-2".to_string());
1382
1383        let score = matcher.match_components(&a, &b);
1384        assert!(
1385            score > 0.7,
1386            "Phonetically similar names should match: {}",
1387            score
1388        );
1389    }
1390
1391    fn comp(name: &str, eco: Option<crate::model::Ecosystem>, version: &str) -> Component {
1392        let mut c = Component::new(name.to_string(), format!("test-{name}-{version}"));
1393        c.version = Some(version.to_string());
1394        c.ecosystem = eco;
1395        c
1396    }
1397
1398    /// Regression for the score-tier inversion: an exact-name counterpart
1399    /// must strictly outscore every near-miss neighbor, otherwise the
1400    /// assignment pairs components with the wrong neighbor (reproduced 6/6
1401    /// on convention-named fixtures before the fix).
1402    #[test]
1403    fn exact_name_match_dominates_fuzzy_neighbors() {
1404        use crate::model::Ecosystem;
1405        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
1406
1407        let old = comp("comp000001lib", Some(Ecosystem::Npm), "1.0.0");
1408        let twin = comp("comp000001lib", Some(Ecosystem::Npm), "2.0.0");
1409        let neighbor = comp("comp000002lib", Some(Ecosystem::Npm), "1.0.0");
1410
1411        let twin_score = matcher.match_components(&old, &twin);
1412        let neighbor_score = matcher.match_components(&old, &neighbor);
1413
1414        assert!(
1415            (twin_score - 1.0).abs() < 1e-9,
1416            "identical-name pair must score 1.0, got {twin_score}"
1417        );
1418        assert!(
1419            neighbor_score <= NON_IDENTICAL_NAME_CAP,
1420            "near-miss neighbor must be capped, got {neighbor_score}"
1421        );
1422        assert!(
1423            twin_score > neighbor_score,
1424            "exact-name counterpart must dominate: twin {twin_score} vs neighbor {neighbor_score}"
1425        );
1426    }
1427
1428    /// The cap also holds when the version boost would have pushed a
1429    /// non-identical name to 1.0, and does not apply to identical names.
1430    #[test]
1431    fn non_identical_names_never_reach_one() {
1432        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
1433
1434        // Same version -> +0.10 boost; uncapped this clamps to 1.0.
1435        let a = comp("libfoo-aaaa1", None, "1.0.0");
1436        let b = comp("libfoo-aaaa2", None, "1.0.0");
1437        let score = matcher.match_components(&a, &b);
1438        assert!(
1439            score > 0.0 && score <= NON_IDENTICAL_NAME_CAP,
1440            "boosted near-miss must stay below 1.0, got {score}"
1441        );
1442
1443        // Identical names without ecosystems flow through the fuzzy tier
1444        // uncapped and reach 1.0.
1445        let c = comp("libfoo", None, "1.0.0");
1446        let d = comp("libfoo", None, "3.0.0");
1447        assert!((matcher.match_components(&c, &d) - 1.0).abs() < 1e-9);
1448    }
1449
1450    /// Same-name packages in different ecosystems are DIFFERENT packages
1451    /// unless the curated DB says otherwise: npm/redis vs pypi/redis was
1452    /// previously merged at an unpenalized 1.0.
1453    #[test]
1454    fn same_name_different_ecosystem_does_not_match() {
1455        use crate::model::Ecosystem;
1456        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
1457
1458        let a = comp("redis", Some(Ecosystem::Npm), "4.6.0");
1459        let b = comp("redis", Some(Ecosystem::PyPi), "5.0.0");
1460
1461        assert_eq!(
1462            matcher.match_components(&a, &b),
1463            0.0,
1464            "npm/redis and pypi/redis must not merge"
1465        );
1466        let explanation = matcher.explain_match(&a, &b);
1467        assert!(!explanation.is_match);
1468        assert!(
1469            explanation.reason.contains("different ecosystems"),
1470            "rejection must be explained: {}",
1471            explanation.reason
1472        );
1473    }
1474
1475    /// Curated equivalents cross the gate with the configured penalty and
1476    /// surface the CrossEcosystem tier.
1477    #[test]
1478    fn curated_cross_ecosystem_equivalents_match_with_penalty() {
1479        use crate::model::Ecosystem;
1480        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
1481
1482        // "regex" is a builtin family spanning pypi and cargo.
1483        let a = comp("regex", Some(Ecosystem::PyPi), "2023.12.25");
1484        let b = comp("regex", Some(Ecosystem::Cargo), "1.10.2");
1485
1486        let score = matcher.match_components(&a, &b);
1487        let expected = 1.0 - CrossEcosystemConfig::default().score_penalty;
1488        assert!(
1489            (score - expected).abs() < 1e-9,
1490            "expected penalized {expected}, got {score}"
1491        );
1492
1493        let detailed = matcher.match_detailed(&a, &b);
1494        assert_eq!(detailed.tier, MatchTier::CrossEcosystem);
1495        let explanation = matcher.explain_match(&a, &b);
1496        assert!(explanation.is_match);
1497        assert_eq!(explanation.tier, MatchTier::CrossEcosystem);
1498    }
1499
1500    /// verified_only rejects unverified families; disabling the policy
1501    /// rejects everything cross-ecosystem.
1502    #[test]
1503    fn cross_ecosystem_policy_knobs_are_honored() {
1504        use crate::model::Ecosystem;
1505        use cross_ecosystem::{CrossEcosystemDb, PackageFamily};
1506
1507        let mut db = CrossEcosystemDb::new();
1508        db.add_family(
1509            PackageFamily::new("unverified-fam")
1510                .with_names(&Ecosystem::PyPi, &["fooberlib"])
1511                .with_names(&Ecosystem::Cargo, &["fooberlib"]),
1512        );
1513
1514        let a = comp("fooberlib", Some(Ecosystem::PyPi), "1.0.0");
1515        let b = comp("fooberlib", Some(Ecosystem::Cargo), "1.0.0");
1516
1517        let permissive_matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced())
1518            .with_cross_ecosystem_db(db.clone())
1519            .with_cross_ecosystem(CrossEcosystemConfig {
1520                verified_only: false,
1521                ..CrossEcosystemConfig::default()
1522            });
1523        assert!(permissive_matcher.match_components(&a, &b) > 0.0);
1524
1525        let strict_matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced())
1526            .with_cross_ecosystem_db(db)
1527            .with_cross_ecosystem(CrossEcosystemConfig {
1528                verified_only: true,
1529                ..CrossEcosystemConfig::default()
1530            });
1531        assert_eq!(strict_matcher.match_components(&a, &b), 0.0);
1532
1533        let disabled_matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced())
1534            .with_cross_ecosystem(CrossEcosystemConfig::disabled());
1535        assert_eq!(disabled_matcher.match_components(&a, &b), 0.0);
1536    }
1537
1538    /// match_score, match_detailed, and explain_match must agree — a matched
1539    /// pair carrying a "no match" explanation was possible when the three
1540    /// methods used three different formulas.
1541    #[test]
1542    fn score_detailed_and_explanation_agree() {
1543        use crate::model::Ecosystem;
1544
1545        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced())
1546            .with_alias_table(AliasTable::with_builtins());
1547
1548        let pairs = [
1549            // token-reorder pair: matched via token similarity, previously
1550            // labeled no-match by explain_match's jw+lev-only formula
1551            (
1552                comp("react-dom", None, "18.0.0"),
1553                comp("dom-react", None, "18.0.0"),
1554            ),
1555            (
1556                comp("lodash", Some(Ecosystem::Npm), "4.0.0"),
1557                comp("lodash", Some(Ecosystem::Npm), "5.0.0"),
1558            ),
1559            (
1560                comp("redis", Some(Ecosystem::Npm), "4.0.0"),
1561                comp("redis", Some(Ecosystem::PyPi), "5.0.0"),
1562            ),
1563            (
1564                comp("regex", Some(Ecosystem::PyPi), "2023.1.1"),
1565                comp("regex", Some(Ecosystem::Cargo), "1.10.0"),
1566            ),
1567            (comp("PIL", None, "9.0.0"), comp("pillow", None, "10.0.0")),
1568            (
1569                comp("react", None, "18.0.0"),
1570                comp("angular", None, "17.0.0"),
1571            ),
1572            (
1573                comp("lodash-utils", None, "1.2.3"),
1574                comp("lodash-util", None, "1.2.4"),
1575            ),
1576        ];
1577
1578        for (a, b) in &pairs {
1579            let score = matcher.match_components(a, b);
1580            let detailed = matcher.match_detailed(a, b);
1581            let explanation = matcher.explain_match(a, b);
1582
1583            assert!(
1584                (score - detailed.score).abs() < 1e-9 || (score == 0.0 && !detailed.is_match()),
1585                "match_score {score} disagrees with match_detailed {} for '{}' vs '{}'",
1586                detailed.score,
1587                a.name,
1588                b.name
1589            );
1590            assert_eq!(
1591                score > 0.0,
1592                explanation.is_match,
1593                "match_score {score} disagrees with explanation '{}' for '{}' vs '{}'",
1594                explanation.reason,
1595                a.name,
1596                b.name
1597            );
1598            if explanation.is_match {
1599                assert!(
1600                    (score - explanation.score).abs() < 1e-9,
1601                    "explanation score {} != match score {score} for '{}' vs '{}'",
1602                    explanation.score,
1603                    a.name,
1604                    b.name
1605                );
1606            }
1607        }
1608    }
1609
1610    /// Audit regression: a fully-penalized cross-ecosystem pair must be a
1611    /// rejection even when min_score is configured 0.0 — "matched at 0.0"
1612    /// made the three views disagree.
1613    #[test]
1614    fn fully_penalized_cross_ecosystem_pair_is_rejected() {
1615        use crate::model::Ecosystem;
1616        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced()).with_cross_ecosystem(
1617            CrossEcosystemConfig {
1618                min_score: 0.0,
1619                score_penalty: 1.0,
1620                ..CrossEcosystemConfig::default()
1621            },
1622        );
1623
1624        let a = comp("regex", Some(Ecosystem::PyPi), "2023.1.1");
1625        let b = comp("regex", Some(Ecosystem::Cargo), "1.10.0");
1626
1627        assert_eq!(matcher.match_components(&a, &b), 0.0);
1628        let explanation = matcher.explain_match(&a, &b);
1629        assert!(
1630            !explanation.is_match,
1631            "a 0.0 score must never be explained as a match: {}",
1632            explanation.reason
1633        );
1634        assert!(!matcher.match_detailed(&a, &b).is_match());
1635    }
1636
1637    /// Audit regression: the exact-name anchor must hold in MULTI-FIELD mode
1638    /// with missing ecosystem data — previously the twin scored below
1639    /// threshold there while a near-miss neighbor passed, reintroducing the
1640    /// wrong-neighbor inversion the rewrite exists to fix.
1641    #[test]
1642    fn multi_field_mode_keeps_exact_name_dominance() {
1643        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced_multi_field());
1644
1645        let old = comp("libfoo-aaaa1", None, "1.0.0");
1646        let twin = comp("libfoo-aaaa1", None, "2.0.0");
1647        let neighbor = comp("libfoo-aaaa2", None, "1.0.0");
1648
1649        let twin_score = matcher.match_components(&old, &twin);
1650        let neighbor_score = matcher.match_components(&old, &neighbor);
1651
1652        assert!(
1653            (twin_score - 1.0).abs() < 1e-9,
1654            "identity anchor must hold in multi-field mode, got {twin_score}"
1655        );
1656        assert!(
1657            twin_score > neighbor_score,
1658            "twin must dominate neighbor in multi-field mode: {twin_score} vs {neighbor_score}"
1659        );
1660    }
1661
1662    /// Audit regression: Unknown/Generic ecosystems carry no cross-ecosystem
1663    /// evidence — a non-canonical purl-type spelling from another tool must
1664    /// not hard-reject an identical-name pair.
1665    #[test]
1666    fn unknown_ecosystem_does_not_engage_the_gate() {
1667        use crate::model::Ecosystem;
1668        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
1669
1670        let a = comp(
1671            "rails",
1672            Some(Ecosystem::Unknown("rubygems".to_string())),
1673            "7.0.0",
1674        );
1675        let b = comp("rails", Some(Ecosystem::RubyGems), "7.1.0");
1676
1677        let score = matcher.match_components(&a, &b);
1678        assert!(
1679            (score - 1.0).abs() < 1e-9,
1680            "Unknown('rubygems') vs RubyGems with identical names must match, got {score}"
1681        );
1682        assert_eq!(matcher.match_detailed(&a, &b).tier, MatchTier::NameIdentity);
1683    }
1684
1685    /// Identity must outrank the alias tier: an alias-listed name compared
1686    /// with itself is an identical-name pair (1.0), not an alias pair (0.95).
1687    #[test]
1688    fn identical_names_outrank_alias_tier() {
1689        let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced())
1690            .with_alias_table(AliasTable::with_builtins());
1691
1692        let a = comp("pillow", None, "9.0.0");
1693        let b = comp("pillow", None, "10.0.0");
1694
1695        assert!(
1696            (matcher.match_components(&a, &b) - 1.0).abs() < 1e-9,
1697            "identical alias-listed names must hit the identity tier at 1.0"
1698        );
1699    }
1700
1701    /// The use_aliases knob gates the alias tier (previously dead config).
1702    #[test]
1703    fn use_aliases_knob_gates_alias_tier() {
1704        let table = AliasTable::with_builtins();
1705        let a = comp("PIL", None, "9.0.0");
1706        let b = comp("pillow", None, "10.0.0");
1707
1708        let enabled =
1709            FuzzyMatcher::new(FuzzyMatchConfig::balanced()).with_alias_table(table.clone());
1710        assert_eq!(
1711            enabled.match_components(&a, &b),
1712            0.95,
1713            "builtin alias PIL/pillow should match via alias tier"
1714        );
1715
1716        let disabled = FuzzyMatcher::new(FuzzyMatchConfig {
1717            use_aliases: false,
1718            ..FuzzyMatchConfig::balanced()
1719        })
1720        .with_alias_table(table);
1721        assert!(
1722            disabled.match_components(&a, &b) < 0.95,
1723            "use_aliases=false must bypass the alias tier"
1724        );
1725    }
1726
1727    /// The use_ecosystem_rules knob gates normalized-name equality
1728    /// (previously dead config). Identical raw names still match via fuzzy.
1729    #[test]
1730    fn use_ecosystem_rules_knob_gates_normalization() {
1731        use crate::model::Ecosystem;
1732        let a = comp("Python_Dateutil", Some(Ecosystem::PyPi), "2.8.0");
1733        let b = comp("python-dateutil", Some(Ecosystem::PyPi), "2.9.0");
1734
1735        let enabled = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
1736        assert!(
1737            (enabled.match_components(&a, &b) - 1.0).abs() < 1e-9,
1738            "ecosystem-normalized equal names must score 1.0"
1739        );
1740
1741        let disabled = FuzzyMatcher::new(FuzzyMatchConfig {
1742            use_ecosystem_rules: false,
1743            ..FuzzyMatchConfig::balanced()
1744        });
1745        let score = disabled.match_components(&a, &b);
1746        assert!(
1747            score > 0.0 && score < 1.0,
1748            "knob off: normalization variants fall to capped fuzzy, got {score}"
1749        );
1750    }
1751}