Skip to main content

sbom_tools/matching/
config.rs

1//! Fuzzy matching configuration.
2
3use serde::{Deserialize, Serialize};
4
5/// Configuration for fuzzy matching behavior.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct FuzzyMatchConfig {
8    /// Minimum confidence threshold (0.0 - 1.0)
9    pub threshold: f64,
10    /// Weight for Levenshtein distance component
11    pub levenshtein_weight: f64,
12    /// Weight for Jaro-Winkler similarity component
13    pub jaro_winkler_weight: f64,
14    /// Whether to use alias table lookups
15    pub use_aliases: bool,
16    /// Whether to use ecosystem-specific rules
17    pub use_ecosystem_rules: bool,
18    /// Multi-field scoring weights (optional, enables multi-field matching when set)
19    #[serde(default)]
20    pub field_weights: Option<MultiFieldWeights>,
21}
22
23/// Weights for multi-field scoring.
24///
25/// All weights should sum to 1.0 for normalized scoring.
26/// Fields with weight 0.0 are ignored in matching.
27///
28/// Penalty fields (negative values) are applied on top of the weighted score
29/// to penalize mismatches more strongly.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct MultiFieldWeights {
32    /// Weight for name similarity (primary field)
33    pub name: f64,
34    /// Weight for version match (exact match gives full score)
35    pub version: f64,
36    /// Weight for ecosystem match (exact match gives full score)
37    pub ecosystem: f64,
38    /// Weight for license overlap (Jaccard similarity of license sets)
39    pub licenses: f64,
40    /// Weight for supplier/publisher match
41    pub supplier: f64,
42    /// Weight for group/namespace match
43    pub group: f64,
44
45    // Penalty fields (applied on top of weighted score)
46    /// Penalty applied when ecosystems are different (negative value, e.g., -0.15)
47    #[serde(default)]
48    pub ecosystem_mismatch_penalty: f64,
49    /// Enable graduated version scoring based on semver distance
50    #[serde(default = "default_true")]
51    pub version_divergence_enabled: bool,
52    /// Penalty per major version difference (e.g., 0.10 = 10% per major)
53    #[serde(default = "default_version_major_penalty")]
54    pub version_major_penalty: f64,
55    /// Penalty per minor version difference, capped (e.g., 0.02 = 2% per minor)
56    #[serde(default = "default_version_minor_penalty")]
57    pub version_minor_penalty: f64,
58}
59
60const fn default_true() -> bool {
61    true
62}
63
64const fn default_version_major_penalty() -> f64 {
65    0.10
66}
67
68const fn default_version_minor_penalty() -> f64 {
69    0.02
70}
71
72impl MultiFieldWeights {
73    /// Default weights emphasizing name matching.
74    #[must_use]
75    pub const fn name_focused() -> Self {
76        Self {
77            name: 0.80,
78            version: 0.05,
79            ecosystem: 0.10,
80            licenses: 0.03,
81            supplier: 0.01,
82            group: 0.01,
83            ecosystem_mismatch_penalty: -0.15,
84            version_divergence_enabled: true,
85            version_major_penalty: 0.10,
86            version_minor_penalty: 0.02,
87        }
88    }
89
90    /// Balanced weights across all fields.
91    #[must_use]
92    pub const fn balanced() -> Self {
93        Self {
94            name: 0.60,
95            version: 0.10,
96            ecosystem: 0.15,
97            licenses: 0.08,
98            supplier: 0.04,
99            group: 0.03,
100            ecosystem_mismatch_penalty: -0.15, // Applied on top of weighted score
101            version_divergence_enabled: true,
102            version_major_penalty: 0.10,
103            version_minor_penalty: 0.02,
104        }
105    }
106
107    /// Weights for security-focused matching (emphasizes ecosystem and version).
108    #[must_use]
109    pub const fn security_focused() -> Self {
110        Self {
111            name: 0.50,
112            version: 0.20,
113            ecosystem: 0.20,
114            licenses: 0.05,
115            supplier: 0.03,
116            group: 0.02,
117            ecosystem_mismatch_penalty: -0.25, // Stricter penalty
118            version_divergence_enabled: true,
119            version_major_penalty: 0.15, // Higher penalty for major version diff
120            version_minor_penalty: 0.03,
121        }
122    }
123
124    /// Legacy weights with no penalties (for backward compatibility).
125    ///
126    /// Use this preset when you want the old binary scoring behavior
127    /// without ecosystem mismatch penalties or version divergence scoring.
128    #[must_use]
129    pub const fn legacy() -> Self {
130        Self {
131            name: 0.60,
132            version: 0.10,
133            ecosystem: 0.15,
134            licenses: 0.08,
135            supplier: 0.04,
136            group: 0.03,
137            ecosystem_mismatch_penalty: 0.0,   // No penalty
138            version_divergence_enabled: false, // Binary scoring
139            version_major_penalty: 0.0,
140            version_minor_penalty: 0.0,
141        }
142    }
143
144    /// Check if weights are properly normalized (sum to ~1.0).
145    /// Note: Penalty fields are not included in normalization check.
146    #[must_use]
147    pub fn is_normalized(&self) -> bool {
148        let sum =
149            self.name + self.version + self.ecosystem + self.licenses + self.supplier + self.group;
150        (sum - 1.0).abs() < 0.001
151    }
152
153    /// Normalize weights to sum to 1.0.
154    /// Note: Penalty fields are not affected by normalization.
155    pub fn normalize(&mut self) {
156        let sum =
157            self.name + self.version + self.ecosystem + self.licenses + self.supplier + self.group;
158        if sum > 0.0 {
159            self.name /= sum;
160            self.version /= sum;
161            self.ecosystem /= sum;
162            self.licenses /= sum;
163            self.supplier /= sum;
164            self.group /= sum;
165        }
166    }
167}
168
169impl Default for MultiFieldWeights {
170    fn default() -> Self {
171        Self::balanced()
172    }
173}
174
175impl FuzzyMatchConfig {
176    /// Strict matching for security-critical scenarios
177    #[must_use]
178    pub const fn strict() -> Self {
179        Self {
180            threshold: 0.95,
181            levenshtein_weight: 0.5,
182            jaro_winkler_weight: 0.5,
183            use_aliases: true,
184            use_ecosystem_rules: true,
185            field_weights: None, // Single-field (name) matching by default
186        }
187    }
188
189    /// Balanced matching for general diff operations
190    #[must_use]
191    pub const fn balanced() -> Self {
192        Self {
193            threshold: 0.85,
194            levenshtein_weight: 0.4,
195            jaro_winkler_weight: 0.6,
196            use_aliases: true,
197            use_ecosystem_rules: true,
198            field_weights: None, // Single-field (name) matching by default
199        }
200    }
201
202    /// Permissive matching for discovery/exploration
203    #[must_use]
204    pub const fn permissive() -> Self {
205        Self {
206            threshold: 0.70,
207            levenshtein_weight: 0.3,
208            jaro_winkler_weight: 0.7,
209            use_aliases: true,
210            use_ecosystem_rules: true,
211            field_weights: None, // Single-field (name) matching by default
212        }
213    }
214
215    /// Enable multi-field scoring with the given weights.
216    #[must_use]
217    pub const fn with_multi_field(mut self, weights: MultiFieldWeights) -> Self {
218        self.field_weights = Some(weights);
219        self
220    }
221
222    /// Set a custom threshold value.
223    #[must_use]
224    pub const fn with_threshold(mut self, threshold: f64) -> Self {
225        self.threshold = threshold;
226        self
227    }
228
229    /// Strict matching with multi-field scoring for security scenarios.
230    #[must_use]
231    pub const fn strict_multi_field() -> Self {
232        Self::strict().with_multi_field(MultiFieldWeights::security_focused())
233    }
234
235    /// Balanced matching with multi-field scoring.
236    #[must_use]
237    pub const fn balanced_multi_field() -> Self {
238        Self::balanced().with_multi_field(MultiFieldWeights::balanced())
239    }
240
241    /// Create config from a preset name.
242    ///
243    /// Supported presets:
244    /// - "strict", "balanced", "permissive" - single-field (name only)
245    /// - "strict-multi", "balanced-multi" - multi-field scoring enabled
246    /// - "security-focused" - strict matching with security-weighted multi-field scoring
247    #[must_use]
248    pub fn from_preset(name: &str) -> Option<Self> {
249        match name.to_lowercase().as_str() {
250            "strict" => Some(Self::strict()),
251            "balanced" => Some(Self::balanced()),
252            "permissive" => Some(Self::permissive()),
253            "strict-multi" | "strict_multi" => Some(Self::strict_multi_field()),
254            "balanced-multi" | "balanced_multi" => Some(Self::balanced_multi_field()),
255            "security-focused" | "security_focused" => Some(Self::security_focused()),
256            _ => None,
257        }
258    }
259
260    /// Security-focused preset: strict thresholds with multi-field scoring
261    /// weighted toward security-relevant fields.
262    #[must_use]
263    pub fn security_focused() -> Self {
264        Self {
265            threshold: 0.85,
266            field_weights: Some(MultiFieldWeights::security_focused()),
267            ..Self::strict()
268        }
269    }
270}
271
272impl Default for FuzzyMatchConfig {
273    fn default() -> Self {
274        Self::balanced()
275    }
276}
277
278/// Configuration for cross-ecosystem matching.
279///
280/// Cross-ecosystem matching allows components to be matched across different
281/// package ecosystems (e.g., npm vs `PyPI`) when they represent the same
282/// underlying library. This is enabled by default with conservative settings.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct CrossEcosystemConfig {
285    /// Whether cross-ecosystem matching is enabled
286    pub enabled: bool,
287    /// Minimum score required for cross-ecosystem matches
288    pub min_score: f64,
289    /// Score penalty applied to cross-ecosystem matches
290    pub score_penalty: f64,
291    /// Maximum number of cross-ecosystem candidates per component
292    pub max_candidates: usize,
293    /// Only use verified cross-ecosystem mappings (stricter)
294    pub verified_only: bool,
295}
296
297impl Default for CrossEcosystemConfig {
298    fn default() -> Self {
299        Self {
300            enabled: true,
301            min_score: 0.80,
302            score_penalty: 0.10,
303            max_candidates: 10,
304            verified_only: false,
305        }
306    }
307}
308
309impl CrossEcosystemConfig {
310    /// Disabled cross-ecosystem matching.
311    #[must_use]
312    pub fn disabled() -> Self {
313        Self {
314            enabled: false,
315            ..Default::default()
316        }
317    }
318
319    /// Strict settings for high-confidence matches only.
320    #[must_use]
321    pub const fn strict() -> Self {
322        Self {
323            enabled: true,
324            min_score: 0.90,
325            score_penalty: 0.15,
326            max_candidates: 5,
327            verified_only: true,
328        }
329    }
330
331    /// Permissive settings for discovery/exploration.
332    #[must_use]
333    pub const fn permissive() -> Self {
334        Self {
335            enabled: true,
336            min_score: 0.70,
337            score_penalty: 0.05,
338            max_candidates: 20,
339            verified_only: false,
340        }
341    }
342}