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
66fn 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
179fn 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    pub fn new() -> Self {
300        Self {
301            version: default_version(),
302            settings: GlobalSettings::default(),
303            ecosystems: HashMap::new(),
304            cross_ecosystem: HashMap::new(),
305            custom_rules: CustomRules::default(),
306        }
307    }
308
309    /// Create configuration with built-in defaults
310    pub fn builtin() -> Self {
311        let mut config = Self::new();
312        config.load_builtin_rules();
313        config
314    }
315
316    /// Load configuration from a YAML string
317    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
318        serde_yaml_ng::from_str(yaml)
319    }
320
321    /// Load configuration from a JSON string
322    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
323        serde_json::from_str(json)
324    }
325
326    /// Load configuration from a file (auto-detects format)
327    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
328        let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
329
330        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
331
332        match extension.to_lowercase().as_str() {
333            "yaml" | "yml" => Self::from_yaml(&content).map_err(ConfigError::Yaml),
334            "json" => Self::from_json(&content).map_err(ConfigError::Json),
335            _ => {
336                // Try YAML first, then JSON
337                Self::from_yaml(&content)
338                    .map_err(ConfigError::Yaml)
339                    .or_else(|_| Self::from_json(&content).map_err(ConfigError::Json))
340            }
341        }
342    }
343
344    /// Load configuration with precedence from multiple locations
345    pub fn load_with_precedence(paths: &[&str]) -> Result<Self, ConfigError> {
346        for path_str in paths {
347            let path = if path_str.starts_with('~') {
348                if let Some(home) = dirs::home_dir() {
349                    home.join(&path_str[2..])
350                } else {
351                    continue;
352                }
353            } else {
354                Path::new(path_str).to_path_buf()
355            };
356
357            if path.exists() {
358                return Self::from_file(&path);
359            }
360        }
361
362        // No config file found, use built-in defaults
363        Ok(Self::builtin())
364    }
365
366    /// Load built-in ecosystem rules
367    fn load_builtin_rules(&mut self) {
368        // PyPI rules
369        self.ecosystems.insert(
370            "pypi".to_string(),
371            EcosystemConfig {
372                normalization: NormalizationConfig {
373                    case_sensitive: false,
374                    equivalent_chars: vec![vec!["-".to_string(), "_".to_string(), ".".to_string()]],
375                    collapse_separators: true,
376                    ..Default::default()
377                },
378                strip_prefixes: vec!["python-".to_string(), "py-".to_string(), "lib".to_string()],
379                strip_suffixes: vec![
380                    "-python".to_string(),
381                    "-py".to_string(),
382                    "-py3".to_string(),
383                    "-lib".to_string(),
384                ],
385                aliases: Self::pypi_aliases(),
386                versioning: VersioningConfig {
387                    spec: VersionSpec::Pep440,
388                    prerelease_tags: vec![
389                        "a".to_string(),
390                        "b".to_string(),
391                        "rc".to_string(),
392                        "alpha".to_string(),
393                        "beta".to_string(),
394                        "dev".to_string(),
395                        "post".to_string(),
396                    ],
397                    ..Default::default()
398                },
399                security: SecurityConfig {
400                    known_typosquats: vec![
401                        TyposquatEntry {
402                            malicious: "python-dateutils".to_string(),
403                            legitimate: "python-dateutil".to_string(),
404                            description: Some("Common typosquat".to_string()),
405                        },
406                        TyposquatEntry {
407                            malicious: "request".to_string(),
408                            legitimate: "requests".to_string(),
409                            description: Some("Missing 's' typosquat".to_string()),
410                        },
411                    ],
412                    ..Default::default()
413                },
414                ..Default::default()
415            },
416        );
417
418        // npm rules
419        self.ecosystems.insert(
420            "npm".to_string(),
421            EcosystemConfig {
422                normalization: NormalizationConfig {
423                    case_sensitive: false,
424                    scope_handling: ScopeHandling::PreserveScopeCase,
425                    ..Default::default()
426                },
427                strip_prefixes: vec!["node-".to_string(), "@types/".to_string()],
428                strip_suffixes: vec!["-js".to_string(), ".js".to_string(), "-node".to_string()],
429                package_groups: Self::npm_package_groups(),
430                versioning: VersioningConfig {
431                    spec: VersionSpec::Semver,
432                    prerelease_tags: vec![
433                        "alpha".to_string(),
434                        "beta".to_string(),
435                        "rc".to_string(),
436                        "next".to_string(),
437                        "canary".to_string(),
438                    ],
439                    ..Default::default()
440                },
441                security: SecurityConfig {
442                    suspicious_patterns: vec![
443                        r"^[a-z]{1,2}$".to_string(), // Very short names
444                    ],
445                    ..Default::default()
446                },
447                ..Default::default()
448            },
449        );
450
451        // Cargo rules
452        self.ecosystems.insert(
453            "cargo".to_string(),
454            EcosystemConfig {
455                normalization: NormalizationConfig {
456                    case_sensitive: false,
457                    // Replace "-" with "_" (target is first, source is second)
458                    equivalent_chars: vec![vec!["_".to_string(), "-".to_string()]],
459                    ..Default::default()
460                },
461                strip_prefixes: vec!["rust-".to_string(), "lib".to_string()],
462                strip_suffixes: vec!["-rs".to_string(), "-rust".to_string()],
463                versioning: VersioningConfig {
464                    spec: VersionSpec::Semver,
465                    ..Default::default()
466                },
467                ..Default::default()
468            },
469        );
470
471        // Maven rules
472        self.ecosystems.insert(
473            "maven".to_string(),
474            EcosystemConfig {
475                normalization: NormalizationConfig {
476                    case_sensitive: true,
477                    use_full_coordinate: true,
478                    ..Default::default()
479                },
480                group_migrations: vec![GroupMigration {
481                    from: "javax.*".to_string(),
482                    to: "jakarta.*".to_string(),
483                    after_version: Some("9".to_string()),
484                }],
485                versioning: VersioningConfig {
486                    spec: VersionSpec::Maven,
487                    qualifier_order: vec![
488                        "alpha".to_string(),
489                        "beta".to_string(),
490                        "milestone".to_string(),
491                        "rc".to_string(),
492                        "snapshot".to_string(),
493                        "final".to_string(),
494                        "ga".to_string(),
495                        "sp".to_string(),
496                    ],
497                    ..Default::default()
498                },
499                ..Default::default()
500            },
501        );
502
503        // Go rules
504        self.ecosystems.insert(
505            "golang".to_string(),
506            EcosystemConfig {
507                normalization: NormalizationConfig {
508                    case_sensitive: true,
509                    strip_version_suffix: true,
510                    ..Default::default()
511                },
512                import_mappings: vec![
513                    ImportMapping {
514                        pattern: "github.com/*/*".to_string(),
515                        mapping_type: "github".to_string(),
516                    },
517                    ImportMapping {
518                        pattern: "golang.org/x/*".to_string(),
519                        mapping_type: "stdlib_extension".to_string(),
520                    },
521                ],
522                versioning: VersioningConfig {
523                    spec: VersionSpec::Gomod,
524                    ..Default::default()
525                },
526                ..Default::default()
527            },
528        );
529
530        // NuGet rules
531        self.ecosystems.insert(
532            "nuget".to_string(),
533            EcosystemConfig {
534                normalization: NormalizationConfig {
535                    case_sensitive: false,
536                    ..Default::default()
537                },
538                versioning: VersioningConfig {
539                    spec: VersionSpec::Semver,
540                    ..Default::default()
541                },
542                ..Default::default()
543            },
544        );
545
546        // RubyGems rules
547        self.ecosystems.insert(
548            "rubygems".to_string(),
549            EcosystemConfig {
550                normalization: NormalizationConfig {
551                    case_sensitive: true,
552                    ..Default::default()
553                },
554                strip_prefixes: vec!["ruby-".to_string()],
555                strip_suffixes: vec!["-ruby".to_string(), "-rb".to_string()],
556                versioning: VersioningConfig {
557                    spec: VersionSpec::Rubygems,
558                    ..Default::default()
559                },
560                ..Default::default()
561            },
562        );
563
564        // Composer (PHP) rules
565        self.ecosystems.insert(
566            "composer".to_string(),
567            EcosystemConfig {
568                normalization: NormalizationConfig {
569                    case_sensitive: false,
570                    use_full_coordinate: true,
571                    ..Default::default()
572                },
573                versioning: VersioningConfig {
574                    spec: VersionSpec::Semver,
575                    ..Default::default()
576                },
577                ..Default::default()
578            },
579        );
580
581        // Cross-ecosystem mappings
582        self.load_cross_ecosystem_mappings();
583    }
584
585    /// PyPI known aliases
586    fn pypi_aliases() -> HashMap<String, Vec<String>> {
587        let mut aliases = HashMap::new();
588        aliases.insert(
589            "pillow".to_string(),
590            vec!["PIL".to_string(), "python-pillow".to_string()],
591        );
592        aliases.insert(
593            "scikit-learn".to_string(),
594            vec!["sklearn".to_string(), "scikit_learn".to_string()],
595        );
596        aliases.insert(
597            "beautifulsoup4".to_string(),
598            vec![
599                "bs4".to_string(),
600                "BeautifulSoup".to_string(),
601                "beautifulsoup".to_string(),
602            ],
603        );
604        aliases.insert(
605            "pyyaml".to_string(),
606            vec!["yaml".to_string(), "PyYAML".to_string()],
607        );
608        aliases.insert(
609            "opencv-python".to_string(),
610            vec![
611                "cv2".to_string(),
612                "opencv-python-headless".to_string(),
613                "opencv".to_string(),
614            ],
615        );
616        aliases.insert("python-dateutil".to_string(), vec!["dateutil".to_string()]);
617        aliases.insert("attrs".to_string(), vec!["attr".to_string()]);
618        aliases.insert(
619            "importlib-metadata".to_string(),
620            vec!["importlib_metadata".to_string()],
621        );
622        aliases.insert(
623            "typing-extensions".to_string(),
624            vec!["typing_extensions".to_string()],
625        );
626        aliases
627    }
628
629    /// npm package groups
630    fn npm_package_groups() -> HashMap<String, PackageGroup> {
631        let mut groups = HashMap::new();
632        groups.insert(
633            "lodash".to_string(),
634            PackageGroup {
635                canonical: "lodash".to_string(),
636                members: vec![
637                    "lodash-es".to_string(),
638                    "lodash.merge".to_string(),
639                    "lodash.get".to_string(),
640                    "lodash.set".to_string(),
641                    "lodash.clonedeep".to_string(),
642                ],
643            },
644        );
645        groups.insert(
646            "babel".to_string(),
647            PackageGroup {
648                canonical: "@babel/core".to_string(),
649                members: vec!["@babel/*".to_string()],
650            },
651        );
652        groups.insert(
653            "react".to_string(),
654            PackageGroup {
655                canonical: "react".to_string(),
656                members: vec![
657                    "react-dom".to_string(),
658                    "react-router".to_string(),
659                    "react-redux".to_string(),
660                ],
661            },
662        );
663        groups
664    }
665
666    /// Load cross-ecosystem package mappings
667    fn load_cross_ecosystem_mappings(&mut self) {
668        // YAML parsing libraries
669        let mut yaml_mapping = HashMap::new();
670        yaml_mapping.insert("pypi".to_string(), Some("pyyaml".to_string()));
671        yaml_mapping.insert("npm".to_string(), Some("js-yaml".to_string()));
672        yaml_mapping.insert("cargo".to_string(), Some("serde_yaml".to_string()));
673        yaml_mapping.insert("golang".to_string(), Some("gopkg.in/yaml.v3".to_string()));
674        yaml_mapping.insert("rubygems".to_string(), Some("psych".to_string()));
675        self.cross_ecosystem
676            .insert("yaml_parsing".to_string(), yaml_mapping);
677
678        // JSON parsing libraries
679        let mut json_mapping = HashMap::new();
680        json_mapping.insert("pypi".to_string(), None); // stdlib
681        json_mapping.insert("npm".to_string(), None); // native
682        json_mapping.insert("cargo".to_string(), Some("serde_json".to_string()));
683        json_mapping.insert("golang".to_string(), None); // encoding/json stdlib
684        self.cross_ecosystem
685            .insert("json_parsing".to_string(), json_mapping);
686
687        // HTTP client libraries
688        let mut http_mapping = HashMap::new();
689        http_mapping.insert("pypi".to_string(), Some("requests".to_string()));
690        http_mapping.insert("npm".to_string(), Some("axios".to_string()));
691        http_mapping.insert("cargo".to_string(), Some("reqwest".to_string()));
692        http_mapping.insert("golang".to_string(), None); // net/http stdlib
693        http_mapping.insert("rubygems".to_string(), Some("faraday".to_string()));
694        self.cross_ecosystem
695            .insert("http_client".to_string(), http_mapping);
696
697        // Testing frameworks
698        let mut test_mapping = HashMap::new();
699        test_mapping.insert("pypi".to_string(), Some("pytest".to_string()));
700        test_mapping.insert("npm".to_string(), Some("jest".to_string()));
701        test_mapping.insert("cargo".to_string(), None); // built-in
702        test_mapping.insert("golang".to_string(), None); // testing stdlib
703        test_mapping.insert("rubygems".to_string(), Some("rspec".to_string()));
704        self.cross_ecosystem
705            .insert("testing".to_string(), test_mapping);
706    }
707
708    /// Get configuration for a specific ecosystem
709    pub fn get_ecosystem(&self, ecosystem: &str) -> Option<&EcosystemConfig> {
710        self.ecosystems.get(&ecosystem.to_lowercase())
711    }
712
713    /// Check if configuration is empty
714    pub fn is_empty(&self) -> bool {
715        self.ecosystems.is_empty()
716            && self.cross_ecosystem.is_empty()
717            && self.custom_rules.equivalences.is_empty()
718    }
719
720    /// Merge another configuration into this one (other takes precedence)
721    pub fn merge(&mut self, other: Self) {
722        // Merge ecosystems
723        for (key, value) in other.ecosystems {
724            self.ecosystems.insert(key, value);
725        }
726
727        // Merge cross-ecosystem mappings
728        for (key, value) in other.cross_ecosystem {
729            self.cross_ecosystem.insert(key, value);
730        }
731
732        // Merge custom rules
733        self.custom_rules
734            .internal_prefixes
735            .extend(other.custom_rules.internal_prefixes);
736        self.custom_rules
737            .equivalences
738            .extend(other.custom_rules.equivalences);
739        self.custom_rules
740            .ignored_packages
741            .extend(other.custom_rules.ignored_packages);
742
743        // Update settings if explicitly set
744        if other.settings.enable_security_checks != self.settings.enable_security_checks {
745            self.settings.enable_security_checks = other.settings.enable_security_checks;
746        }
747    }
748
749    /// Export configuration to YAML
750    pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
751        serde_yaml_ng::to_string(self)
752    }
753
754    /// Export configuration to JSON
755    pub fn to_json(&self) -> Result<String, serde_json::Error> {
756        serde_json::to_string_pretty(self)
757    }
758}
759
760impl Default for EcosystemRulesConfig {
761    fn default() -> Self {
762        Self::builtin()
763    }
764}
765
766/// Configuration loading error
767#[derive(Debug)]
768pub enum ConfigError {
769    Io(std::io::Error),
770    Yaml(serde_yaml_ng::Error),
771    Json(serde_json::Error),
772}
773
774impl std::fmt::Display for ConfigError {
775    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776        match self {
777            Self::Io(e) => write!(f, "IO error: {}", e),
778            Self::Yaml(e) => write!(f, "YAML parse error: {}", e),
779            Self::Json(e) => write!(f, "JSON parse error: {}", e),
780        }
781    }
782}
783
784impl std::error::Error for ConfigError {}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    #[test]
791    fn test_builtin_config() {
792        let config = EcosystemRulesConfig::builtin();
793
794        assert!(config.ecosystems.contains_key("pypi"));
795        assert!(config.ecosystems.contains_key("npm"));
796        assert!(config.ecosystems.contains_key("cargo"));
797        assert!(config.ecosystems.contains_key("maven"));
798        assert!(config.ecosystems.contains_key("golang"));
799    }
800
801    #[test]
802    fn test_pypi_config() {
803        let config = EcosystemRulesConfig::builtin();
804        let pypi = config.get_ecosystem("pypi").unwrap();
805
806        assert!(!pypi.normalization.case_sensitive);
807        assert!(!pypi.strip_prefixes.is_empty());
808        assert!(pypi.aliases.contains_key("pillow"));
809        assert_eq!(pypi.versioning.spec, VersionSpec::Pep440);
810    }
811
812    #[test]
813    fn test_npm_config() {
814        let config = EcosystemRulesConfig::builtin();
815        let npm = config.get_ecosystem("npm").unwrap();
816
817        assert_eq!(
818            npm.normalization.scope_handling,
819            ScopeHandling::PreserveScopeCase
820        );
821        assert!(npm.package_groups.contains_key("lodash"));
822    }
823
824    #[test]
825    fn test_cross_ecosystem_mapping() {
826        let config = EcosystemRulesConfig::builtin();
827
828        let yaml_libs = config.cross_ecosystem.get("yaml_parsing").unwrap();
829        assert_eq!(yaml_libs.get("pypi").unwrap(), &Some("pyyaml".to_string()));
830        assert_eq!(yaml_libs.get("npm").unwrap(), &Some("js-yaml".to_string()));
831    }
832
833    #[test]
834    fn test_yaml_parsing() {
835        let yaml = r#"
836version: "1.0"
837settings:
838  case_sensitive_default: false
839ecosystems:
840  custom:
841    normalization:
842      case_sensitive: true
843    strip_prefixes:
844      - "my-"
845    strip_suffixes:
846      - "-custom"
847"#;
848
849        let config = EcosystemRulesConfig::from_yaml(yaml).unwrap();
850        assert!(config.ecosystems.contains_key("custom"));
851
852        let custom = config.get_ecosystem("custom").unwrap();
853        assert!(custom.normalization.case_sensitive);
854        assert_eq!(custom.strip_prefixes, vec!["my-"]);
855    }
856
857    #[test]
858    fn test_config_merge() {
859        let mut base = EcosystemRulesConfig::builtin();
860        let overlay = EcosystemRulesConfig::from_yaml(
861            r#"
862ecosystems:
863  pypi:
864    strip_prefixes:
865      - "custom-"
866custom_rules:
867  internal_prefixes:
868    - "@mycompany/"
869"#,
870        )
871        .unwrap();
872
873        base.merge(overlay);
874
875        // Overlay should override pypi
876        let pypi = base.get_ecosystem("pypi").unwrap();
877        assert_eq!(pypi.strip_prefixes, vec!["custom-"]);
878
879        // Custom rules should be merged
880        assert!(base
881            .custom_rules
882            .internal_prefixes
883            .contains(&"@mycompany/".to_string()));
884    }
885
886    #[test]
887    fn test_security_config() {
888        let config = EcosystemRulesConfig::builtin();
889        let pypi = config.get_ecosystem("pypi").unwrap();
890
891        assert!(!pypi.security.known_typosquats.is_empty());
892        let typosquat = &pypi.security.known_typosquats[0];
893        assert_eq!(typosquat.malicious, "python-dateutils");
894        assert_eq!(typosquat.legitimate, "python-dateutil");
895    }
896}