Skip to main content

sbom_tools/matching/
traits.rs

1//! Trait definitions for component matching strategies.
2//!
3//! This module provides abstractions for component matching, enabling
4//! pluggable matching strategies and easier testing.
5
6use crate::model::Component;
7
8/// Result of matching two components.
9#[derive(Debug, Clone)]
10#[must_use]
11pub struct MatchResult {
12    /// The matching confidence score (0.0 - 1.0)
13    pub score: f64,
14    /// The matching tier that produced this result
15    pub tier: MatchTier,
16    /// Additional metadata about the match
17    pub metadata: MatchMetadata,
18}
19
20impl MatchResult {
21    /// Create a new match result
22    pub fn new(score: f64, tier: MatchTier) -> Self {
23        Self {
24            score,
25            tier,
26            metadata: MatchMetadata::default(),
27        }
28    }
29
30    /// Create a match result with metadata
31    pub const fn with_metadata(score: f64, tier: MatchTier, metadata: MatchMetadata) -> Self {
32        Self {
33            score,
34            tier,
35            metadata,
36        }
37    }
38
39    /// Create a no-match result
40    pub fn no_match() -> Self {
41        Self {
42            score: 0.0,
43            tier: MatchTier::None,
44            metadata: MatchMetadata::default(),
45        }
46    }
47
48    /// Check if this represents a successful match
49    #[must_use]
50    pub fn is_match(&self) -> bool {
51        self.score > 0.0 && self.tier != MatchTier::None
52    }
53}
54
55/// The tier/level at which a match was found.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57#[non_exhaustive]
58pub enum MatchTier {
59    /// No match found
60    None,
61    /// Exact identifier match (PURL, CPE, etc.)
62    ExactIdentifier,
63    /// Match via alias table
64    Alias,
65    /// Match via ecosystem-specific rules
66    EcosystemRule,
67    /// Case-insensitive identical names (ecosystem info missing or partial)
68    NameIdentity,
69    /// Match via fuzzy string similarity
70    Fuzzy,
71    /// Match via custom user rules
72    CustomRule,
73    /// Match across different ecosystems via the curated equivalence DB
74    /// (score carries the configured cross-ecosystem penalty)
75    CrossEcosystem,
76}
77
78impl MatchTier {
79    /// Get the default confidence score for this tier
80    #[must_use]
81    pub const fn default_score(&self) -> f64 {
82        match self {
83            Self::None => 0.0,
84            Self::ExactIdentifier => 1.0,
85            Self::NameIdentity => 0.98,
86            Self::Alias => 0.95,
87            Self::EcosystemRule => 0.90,
88            Self::CustomRule => 0.92,
89            Self::CrossEcosystem => 0.85,
90            Self::Fuzzy => 0.80,
91        }
92    }
93}
94
95/// Additional metadata about a match.
96#[derive(Debug, Clone, Default)]
97pub struct MatchMetadata {
98    /// The field(s) that matched
99    pub matched_fields: Vec<String>,
100    /// The normalization applied, if any
101    pub normalization: Option<String>,
102    /// The rule that produced the match, if applicable
103    pub rule_id: Option<String>,
104}
105
106/// Human-readable explanation of why two components matched (or didn't).
107///
108/// Useful for debugging match decisions and auditing SBOM diff results.
109#[derive(Debug, Clone)]
110pub struct MatchExplanation {
111    /// The matching tier that produced this result
112    pub tier: MatchTier,
113    /// The final confidence score
114    pub score: f64,
115    /// Human-readable reason for the match/non-match
116    pub reason: String,
117    /// Detailed breakdown of score components
118    pub score_breakdown: Vec<ScoreComponent>,
119    /// Normalizations that were applied
120    pub normalizations_applied: Vec<String>,
121    /// Whether this was a successful match
122    pub is_match: bool,
123}
124
125/// A component of the overall match score.
126#[derive(Debug, Clone)]
127pub struct ScoreComponent {
128    /// Name of this score component
129    pub name: String,
130    /// Weight applied to this component
131    pub weight: f64,
132    /// Raw score before weighting
133    pub raw_score: f64,
134    /// Weighted contribution to final score
135    pub weighted_score: f64,
136    /// Description of what was compared
137    pub description: String,
138}
139
140impl MatchExplanation {
141    /// Create an explanation for a successful match.
142    pub fn matched(tier: MatchTier, score: f64, reason: impl Into<String>) -> Self {
143        Self {
144            tier,
145            score,
146            reason: reason.into(),
147            score_breakdown: Vec::new(),
148            normalizations_applied: Vec::new(),
149            is_match: true,
150        }
151    }
152
153    /// Create an explanation for a failed match.
154    pub fn no_match(reason: impl Into<String>) -> Self {
155        Self {
156            tier: MatchTier::None,
157            score: 0.0,
158            reason: reason.into(),
159            score_breakdown: Vec::new(),
160            normalizations_applied: Vec::new(),
161            is_match: false,
162        }
163    }
164
165    /// Add a score component to the breakdown.
166    #[must_use]
167    pub fn with_score_component(mut self, component: ScoreComponent) -> Self {
168        self.score_breakdown.push(component);
169        self
170    }
171
172    /// Add a normalization that was applied.
173    #[must_use]
174    pub fn with_normalization(mut self, normalization: impl Into<String>) -> Self {
175        self.normalizations_applied.push(normalization.into());
176        self
177    }
178
179    /// Generate a human-readable summary of the match.
180    #[must_use]
181    pub fn summary(&self) -> String {
182        if self.is_match {
183            format!(
184                "MATCH ({:.0}% confidence via {:?}): {}",
185                self.score * 100.0,
186                self.tier,
187                self.reason
188            )
189        } else {
190            format!("NO MATCH: {}", self.reason)
191        }
192    }
193
194    /// Generate a detailed multi-line explanation.
195    #[must_use]
196    pub fn detailed(&self) -> String {
197        let mut lines = vec![self.summary()];
198
199        if !self.score_breakdown.is_empty() {
200            lines.push("Score breakdown:".to_string());
201            for component in &self.score_breakdown {
202                lines.push(format!(
203                    "  - {}: {:.2} × {:.2} = {:.2} ({})",
204                    component.name,
205                    component.raw_score,
206                    component.weight,
207                    component.weighted_score,
208                    component.description
209                ));
210            }
211        }
212
213        if !self.normalizations_applied.is_empty() {
214            lines.push(format!(
215                "Normalizations: {}",
216                self.normalizations_applied.join(", ")
217            ));
218        }
219
220        lines.join("\n")
221    }
222}
223
224impl std::fmt::Display for MatchExplanation {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        write!(f, "{}", self.summary())
227    }
228}
229
230/// Trait for component matching strategies.
231///
232/// Implementors provide different strategies for determining if two
233/// components represent the same logical package across SBOMs.
234///
235/// # Example
236///
237/// ```ignore
238/// use sbom_tools::matching::{ComponentMatcher, FuzzyMatcher, FuzzyMatchConfig};
239///
240/// let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
241/// let score = matcher.match_score(&component_a, &component_b);
242/// ```
243pub trait ComponentMatcher: Send + Sync {
244    /// Compute a match score between two components.
245    ///
246    /// Returns a score between 0.0 (no match) and 1.0 (perfect match).
247    fn match_score(&self, a: &Component, b: &Component) -> f64;
248
249    /// Compute a detailed match result between two components.
250    ///
251    /// Returns a `MatchResult` with score, tier, and metadata.
252    fn match_detailed(&self, a: &Component, b: &Component) -> MatchResult {
253        let score = self.match_score(a, b);
254        if score > 0.0 {
255            MatchResult::new(score, MatchTier::Fuzzy)
256        } else {
257            MatchResult::no_match()
258        }
259    }
260
261    /// Generate a human-readable explanation of why two components matched or didn't.
262    ///
263    /// Useful for debugging and auditing match decisions.
264    fn explain_match(&self, a: &Component, b: &Component) -> MatchExplanation {
265        let result = self.match_detailed(a, b);
266        if result.is_match() {
267            MatchExplanation::matched(
268                result.tier,
269                result.score,
270                format!("'{}' matches '{}' via {:?}", a.name, b.name, result.tier),
271            )
272        } else {
273            MatchExplanation::no_match(format!(
274                "'{}' does not match '{}' (score {:.2} below threshold)",
275                a.name, b.name, result.score
276            ))
277        }
278    }
279
280    /// Find the best matching component from a list of candidates.
281    ///
282    /// Returns the best match and its score, or None if no match meets the threshold.
283    fn find_best_match<'a>(
284        &self,
285        target: &Component,
286        candidates: &'a [&Component],
287        threshold: f64,
288    ) -> Option<(&'a Component, f64)> {
289        candidates
290            .iter()
291            .map(|c| (*c, self.match_score(target, c)))
292            .filter(|(_, score)| *score >= threshold)
293            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
294    }
295
296    /// Get the name of this matcher for logging/debugging.
297    fn name(&self) -> &'static str {
298        "ComponentMatcher"
299    }
300
301    /// Get the minimum threshold this matcher uses for fuzzy matching.
302    fn threshold(&self) -> f64 {
303        0.0
304    }
305}
306
307/// Configuration for the cached matcher.
308#[derive(Debug, Clone)]
309pub struct CacheConfig {
310    /// Maximum number of entries in the cache.
311    pub max_entries: usize,
312    /// Whether to cache detailed results (more memory).
313    pub cache_detailed: bool,
314}
315
316impl Default for CacheConfig {
317    fn default() -> Self {
318        Self {
319            max_entries: 100_000,
320            cache_detailed: false,
321        }
322    }
323}
324
325impl CacheConfig {
326    /// Create a config optimized for small SBOMs.
327    #[must_use]
328    pub const fn small() -> Self {
329        Self {
330            max_entries: 10_000,
331            cache_detailed: true,
332        }
333    }
334
335    /// Create a config optimized for large SBOMs.
336    #[must_use]
337    pub const fn large() -> Self {
338        Self {
339            max_entries: 500_000,
340            cache_detailed: false,
341        }
342    }
343}
344
345/// Cache key combining component IDs.
346#[derive(Hash, Eq, PartialEq, Clone)]
347struct CacheKey {
348    hash: u64,
349}
350
351impl CacheKey {
352    fn new(a_id: &str, b_id: &str) -> Self {
353        use xxhash_rust::xxh3::xxh3_64;
354
355        // Create a combined key - order-independent for symmetry
356        let (first, second) = if a_id < b_id {
357            (a_id, b_id)
358        } else {
359            (b_id, a_id)
360        };
361
362        let combined = format!("{first}|{second}");
363        Self {
364            hash: xxh3_64(combined.as_bytes()),
365        }
366    }
367}
368
369/// Cached match result entry.
370#[derive(Clone)]
371struct CacheEntry {
372    score: f64,
373    detailed: Option<MatchResult>,
374}
375
376/// A wrapper that caches match results for performance.
377///
378/// The cache uses component IDs to generate cache keys and stores
379/// match scores for quick lookup. This is particularly effective when
380/// the same component pairs are compared multiple times.
381///
382/// # Example
383///
384/// ```ignore
385/// use sbom_tools::matching::{CachedMatcher, FuzzyMatcher, FuzzyMatchConfig, CacheConfig};
386///
387/// let matcher = FuzzyMatcher::new(FuzzyMatchConfig::balanced());
388/// let cached = CachedMatcher::new(matcher);
389/// // Or with custom config:
390/// let cached = CachedMatcher::with_config(matcher, CacheConfig::large());
391/// ```
392pub struct CachedMatcher<M: ComponentMatcher> {
393    inner: M,
394    config: CacheConfig,
395    cache: std::sync::RwLock<std::collections::HashMap<CacheKey, CacheEntry>>,
396    stats: std::sync::atomic::AtomicUsize,
397    hits: std::sync::atomic::AtomicUsize,
398}
399
400impl<M: ComponentMatcher> CachedMatcher<M> {
401    /// Create a new cached matcher wrapping the given matcher.
402    pub fn new(inner: M) -> Self {
403        Self::with_config(inner, CacheConfig::default())
404    }
405
406    /// Create a cached matcher with custom configuration.
407    pub fn with_config(inner: M, config: CacheConfig) -> Self {
408        Self {
409            inner,
410            config,
411            cache: std::sync::RwLock::new(std::collections::HashMap::new()),
412            stats: std::sync::atomic::AtomicUsize::new(0),
413            hits: std::sync::atomic::AtomicUsize::new(0),
414        }
415    }
416
417    /// Get a reference to the inner matcher.
418    pub const fn inner(&self) -> &M {
419        &self.inner
420    }
421
422    /// Get cache statistics.
423    pub fn cache_stats(&self) -> CacheStats {
424        let total = self.stats.load(std::sync::atomic::Ordering::Relaxed);
425        let hits = self.hits.load(std::sync::atomic::Ordering::Relaxed);
426        let size = self.cache.read().map(|c| c.len()).unwrap_or(0);
427        CacheStats {
428            total_lookups: total,
429            cache_hits: hits,
430            cache_misses: total.saturating_sub(hits),
431            hit_rate: if total > 0 {
432                hits as f64 / total as f64
433            } else {
434                0.0
435            },
436            cache_size: size,
437        }
438    }
439
440    /// Clear the cache.
441    pub fn clear_cache(&self) {
442        if let Ok(mut cache) = self.cache.write() {
443            cache.clear();
444        }
445        self.stats.store(0, std::sync::atomic::Ordering::Relaxed);
446        self.hits.store(0, std::sync::atomic::Ordering::Relaxed);
447    }
448
449    /// Try to get a cached score.
450    fn get_cached(&self, key: &CacheKey) -> Option<CacheEntry> {
451        self.stats
452            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
453        if let Ok(cache) = self.cache.read()
454            && let Some(entry) = cache.get(key)
455        {
456            self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
457            return Some(entry.clone());
458        }
459        None
460    }
461
462    /// Store a result in the cache.
463    fn store_cached(&self, key: CacheKey, entry: CacheEntry) {
464        if let Ok(mut cache) = self.cache.write() {
465            // Simple eviction: clear half the cache when full
466            if cache.len() >= self.config.max_entries {
467                let to_remove: Vec<CacheKey> = cache
468                    .keys()
469                    .take(self.config.max_entries / 2)
470                    .cloned()
471                    .collect();
472                for k in to_remove {
473                    cache.remove(&k);
474                }
475            }
476            cache.insert(key, entry);
477        }
478    }
479}
480
481/// Cache statistics.
482#[derive(Debug, Clone)]
483pub struct CacheStats {
484    /// Total number of cache lookups.
485    pub total_lookups: usize,
486    /// Number of cache hits.
487    pub cache_hits: usize,
488    /// Number of cache misses.
489    pub cache_misses: usize,
490    /// Hit rate (0.0 - 1.0).
491    pub hit_rate: f64,
492    /// Current cache size.
493    pub cache_size: usize,
494}
495
496impl<M: ComponentMatcher> ComponentMatcher for CachedMatcher<M> {
497    fn match_score(&self, a: &Component, b: &Component) -> f64 {
498        let key = CacheKey::new(a.canonical_id.value(), b.canonical_id.value());
499
500        // Check cache first
501        if let Some(entry) = self.get_cached(&key) {
502            return entry.score;
503        }
504
505        // Compute and cache
506        let score = self.inner.match_score(a, b);
507        self.store_cached(
508            key,
509            CacheEntry {
510                score,
511                detailed: None,
512            },
513        );
514        score
515    }
516
517    fn match_detailed(&self, a: &Component, b: &Component) -> MatchResult {
518        if !self.config.cache_detailed {
519            return self.inner.match_detailed(a, b);
520        }
521
522        let key = CacheKey::new(a.canonical_id.value(), b.canonical_id.value());
523
524        // Check cache for detailed result
525        if let Some(entry) = self.get_cached(&key)
526            && let Some(detailed) = entry.detailed
527        {
528            return detailed;
529        }
530
531        // Compute and cache
532        let result = self.inner.match_detailed(a, b);
533        self.store_cached(
534            key,
535            CacheEntry {
536                score: result.score,
537                detailed: Some(result.clone()),
538            },
539        );
540        result
541    }
542
543    fn explain_match(&self, a: &Component, b: &Component) -> MatchExplanation {
544        // Don't cache explanations as they're typically for debugging
545        self.inner.explain_match(a, b)
546    }
547
548    fn name(&self) -> &'static str {
549        "CachedMatcher"
550    }
551
552    fn threshold(&self) -> f64 {
553        self.inner.threshold()
554    }
555}
556
557/// A composite matcher that tries multiple strategies in order.
558#[must_use]
559pub struct CompositeMatcherBuilder {
560    matchers: Vec<Box<dyn ComponentMatcher>>,
561}
562
563impl CompositeMatcherBuilder {
564    /// Create a new composite matcher builder.
565    pub fn new() -> Self {
566        Self {
567            matchers: Vec::new(),
568        }
569    }
570
571    /// Add a matcher to the composite.
572    pub fn with_matcher(mut self, matcher: Box<dyn ComponentMatcher>) -> Self {
573        self.matchers.push(matcher);
574        self
575    }
576
577    /// Build the composite matcher.
578    #[must_use]
579    pub fn build(self) -> CompositeMatcher {
580        CompositeMatcher {
581            matchers: self.matchers,
582        }
583    }
584}
585
586impl Default for CompositeMatcherBuilder {
587    fn default() -> Self {
588        Self::new()
589    }
590}
591
592/// A matcher that combines multiple matching strategies.
593pub struct CompositeMatcher {
594    matchers: Vec<Box<dyn ComponentMatcher>>,
595}
596
597impl ComponentMatcher for CompositeMatcher {
598    fn match_score(&self, a: &Component, b: &Component) -> f64 {
599        // Return the highest score from any matcher
600        self.matchers
601            .iter()
602            .map(|m| m.match_score(a, b))
603            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
604            .unwrap_or(0.0)
605    }
606
607    fn match_detailed(&self, a: &Component, b: &Component) -> MatchResult {
608        // Return the best result from any matcher
609        self.matchers
610            .iter()
611            .map(|m| m.match_detailed(a, b))
612            .max_by(|a, b| {
613                a.score
614                    .partial_cmp(&b.score)
615                    .unwrap_or(std::cmp::Ordering::Equal)
616            })
617            .unwrap_or_else(MatchResult::no_match)
618    }
619
620    fn name(&self) -> &'static str {
621        "CompositeMatcher"
622    }
623
624    /// The acceptance threshold matching this matcher's max-score semantics:
625    /// a pair is acceptable if ANY inner matcher would accept it, so the
626    /// composite threshold is the MINIMUM of the inner thresholds. (Without
627    /// this override the trait default of 0.0 made the engine's
628    /// matcher-owned gate accept every candidate pair, including score-0.)
629    fn threshold(&self) -> f64 {
630        self.matchers
631            .iter()
632            .map(|m| m.threshold())
633            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
634            .unwrap_or(1.0)
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    /// A simple test matcher that always returns a fixed score
643    struct FixedScoreMatcher(f64);
644
645    impl ComponentMatcher for FixedScoreMatcher {
646        fn match_score(&self, _a: &Component, _b: &Component) -> f64 {
647            self.0
648        }
649
650        fn name(&self) -> &'static str {
651            "FixedScoreMatcher"
652        }
653    }
654
655    #[test]
656    fn test_match_result_creation() {
657        let result = MatchResult::new(0.95, MatchTier::Alias);
658        assert_eq!(result.score, 0.95);
659        assert_eq!(result.tier, MatchTier::Alias);
660        assert!(result.is_match());
661    }
662
663    #[test]
664    fn test_no_match_result() {
665        let result = MatchResult::no_match();
666        assert_eq!(result.score, 0.0);
667        assert_eq!(result.tier, MatchTier::None);
668        assert!(!result.is_match());
669    }
670
671    #[test]
672    fn test_match_tier_default_scores() {
673        assert_eq!(MatchTier::ExactIdentifier.default_score(), 1.0);
674        assert_eq!(MatchTier::Alias.default_score(), 0.95);
675        assert_eq!(MatchTier::EcosystemRule.default_score(), 0.90);
676        assert_eq!(MatchTier::None.default_score(), 0.0);
677    }
678
679    #[test]
680    fn test_composite_matcher() {
681        let matcher = CompositeMatcherBuilder::new()
682            .with_matcher(Box::new(FixedScoreMatcher(0.5)))
683            .with_matcher(Box::new(FixedScoreMatcher(0.8)))
684            .with_matcher(Box::new(FixedScoreMatcher(0.3)))
685            .build();
686
687        let comp_a = Component::new("test".to_string(), "id-1".to_string());
688        let comp_b = Component::new("test".to_string(), "id-2".to_string());
689
690        // Should return the highest score (0.8)
691        assert_eq!(matcher.match_score(&comp_a, &comp_b), 0.8);
692    }
693
694    #[test]
695    fn test_find_best_match() {
696        let matcher = FixedScoreMatcher(0.85);
697        let target = Component::new("target".to_string(), "id-0".to_string());
698        let candidates: Vec<Component> = vec![
699            Component::new("candidate1".to_string(), "id-1".to_string()),
700            Component::new("candidate2".to_string(), "id-2".to_string()),
701        ];
702        let candidate_refs: Vec<&Component> = candidates.iter().collect();
703
704        // With threshold 0.8, should find a match
705        let result = matcher.find_best_match(&target, &candidate_refs, 0.8);
706        assert!(result.is_some());
707
708        // With threshold 0.9, should not find a match
709        let result = matcher.find_best_match(&target, &candidate_refs, 0.9);
710        assert!(result.is_none());
711    }
712
713    #[test]
714    fn test_match_explanation_matched() {
715        let explanation =
716            MatchExplanation::matched(MatchTier::ExactIdentifier, 1.0, "Test match reason");
717
718        assert!(explanation.is_match);
719        assert_eq!(explanation.score, 1.0);
720        assert_eq!(explanation.tier, MatchTier::ExactIdentifier);
721        assert!(explanation.summary().contains("MATCH"));
722        assert!(explanation.summary().contains("100%"));
723    }
724
725    #[test]
726    fn test_match_explanation_no_match() {
727        let explanation = MatchExplanation::no_match("Components are too different");
728
729        assert!(!explanation.is_match);
730        assert_eq!(explanation.score, 0.0);
731        assert_eq!(explanation.tier, MatchTier::None);
732        assert!(explanation.summary().contains("NO MATCH"));
733    }
734
735    #[test]
736    fn test_match_explanation_with_breakdown() {
737        let explanation = MatchExplanation::matched(MatchTier::Fuzzy, 0.85, "Fuzzy match")
738            .with_score_component(ScoreComponent {
739                name: "Jaro-Winkler".to_string(),
740                weight: 0.7,
741                raw_score: 0.9,
742                weighted_score: 0.63,
743                description: "name similarity".to_string(),
744            })
745            .with_score_component(ScoreComponent {
746                name: "Levenshtein".to_string(),
747                weight: 0.3,
748                raw_score: 0.73,
749                weighted_score: 0.22,
750                description: "edit distance".to_string(),
751            })
752            .with_normalization("lowercase");
753
754        assert_eq!(explanation.score_breakdown.len(), 2);
755        assert_eq!(explanation.normalizations_applied.len(), 1);
756
757        let detailed = explanation.detailed();
758        assert!(detailed.contains("Score breakdown:"));
759        assert!(detailed.contains("Jaro-Winkler"));
760        assert!(detailed.contains("Normalizations: lowercase"));
761    }
762
763    #[test]
764    fn test_match_explanation_display() {
765        let explanation = MatchExplanation::matched(MatchTier::Alias, 0.95, "Known alias");
766        let display = format!("{}", explanation);
767        assert!(display.contains("MATCH"));
768        assert!(display.contains("95%"));
769    }
770}