1pub 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
78const NON_IDENTICAL_NAME_CAP: f64 = 0.99;
88
89#[must_use]
91pub struct FuzzyMatcher {
92 config: FuzzyMatchConfig,
93 alias_table: AliasTable,
94 purl_normalizer: PurlNormalizer,
95 ecosystem_rules: EcosystemRules,
96 cross_ecosystem: CrossEcosystemConfig,
101 cross_ecosystem_db: Option<CrossEcosystemDb>,
102}
103
104impl FuzzyMatcher {
105 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 #[must_use]
127 pub const fn config(&self) -> &FuzzyMatchConfig {
128 &self.config
129 }
130
131 pub fn with_alias_table(mut self, table: AliasTable) -> Self {
133 self.alias_table = table;
134 self
135 }
136
137 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 pub fn with_cross_ecosystem_db(mut self, db: CrossEcosystemDb) -> Self {
149 self.cross_ecosystem_db = Some(db);
150 self
151 }
152
153 #[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 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 fn score_pair(&self, a: &Component, b: &Component) -> ScoredPair {
198 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 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 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 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 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 self.fuzzy_or_multi_field(a, b, identical_names)
287 }
288
289 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 fn check_alias_match(&self, a: &Component, b: &Component) -> bool {
318 if self.alias_table.is_empty() {
321 return false;
322 }
323
324 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 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 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 fn extract_name_from_purl(&self, purl: &str) -> Option<String> {
358 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 let name = name_part.split('@').next()?;
366 Some(name.to_string())
367 } else {
368 None
369 }
370 }
371
372 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 fn compute_fuzzy_score(&self, a: &Component, b: &Component) -> f64 {
389 self.fuzzy_breakdown(a, b, true).total
392 }
393
394 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 let jw_score = jaro_winkler(&name_a, &name_b);
408
409 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 let token_score = Self::compute_token_similarity(&name_a, &name_b);
420
421 let phonetic_score = Self::compute_phonetic_similarity(&name_a, &name_b);
423
424 let char_score = jw_score.mul_add(
426 self.config.jaro_winkler_weight,
427 lev_score * self.config.levenshtein_weight,
428 );
429
430 let combined = char_score.max(token_score).max(phonetic_score * 0.85);
433
434 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 fn compute_token_similarity(name_a: &str, name_b: &str) -> f64 {
462 string_similarity::compute_token_similarity(name_a, name_b)
463 }
464
465 fn compute_version_similarity(va: Option<&String>, vb: Option<&String>) -> f64 {
467 string_similarity::compute_version_similarity(va, vb)
468 }
469
470 #[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 #[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 let name_score = self.compute_fuzzy_score(a, b);
495 result.name_score = name_score;
496 result.total += name_score * weights.name;
497
498 let version_score = if weights.version_divergence_enabled {
500 scoring::compute_version_divergence_score(&a.version, &b.version, weights)
501 } else {
502 match (&a.version, &b.version) {
504 (Some(va), Some(vb)) if va == vb => 1.0,
505 (None, None) => 0.5, _ => 0.0,
507 }
508 };
509 result.version_score = version_score;
510 result.total += version_score * weights.version;
511
512 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), (Some(_), Some(_)) => (0.0, weights.ecosystem_mismatch_penalty), _ => (0.0, 0.0), };
519 result.ecosystem_score = ecosystem_score;
520 result.total += ecosystem_score.mul_add(weights.ecosystem, ecosystem_penalty);
521
522 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 } else if licenses_a.is_empty() || licenses_b.is_empty() {
538 0.0 } 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 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, _ => 0.0,
556 };
557 result.supplier_score = supplier_score;
558 result.total += supplier_score * weights.supplier;
559
560 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, _ => 0.0,
565 };
566 result.group_score = group_score;
567 result.total += group_score * weights.group;
568
569 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
582struct 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
598enum TierOutcome {
600 ExactPurl,
601 Alias,
602 EcosystemRule,
603 NameIdentity,
606 CrossEcosystem {
607 base: f64,
609 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
624enum CrossEcoRejection {
626 Disabled,
627 NotEquivalent,
628 Unverified,
629 BelowFloor { penalized: f64 },
630}
631
632struct 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
968fn 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
975const 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 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 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 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 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()); b.ecosystem = Some(crate::model::Ecosystem::Npm);
1098
1099 let result = matcher.compute_multi_field_score(&a, &b, &weights);
1100
1101 assert!(result.name_score > 0.9, "Name score should be > 0.9");
1103
1104 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 assert_eq!(
1115 result.ecosystem_score, 1.0,
1116 "Same ecosystem should score 1.0"
1117 );
1118
1119 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()); b.ecosystem = Some(crate::model::Ecosystem::Npm);
1139
1140 let result = matcher.compute_multi_field_score(&a, &b, &weights);
1141
1142 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 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 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 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 let score = string_similarity::compute_token_similarity("react-dom-utils", "react-dom");
1218 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 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 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 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 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()); let mut c = Component::new("lodash-util".to_string(), "comp-3".to_string());
1319 c.version = Some("5.0.0".to_string()); let score_same_minor = matcher.match_components(&a, &b);
1322 let score_diff_major = matcher.match_components(&a, &c);
1323
1324 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 assert_eq!(string_similarity::soundex("Robert"), "R163");
1339 assert_eq!(string_similarity::soundex("Rupert"), "R163"); assert_eq!(string_similarity::soundex("Smith"), "S530");
1341 assert_eq!(string_similarity::soundex("Smyth"), "S530"); }
1343
1344 #[test]
1345 fn test_soundex_empty() {
1346 assert_eq!(string_similarity::soundex(""), "");
1347 assert_eq!(string_similarity::soundex("123"), ""); }
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 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 #[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 #[test]
1431 fn non_identical_names_never_reach_one() {
1432 let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
1433
1434 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 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 #[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 #[test]
1478 fn curated_cross_ecosystem_equivalents_match_with_penalty() {
1479 use crate::model::Ecosystem;
1480 let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
1481
1482 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 #[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 #[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 (
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 #[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 #[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 #[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 #[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 #[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 #[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}