Skip to main content

sbom_tools/matching/
aliases.rs

1//! Curated alias tables for cross-ecosystem package correlation.
2
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5
6/// Alias table for mapping package names across different conventions.
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct AliasTable {
9    /// Mapping from alias to canonical name
10    alias_to_canonical: HashMap<String, String>,
11    /// Mapping from canonical name to all aliases
12    canonical_to_aliases: HashMap<String, HashSet<String>>,
13}
14
15impl AliasTable {
16    /// Create a new empty alias table
17    #[must_use]
18    pub fn new() -> Self {
19        Self::default()
20    }
21
22    /// Create an alias table with built-in common aliases.
23    ///
24    /// The builtins map alternate spellings of the SAME package (import name
25    /// vs distribution name, underscore vs hyphen variants) — never related
26    /// but distinct packages. Entries here merge at high confidence in the
27    /// diff, so a wrong entry hides a real component change; related
28    /// packages (react/react-dom, webpack/webpack-cli) must NOT be aliased,
29    /// and cross-ecosystem equivalence belongs in [`CrossEcosystemDb`]
30    /// (crate::matching::CrossEcosystemDb), which applies a penalty and is
31    /// gated per config.
32    ///
33    /// The table is opt-in: `FuzzyMatcher` starts with an empty table; install
34    /// this one via `FuzzyMatcher::with_alias_table`.
35    #[must_use]
36    pub fn with_builtins() -> Self {
37        let mut table = Self::new();
38        table.load_builtins();
39        table
40    }
41
42    /// Load built-in alias mappings
43    fn load_builtins(&mut self) {
44        // PyPI aliases (distribution vs import name differences)
45        self.add_aliases("pkg:pypi/pillow", &["PIL", "python-pillow", "pillow"]);
46        self.add_aliases("pkg:pypi/scikit-learn", &["sklearn", "scikit_learn"]);
47        self.add_aliases(
48            "pkg:pypi/beautifulsoup4",
49            &["bs4", "BeautifulSoup", "beautifulsoup"],
50        );
51        self.add_aliases("pkg:pypi/pyyaml", &["yaml", "PyYAML"]);
52        self.add_aliases("pkg:pypi/opencv-python", &["cv2", "opencv"]);
53        self.add_aliases("pkg:pypi/python-dateutil", &["dateutil"]);
54        self.add_aliases("pkg:pypi/importlib-metadata", &["importlib_metadata"]);
55        self.add_aliases("pkg:pypi/typing-extensions", &["typing_extensions"]);
56    }
57
58    /// Add aliases for a canonical package
59    pub fn add_aliases(&mut self, canonical: &str, aliases: &[&str]) {
60        let canonical_lower = canonical.to_lowercase();
61
62        // Add canonical as its own alias
63        self.alias_to_canonical
64            .insert(canonical_lower.clone(), canonical_lower.clone());
65
66        // Initialize alias set and insert canonical name
67        let alias_set = self
68            .canonical_to_aliases
69            .entry(canonical_lower.clone())
70            .or_default();
71        alias_set.insert(canonical_lower.clone());
72
73        // Add all aliases
74        for alias in aliases {
75            let alias_lower = alias.to_lowercase();
76            self.alias_to_canonical
77                .insert(alias_lower.clone(), canonical_lower.clone());
78            if let Some(set) = self.canonical_to_aliases.get_mut(&canonical_lower) {
79                set.insert(alias_lower);
80            }
81        }
82    }
83
84    /// Check whether the table has any entries at all.
85    #[must_use]
86    pub fn is_empty(&self) -> bool {
87        self.alias_to_canonical.is_empty()
88    }
89
90    /// Get the canonical name for an alias
91    #[must_use]
92    pub fn get_canonical(&self, alias: &str) -> Option<String> {
93        self.alias_to_canonical.get(&alias.to_lowercase()).cloned()
94    }
95
96    /// Check if a name is an alias of a canonical name
97    #[must_use]
98    pub fn is_alias(&self, canonical: &str, name: &str) -> bool {
99        let canonical_lower = canonical.to_lowercase();
100        let name_lower = name.to_lowercase();
101
102        self.canonical_to_aliases
103            .get(&canonical_lower)
104            .is_some_and(|aliases| aliases.contains(&name_lower))
105    }
106
107    /// Get all aliases for a canonical name
108    #[must_use]
109    pub fn get_aliases(&self, canonical: &str) -> Option<&HashSet<String>> {
110        self.canonical_to_aliases.get(&canonical.to_lowercase())
111    }
112
113    /// Load aliases from JSON
114    pub fn load_json(&mut self, json: &str) -> Result<(), serde_json::Error> {
115        let entries: Vec<AliasEntry> = serde_json::from_str(json)?;
116        for entry in entries {
117            let aliases: Vec<&str> = entry
118                .aliases
119                .iter()
120                .map(std::string::String::as_str)
121                .collect();
122            self.add_aliases(&entry.canonical, &aliases);
123        }
124        Ok(())
125    }
126
127    /// Export aliases to JSON
128    pub fn to_json(&self) -> Result<String, serde_json::Error> {
129        let entries: Vec<AliasEntry> = self
130            .canonical_to_aliases
131            .iter()
132            .map(|(canonical, aliases)| AliasEntry {
133                canonical: canonical.clone(),
134                aliases: aliases.iter().cloned().collect(),
135            })
136            .collect();
137        serde_json::to_string_pretty(&entries)
138    }
139}
140
141/// Entry in the alias table JSON format
142#[derive(Debug, Serialize, Deserialize)]
143struct AliasEntry {
144    canonical: String,
145    aliases: Vec<String>,
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_alias_lookup() {
154        let table = AliasTable::with_builtins();
155
156        // PIL -> pillow
157        assert_eq!(
158            table.get_canonical("PIL"),
159            Some("pkg:pypi/pillow".to_lowercase())
160        );
161
162        // sklearn -> scikit-learn
163        assert_eq!(
164            table.get_canonical("sklearn"),
165            Some("pkg:pypi/scikit-learn".to_lowercase())
166        );
167    }
168
169    #[test]
170    fn test_is_alias() {
171        let table = AliasTable::with_builtins();
172
173        assert!(table.is_alias("pkg:pypi/pillow", "PIL"));
174        assert!(table.is_alias("pkg:pypi/pillow", "pillow"));
175        assert!(!table.is_alias("pkg:pypi/pillow", "numpy"));
176    }
177
178    #[test]
179    fn test_custom_aliases() {
180        let mut table = AliasTable::new();
181        table.add_aliases("my-package", &["my_package", "mypackage"]);
182
183        assert_eq!(
184            table.get_canonical("my_package"),
185            Some("my-package".to_string())
186        );
187        assert!(table.is_alias("my-package", "mypackage"));
188    }
189}