Skip to main content

sbom_tools/matching/
ecosystem_config.rs

1//! Ecosystem-specific configuration for package matching rules.
2//!
3//! This module provides configurable rules for normalizing and matching
4//! package names across different ecosystems (npm, `PyPI`, Cargo, Maven, etc.).
5//!
6//! Configuration can be loaded from YAML files or use built-in defaults.
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12/// Root configuration for ecosystem rules
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct EcosystemRulesConfig {
15    /// Configuration format version
16    #[serde(default = "default_version")]
17    pub version: String,
18
19    /// Global settings
20    #[serde(default)]
21    pub settings: GlobalSettings,
22
23    /// Per-ecosystem configuration
24    #[serde(default)]
25    pub ecosystems: HashMap<String, EcosystemConfig>,
26
27    /// Cross-ecosystem package mappings (concept -> ecosystem -> package)
28    #[serde(default)]
29    pub cross_ecosystem: HashMap<String, HashMap<String, Option<String>>>,
30
31    /// Custom organization-specific rules
32    #[serde(default)]
33    pub custom_rules: CustomRules,
34}
35
36fn default_version() -> String {
37    "1.0".to_string()
38}
39
40/// Global settings that apply across all ecosystems
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct GlobalSettings {
43    /// Default case sensitivity for ecosystems without explicit setting
44    #[serde(default)]
45    pub case_sensitive_default: bool,
46
47    /// Whether to normalize unicode characters
48    #[serde(default = "default_true")]
49    pub normalize_unicode: bool,
50
51    /// Enable security checks (typosquat detection, suspicious patterns)
52    #[serde(default = "default_true")]
53    pub enable_security_checks: bool,
54}
55
56impl Default for GlobalSettings {
57    fn default() -> Self {
58        Self {
59            case_sensitive_default: false,
60            normalize_unicode: true,
61            enable_security_checks: true,
62        }
63    }
64}
65
66const fn default_true() -> bool {
67    true
68}
69
70/// Configuration for a specific ecosystem
71#[derive(Debug, Clone, Default, Deserialize, Serialize)]
72pub struct EcosystemConfig {
73    /// Name normalization settings
74    #[serde(default)]
75    pub normalization: NormalizationConfig,
76
77    /// Prefixes to strip for fuzzy matching
78    #[serde(default)]
79    pub strip_prefixes: Vec<String>,
80
81    /// Suffixes to strip for fuzzy matching
82    #[serde(default)]
83    pub strip_suffixes: Vec<String>,
84
85    /// Known package aliases (canonical -> aliases)
86    #[serde(default)]
87    pub aliases: HashMap<String, Vec<String>>,
88
89    /// Package groups (for monorepos)
90    #[serde(default)]
91    pub package_groups: HashMap<String, PackageGroup>,
92
93    /// Version handling configuration
94    #[serde(default)]
95    pub versioning: VersioningConfig,
96
97    /// Security-related configuration
98    #[serde(default)]
99    pub security: SecurityConfig,
100
101    /// Import path mappings (for Go, etc.)
102    #[serde(default)]
103    pub import_mappings: Vec<ImportMapping>,
104
105    /// Group/namespace migrations (for Maven javax->jakarta, etc.)
106    #[serde(default)]
107    pub group_migrations: Vec<GroupMigration>,
108}
109
110/// Name normalization configuration
111#[derive(Debug, Clone, Default, Deserialize, Serialize)]
112pub struct NormalizationConfig {
113    /// Whether name matching is case-sensitive
114    #[serde(default)]
115    pub case_sensitive: bool,
116
117    /// Characters that should be treated as equivalent
118    /// e.g., `["-", "_", "."]` means foo-bar == `foo_bar` == foo.bar
119    #[serde(default)]
120    pub equivalent_chars: Vec<Vec<String>>,
121
122    /// Whether to collapse repeated separators
123    #[serde(default)]
124    pub collapse_separators: bool,
125
126    /// Whether to use full coordinate (groupId:artifactId for Maven)
127    #[serde(default)]
128    pub use_full_coordinate: bool,
129
130    /// Whether to strip version suffix from module path (Go /v2, /v3)
131    #[serde(default)]
132    pub strip_version_suffix: bool,
133
134    /// How to handle scoped packages (npm @scope/name)
135    #[serde(default)]
136    pub scope_handling: ScopeHandling,
137}
138
139/// How to handle scoped package names
140#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
141#[serde(rename_all = "snake_case")]
142pub enum ScopeHandling {
143    /// Lowercase everything
144    #[default]
145    Lowercase,
146    /// Preserve case in scope, lowercase name
147    PreserveScopeCase,
148    /// Preserve all case
149    PreserveCase,
150}
151
152/// Package group definition (for monorepos)
153#[derive(Debug, Clone, Default, Deserialize, Serialize)]
154pub struct PackageGroup {
155    /// Canonical package name
156    pub canonical: String,
157
158    /// Member packages (can use glob patterns like "@babel/*")
159    #[serde(default)]
160    pub members: Vec<String>,
161}
162
163/// Version handling configuration
164#[derive(Debug, Clone, Deserialize, Serialize)]
165pub struct VersioningConfig {
166    /// Version specification type
167    #[serde(default = "default_semver")]
168    pub spec: VersionSpec,
169
170    /// Pre-release identifier tags
171    #[serde(default)]
172    pub prerelease_tags: Vec<String>,
173
174    /// Qualifier ordering (for Maven)
175    #[serde(default)]
176    pub qualifier_order: Vec<String>,
177}
178
179const fn default_semver() -> VersionSpec {
180    VersionSpec::Semver
181}
182
183impl Default for VersioningConfig {
184    fn default() -> Self {
185        Self {
186            spec: VersionSpec::Semver,
187            prerelease_tags: vec![],
188            qualifier_order: vec![],
189        }
190    }
191}
192
193/// Version specification type
194#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
195#[serde(rename_all = "lowercase")]
196pub enum VersionSpec {
197    /// Semantic Versioning (npm, cargo, nuget)
198    #[default]
199    Semver,
200    /// PEP 440 (Python)
201    Pep440,
202    /// Maven versioning
203    Maven,
204    /// `RubyGems` versioning
205    Rubygems,
206    /// Go module versioning
207    Gomod,
208    /// Generic/unknown
209    Generic,
210}
211
212/// Security-related configuration
213#[derive(Debug, Clone, Default, Deserialize, Serialize)]
214pub struct SecurityConfig {
215    /// Known typosquat packages
216    #[serde(default)]
217    pub known_typosquats: Vec<TyposquatEntry>,
218
219    /// Regex patterns for suspicious package names
220    #[serde(default)]
221    pub suspicious_patterns: Vec<String>,
222
223    /// Known malicious packages to warn about
224    #[serde(default)]
225    pub known_malicious: Vec<String>,
226}
227
228/// Typosquat package mapping
229#[derive(Debug, Clone, Deserialize, Serialize)]
230pub struct TyposquatEntry {
231    /// The malicious/typosquat package name
232    pub malicious: String,
233
234    /// The legitimate package it mimics
235    pub legitimate: String,
236
237    /// Optional description of the typosquat
238    #[serde(default)]
239    pub description: Option<String>,
240}
241
242/// Import path mapping (for Go modules)
243#[derive(Debug, Clone, Deserialize, Serialize)]
244pub struct ImportMapping {
245    /// Pattern to match (glob-style)
246    pub pattern: String,
247
248    /// Type of import (github, `stdlib_extension`, etc.)
249    #[serde(rename = "type")]
250    pub mapping_type: String,
251}
252
253/// Group/namespace migration (e.g., javax -> jakarta)
254#[derive(Debug, Clone, Deserialize, Serialize)]
255pub struct GroupMigration {
256    /// Pattern to match (can use wildcards)
257    pub from: String,
258
259    /// Replacement pattern
260    pub to: String,
261
262    /// Optional version threshold (migration applies after this version)
263    #[serde(default)]
264    pub after_version: Option<String>,
265}
266
267/// Custom organization-specific rules
268#[derive(Debug, Clone, Default, Deserialize, Serialize)]
269pub struct CustomRules {
270    /// Internal package prefixes to recognize
271    #[serde(default)]
272    pub internal_prefixes: Vec<String>,
273
274    /// Custom equivalence mappings
275    #[serde(default)]
276    pub equivalences: Vec<CustomEquivalence>,
277
278    /// Packages to always ignore in diffs
279    #[serde(default)]
280    pub ignored_packages: Vec<String>,
281}
282
283/// Custom equivalence mapping
284#[derive(Debug, Clone, Deserialize, Serialize)]
285pub struct CustomEquivalence {
286    /// Canonical package identifier
287    pub canonical: String,
288
289    /// Aliases that should map to canonical
290    pub aliases: Vec<String>,
291
292    /// Whether version must match for equivalence
293    #[serde(default)]
294    pub version_sensitive: bool,
295}
296
297impl EcosystemRulesConfig {
298    /// Create a new empty configuration
299    #[must_use]
300    pub fn new() -> Self {
301        Self {
302            version: default_version(),
303            settings: GlobalSettings::default(),
304            ecosystems: HashMap::new(),
305            cross_ecosystem: HashMap::new(),
306            custom_rules: CustomRules::default(),
307        }
308    }
309
310    /// Create configuration with built-in defaults
311    #[must_use]
312    pub fn builtin() -> Self {
313        let mut config = Self::new();
314        config.load_builtin_rules();
315        config
316    }
317
318    /// Load configuration from a YAML string
319    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
320        serde_yaml_ng::from_str(yaml)
321    }
322
323    /// Load configuration from a JSON string
324    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
325        serde_json::from_str(json)
326    }
327
328    /// Load configuration from a file (auto-detects format)
329    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
330        let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
331
332        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
333
334        match extension.to_lowercase().as_str() {
335            "yaml" | "yml" => Self::from_yaml(&content).map_err(ConfigError::Yaml),
336            "json" => Self::from_json(&content).map_err(ConfigError::Json),
337            _ => {
338                // Try YAML first, then JSON
339                Self::from_yaml(&content)
340                    .map_err(ConfigError::Yaml)
341                    .or_else(|_| Self::from_json(&content).map_err(ConfigError::Json))
342            }
343        }
344    }
345
346    /// Load configuration with precedence from multiple locations
347    pub fn load_with_precedence(paths: &[&str]) -> Result<Self, ConfigError> {
348        for path_str in paths {
349            let path = if path_str.starts_with('~') {
350                if let Some(home) = dirs::home_dir() {
351                    home.join(&path_str[2..])
352                } else {
353                    continue;
354                }
355            } else {
356                Path::new(path_str).to_path_buf()
357            };
358
359            if path.exists() {
360                return Self::from_file(&path);
361            }
362        }
363
364        // No config file found, use built-in defaults
365        Ok(Self::builtin())
366    }
367
368    /// Load built-in ecosystem rules
369    fn load_builtin_rules(&mut self) {
370        // PyPI rules
371        self.ecosystems.insert(
372            "pypi".to_string(),
373            EcosystemConfig {
374                normalization: NormalizationConfig {
375                    case_sensitive: false,
376                    equivalent_chars: vec![vec!["-".to_string(), "_".to_string(), ".".to_string()]],
377                    collapse_separators: true,
378                    ..Default::default()
379                },
380                strip_prefixes: vec!["python-".to_string(), "py-".to_string(), "lib".to_string()],
381                strip_suffixes: vec![
382                    "-python".to_string(),
383                    "-py".to_string(),
384                    "-py3".to_string(),
385                    "-lib".to_string(),
386                ],
387                aliases: Self::pypi_aliases(),
388                versioning: VersioningConfig {
389                    spec: VersionSpec::Pep440,
390                    prerelease_tags: vec![
391                        "a".to_string(),
392                        "b".to_string(),
393                        "rc".to_string(),
394                        "alpha".to_string(),
395                        "beta".to_string(),
396                        "dev".to_string(),
397                        "post".to_string(),
398                    ],
399                    ..Default::default()
400                },
401                security: SecurityConfig {
402                    known_typosquats: vec![
403                        TyposquatEntry {
404                            malicious: "python-dateutils".to_string(),
405                            legitimate: "python-dateutil".to_string(),
406                            description: Some("Common typosquat".to_string()),
407                        },
408                        TyposquatEntry {
409                            malicious: "request".to_string(),
410                            legitimate: "requests".to_string(),
411                            description: Some("Missing 's' typosquat".to_string()),
412                        },
413                    ],
414                    ..Default::default()
415                },
416                ..Default::default()
417            },
418        );
419
420        // npm rules
421        self.ecosystems.insert(
422            "npm".to_string(),
423            EcosystemConfig {
424                normalization: NormalizationConfig {
425                    case_sensitive: false,
426                    scope_handling: ScopeHandling::PreserveScopeCase,
427                    ..Default::default()
428                },
429                strip_prefixes: vec!["node-".to_string(), "@types/".to_string()],
430                strip_suffixes: vec!["-js".to_string(), ".js".to_string(), "-node".to_string()],
431                package_groups: Self::npm_package_groups(),
432                versioning: VersioningConfig {
433                    spec: VersionSpec::Semver,
434                    prerelease_tags: vec![
435                        "alpha".to_string(),
436                        "beta".to_string(),
437                        "rc".to_string(),
438                        "next".to_string(),
439                        "canary".to_string(),
440                    ],
441                    ..Default::default()
442                },
443                security: SecurityConfig {
444                    suspicious_patterns: vec![
445                        r"^[a-z]{1,2}$".to_string(), // Very short names
446                    ],
447                    ..Default::default()
448                },
449                ..Default::default()
450            },
451        );
452
453        // Cargo rules
454        self.ecosystems.insert(
455            "cargo".to_string(),
456            EcosystemConfig {
457                normalization: NormalizationConfig {
458                    case_sensitive: false,
459                    // Replace "-" with "_" (target is first, source is second)
460                    equivalent_chars: vec![vec!["_".to_string(), "-".to_string()]],
461                    ..Default::default()
462                },
463                strip_prefixes: vec!["rust-".to_string(), "lib".to_string()],
464                strip_suffixes: vec!["-rs".to_string(), "-rust".to_string()],
465                versioning: VersioningConfig {
466                    spec: VersionSpec::Semver,
467                    ..Default::default()
468                },
469                ..Default::default()
470            },
471        );
472
473        // Maven rules
474        self.ecosystems.insert(
475            "maven".to_string(),
476            EcosystemConfig {
477                normalization: NormalizationConfig {
478                    case_sensitive: true,
479                    use_full_coordinate: true,
480                    ..Default::default()
481                },
482                group_migrations: vec![GroupMigration {
483                    from: "javax.*".to_string(),
484                    to: "jakarta.*".to_string(),
485                    after_version: Some("9".to_string()),
486                }],
487                versioning: VersioningConfig {
488                    spec: VersionSpec::Maven,
489                    qualifier_order: vec![
490                        "alpha".to_string(),
491                        "beta".to_string(),
492                        "milestone".to_string(),
493                        "rc".to_string(),
494                        "snapshot".to_string(),
495                        "final".to_string(),
496                        "ga".to_string(),
497                        "sp".to_string(),
498                    ],
499                    ..Default::default()
500                },
501                ..Default::default()
502            },
503        );
504
505        // Go rules
506        self.ecosystems.insert(
507            "golang".to_string(),
508            EcosystemConfig {
509                normalization: NormalizationConfig {
510                    case_sensitive: true,
511                    strip_version_suffix: true,
512                    ..Default::default()
513                },
514                import_mappings: vec![
515                    ImportMapping {
516                        pattern: "github.com/*/*".to_string(),
517                        mapping_type: "github".to_string(),
518                    },
519                    ImportMapping {
520                        pattern: "golang.org/x/*".to_string(),
521                        mapping_type: "stdlib_extension".to_string(),
522                    },
523                ],
524                versioning: VersioningConfig {
525                    spec: VersionSpec::Gomod,
526                    ..Default::default()
527                },
528                ..Default::default()
529            },
530        );
531
532        // NuGet rules
533        self.ecosystems.insert(
534            "nuget".to_string(),
535            EcosystemConfig {
536                normalization: NormalizationConfig {
537                    case_sensitive: false,
538                    ..Default::default()
539                },
540                versioning: VersioningConfig {
541                    spec: VersionSpec::Semver,
542                    ..Default::default()
543                },
544                ..Default::default()
545            },
546        );
547
548        // RubyGems rules
549        self.ecosystems.insert(
550            "rubygems".to_string(),
551            EcosystemConfig {
552                normalization: NormalizationConfig {
553                    case_sensitive: true,
554                    ..Default::default()
555                },
556                strip_prefixes: vec!["ruby-".to_string()],
557                strip_suffixes: vec!["-ruby".to_string(), "-rb".to_string()],
558                versioning: VersioningConfig {
559                    spec: VersionSpec::Rubygems,
560                    ..Default::default()
561                },
562                ..Default::default()
563            },
564        );
565
566        // Composer (PHP) rules
567        self.ecosystems.insert(
568            "composer".to_string(),
569            EcosystemConfig {
570                normalization: NormalizationConfig {
571                    case_sensitive: false,
572                    use_full_coordinate: true,
573                    ..Default::default()
574                },
575                versioning: VersioningConfig {
576                    spec: VersionSpec::Semver,
577                    ..Default::default()
578                },
579                ..Default::default()
580            },
581        );
582
583        // Cross-ecosystem mappings
584        self.load_cross_ecosystem_mappings();
585    }
586
587    /// `PyPI` known aliases
588    fn pypi_aliases() -> HashMap<String, Vec<String>> {
589        let mut aliases = HashMap::new();
590        aliases.insert(
591            "pillow".to_string(),
592            vec!["PIL".to_string(), "python-pillow".to_string()],
593        );
594        aliases.insert(
595            "scikit-learn".to_string(),
596            vec!["sklearn".to_string(), "scikit_learn".to_string()],
597        );
598        aliases.insert(
599            "beautifulsoup4".to_string(),
600            vec![
601                "bs4".to_string(),
602                "BeautifulSoup".to_string(),
603                "beautifulsoup".to_string(),
604            ],
605        );
606        aliases.insert(
607            "pyyaml".to_string(),
608            vec!["yaml".to_string(), "PyYAML".to_string()],
609        );
610        aliases.insert(
611            "opencv-python".to_string(),
612            vec![
613                "cv2".to_string(),
614                "opencv-python-headless".to_string(),
615                "opencv".to_string(),
616            ],
617        );
618        aliases.insert("python-dateutil".to_string(), vec!["dateutil".to_string()]);
619        aliases.insert("attrs".to_string(), vec!["attr".to_string()]);
620        aliases.insert(
621            "importlib-metadata".to_string(),
622            vec!["importlib_metadata".to_string()],
623        );
624        aliases.insert(
625            "typing-extensions".to_string(),
626            vec!["typing_extensions".to_string()],
627        );
628        aliases
629    }
630
631    /// npm package groups
632    fn npm_package_groups() -> HashMap<String, PackageGroup> {
633        let mut groups = HashMap::new();
634        groups.insert(
635            "lodash".to_string(),
636            PackageGroup {
637                canonical: "lodash".to_string(),
638                members: vec![
639                    "lodash-es".to_string(),
640                    "lodash.merge".to_string(),
641                    "lodash.get".to_string(),
642                    "lodash.set".to_string(),
643                    "lodash.clonedeep".to_string(),
644                ],
645            },
646        );
647        groups.insert(
648            "babel".to_string(),
649            PackageGroup {
650                canonical: "@babel/core".to_string(),
651                members: vec!["@babel/*".to_string()],
652            },
653        );
654        groups.insert(
655            "react".to_string(),
656            PackageGroup {
657                canonical: "react".to_string(),
658                members: vec![
659                    "react-dom".to_string(),
660                    "react-router".to_string(),
661                    "react-redux".to_string(),
662                ],
663            },
664        );
665        groups
666    }
667
668    /// Load cross-ecosystem package mappings
669    fn load_cross_ecosystem_mappings(&mut self) {
670        // YAML parsing libraries
671        let mut yaml_mapping = HashMap::new();
672        yaml_mapping.insert("pypi".to_string(), Some("pyyaml".to_string()));
673        yaml_mapping.insert("npm".to_string(), Some("js-yaml".to_string()));
674        yaml_mapping.insert("cargo".to_string(), Some("serde_yaml".to_string()));
675        yaml_mapping.insert("golang".to_string(), Some("gopkg.in/yaml.v3".to_string()));
676        yaml_mapping.insert("rubygems".to_string(), Some("psych".to_string()));
677        self.cross_ecosystem
678            .insert("yaml_parsing".to_string(), yaml_mapping);
679
680        // JSON parsing libraries
681        let mut json_mapping = HashMap::new();
682        json_mapping.insert("pypi".to_string(), None); // stdlib
683        json_mapping.insert("npm".to_string(), None); // native
684        json_mapping.insert("cargo".to_string(), Some("serde_json".to_string()));
685        json_mapping.insert("golang".to_string(), None); // encoding/json stdlib
686        self.cross_ecosystem
687            .insert("json_parsing".to_string(), json_mapping);
688
689        // HTTP client libraries
690        let mut http_mapping = HashMap::new();
691        http_mapping.insert("pypi".to_string(), Some("requests".to_string()));
692        http_mapping.insert("npm".to_string(), Some("axios".to_string()));
693        http_mapping.insert("cargo".to_string(), Some("reqwest".to_string()));
694        http_mapping.insert("golang".to_string(), None); // net/http stdlib
695        http_mapping.insert("rubygems".to_string(), Some("faraday".to_string()));
696        self.cross_ecosystem
697            .insert("http_client".to_string(), http_mapping);
698
699        // Testing frameworks
700        let mut test_mapping = HashMap::new();
701        test_mapping.insert("pypi".to_string(), Some("pytest".to_string()));
702        test_mapping.insert("npm".to_string(), Some("jest".to_string()));
703        test_mapping.insert("cargo".to_string(), None); // built-in
704        test_mapping.insert("golang".to_string(), None); // testing stdlib
705        test_mapping.insert("rubygems".to_string(), Some("rspec".to_string()));
706        self.cross_ecosystem
707            .insert("testing".to_string(), test_mapping);
708    }
709
710    /// Get configuration for a specific ecosystem
711    #[must_use]
712    pub fn get_ecosystem(&self, ecosystem: &str) -> Option<&EcosystemConfig> {
713        self.ecosystems.get(&ecosystem.to_lowercase())
714    }
715
716    /// Check if configuration is empty
717    #[must_use]
718    pub fn is_empty(&self) -> bool {
719        self.ecosystems.is_empty()
720            && self.cross_ecosystem.is_empty()
721            && self.custom_rules.equivalences.is_empty()
722    }
723
724    /// Merge another configuration into this one (other takes precedence)
725    pub fn merge(&mut self, other: Self) {
726        // Merge ecosystems
727        for (key, value) in other.ecosystems {
728            self.ecosystems.insert(key, value);
729        }
730
731        // Merge cross-ecosystem mappings
732        for (key, value) in other.cross_ecosystem {
733            self.cross_ecosystem.insert(key, value);
734        }
735
736        // Merge custom rules
737        self.custom_rules
738            .internal_prefixes
739            .extend(other.custom_rules.internal_prefixes);
740        self.custom_rules
741            .equivalences
742            .extend(other.custom_rules.equivalences);
743        self.custom_rules
744            .ignored_packages
745            .extend(other.custom_rules.ignored_packages);
746
747        // Update settings if explicitly set
748        if other.settings.enable_security_checks != self.settings.enable_security_checks {
749            self.settings.enable_security_checks = other.settings.enable_security_checks;
750        }
751    }
752
753    /// Export configuration to YAML
754    pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
755        serde_yaml_ng::to_string(self)
756    }
757
758    /// Export configuration to JSON
759    pub fn to_json(&self) -> Result<String, serde_json::Error> {
760        serde_json::to_string_pretty(self)
761    }
762}
763
764impl Default for EcosystemRulesConfig {
765    fn default() -> Self {
766        Self::builtin()
767    }
768}
769
770/// Configuration loading error
771#[derive(Debug)]
772pub enum ConfigError {
773    Io(std::io::Error),
774    Yaml(serde_yaml_ng::Error),
775    Json(serde_json::Error),
776}
777
778impl std::fmt::Display for ConfigError {
779    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
780        match self {
781            Self::Io(e) => write!(f, "IO error: {e}"),
782            Self::Yaml(e) => write!(f, "YAML parse error: {e}"),
783            Self::Json(e) => write!(f, "JSON parse error: {e}"),
784        }
785    }
786}
787
788impl std::error::Error for ConfigError {}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    #[test]
795    fn test_builtin_config() {
796        let config = EcosystemRulesConfig::builtin();
797
798        assert!(config.ecosystems.contains_key("pypi"));
799        assert!(config.ecosystems.contains_key("npm"));
800        assert!(config.ecosystems.contains_key("cargo"));
801        assert!(config.ecosystems.contains_key("maven"));
802        assert!(config.ecosystems.contains_key("golang"));
803    }
804
805    #[test]
806    fn test_pypi_config() {
807        let config = EcosystemRulesConfig::builtin();
808        let pypi = config.get_ecosystem("pypi").unwrap();
809
810        assert!(!pypi.normalization.case_sensitive);
811        assert!(!pypi.strip_prefixes.is_empty());
812        assert!(pypi.aliases.contains_key("pillow"));
813        assert_eq!(pypi.versioning.spec, VersionSpec::Pep440);
814    }
815
816    #[test]
817    fn test_npm_config() {
818        let config = EcosystemRulesConfig::builtin();
819        let npm = config.get_ecosystem("npm").unwrap();
820
821        assert_eq!(
822            npm.normalization.scope_handling,
823            ScopeHandling::PreserveScopeCase
824        );
825        assert!(npm.package_groups.contains_key("lodash"));
826    }
827
828    #[test]
829    fn test_cross_ecosystem_mapping() {
830        let config = EcosystemRulesConfig::builtin();
831
832        let yaml_libs = config.cross_ecosystem.get("yaml_parsing").unwrap();
833        assert_eq!(yaml_libs.get("pypi").unwrap(), &Some("pyyaml".to_string()));
834        assert_eq!(yaml_libs.get("npm").unwrap(), &Some("js-yaml".to_string()));
835    }
836
837    #[test]
838    fn test_yaml_parsing() {
839        let yaml = r#"
840version: "1.0"
841settings:
842  case_sensitive_default: false
843ecosystems:
844  custom:
845    normalization:
846      case_sensitive: true
847    strip_prefixes:
848      - "my-"
849    strip_suffixes:
850      - "-custom"
851"#;
852
853        let config = EcosystemRulesConfig::from_yaml(yaml).unwrap();
854        assert!(config.ecosystems.contains_key("custom"));
855
856        let custom = config.get_ecosystem("custom").unwrap();
857        assert!(custom.normalization.case_sensitive);
858        assert_eq!(custom.strip_prefixes, vec!["my-"]);
859    }
860
861    #[test]
862    fn test_config_merge() {
863        let mut base = EcosystemRulesConfig::builtin();
864        let overlay = EcosystemRulesConfig::from_yaml(
865            r#"
866ecosystems:
867  pypi:
868    strip_prefixes:
869      - "custom-"
870custom_rules:
871  internal_prefixes:
872    - "@mycompany/"
873"#,
874        )
875        .unwrap();
876
877        base.merge(overlay);
878
879        // Overlay should override pypi
880        let pypi = base.get_ecosystem("pypi").unwrap();
881        assert_eq!(pypi.strip_prefixes, vec!["custom-"]);
882
883        // Custom rules should be merged
884        assert!(
885            base.custom_rules
886                .internal_prefixes
887                .contains(&"@mycompany/".to_string())
888        );
889    }
890
891    #[test]
892    fn test_security_config() {
893        let config = EcosystemRulesConfig::builtin();
894        let pypi = config.get_ecosystem("pypi").unwrap();
895
896        assert!(!pypi.security.known_typosquats.is_empty());
897        let typosquat = &pypi.security.known_typosquats[0];
898        assert_eq!(typosquat.malicious, "python-dateutils");
899        assert_eq!(typosquat.legitimate, "python-dateutil");
900    }
901}