use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct MatchingRulesConfig {
#[serde(default)]
pub precedence: RulePrecedence,
#[serde(default)]
pub equivalences: Vec<EquivalenceGroup>,
#[serde(default)]
pub exclusions: Vec<ExclusionRule>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum RulePrecedence {
#[default]
FirstMatch,
MostSpecific,
}
impl std::fmt::Display for RulePrecedence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FirstMatch => write!(f, "first-match"),
Self::MostSpecific => write!(f, "most-specific"),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct EquivalenceGroup {
#[serde(default)]
pub name: Option<String>,
pub canonical: String,
#[serde(default)]
pub aliases: Vec<AliasPattern>,
#[serde(default)]
pub version_sensitive: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum AliasPattern {
Exact(String),
Pattern {
#[serde(default)]
pattern: Option<String>,
#[serde(default)]
regex: Option<String>,
#[serde(default)]
ecosystem: Option<String>,
#[serde(default)]
name: Option<String>,
},
}
impl AliasPattern {
pub fn exact(purl: impl Into<String>) -> Self {
Self::Exact(purl.into())
}
pub fn glob(pattern: impl Into<String>) -> Self {
Self::Pattern {
pattern: Some(pattern.into()),
regex: None,
ecosystem: None,
name: None,
}
}
pub fn regex(pattern: impl Into<String>) -> Self {
Self::Pattern {
pattern: None,
regex: Some(pattern.into()),
ecosystem: None,
name: None,
}
}
#[must_use]
pub fn description(&self) -> String {
match self {
Self::Exact(purl) => format!("exact:{purl}"),
Self::Pattern {
pattern,
regex,
ecosystem,
name,
} => {
let mut parts = Vec::new();
if let Some(p) = pattern {
parts.push(format!("pattern:{p}"));
}
if let Some(r) = regex {
parts.push(format!("regex:{r}"));
}
if let Some(e) = ecosystem {
parts.push(format!("ecosystem:{e}"));
}
if let Some(n) = name {
parts.push(format!("name:{n}"));
}
parts.join(", ")
}
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ExclusionRule {
Exact(String),
Conditional {
#[serde(default)]
pattern: Option<String>,
#[serde(default)]
regex: Option<String>,
#[serde(default)]
ecosystem: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
reason: Option<String>,
},
}
impl ExclusionRule {
pub fn exact(purl: impl Into<String>) -> Self {
Self::Exact(purl.into())
}
pub fn pattern(pattern: impl Into<String>) -> Self {
Self::Conditional {
pattern: Some(pattern.into()),
regex: None,
ecosystem: None,
name: None,
scope: None,
reason: None,
}
}
pub fn ecosystem(ecosystem: impl Into<String>) -> Self {
Self::Conditional {
pattern: None,
regex: None,
ecosystem: Some(ecosystem.into()),
name: None,
scope: None,
reason: None,
}
}
#[must_use]
pub fn get_reason(&self) -> Option<&str> {
match self {
Self::Exact(_) => None,
Self::Conditional { reason, .. } => reason.as_deref(),
}
}
#[must_use]
pub fn description(&self) -> String {
match self {
Self::Exact(purl) => format!("exact:{purl}"),
Self::Conditional {
pattern,
regex,
ecosystem,
name,
scope,
reason,
} => {
let mut parts = Vec::new();
if let Some(p) = pattern {
parts.push(format!("pattern:{p}"));
}
if let Some(r) = regex {
parts.push(format!("regex:{r}"));
}
if let Some(e) = ecosystem {
parts.push(format!("ecosystem:{e}"));
}
if let Some(n) = name {
parts.push(format!("name:{n}"));
}
if let Some(s) = scope {
parts.push(format!("scope:{s}"));
}
if let Some(r) = reason {
parts.push(format!("reason:{r}"));
}
parts.join(", ")
}
}
}
}
impl MatchingRulesConfig {
pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
serde_yaml_ng::from_str(yaml)
}
pub fn from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let config = Self::from_yaml(&content)?;
Ok(config)
}
#[must_use]
pub fn summary(&self) -> RulesSummary {
RulesSummary {
equivalence_groups: self.equivalences.len(),
total_aliases: self.equivalences.iter().map(|e| e.aliases.len()).sum(),
exclusion_rules: self.exclusions.len(),
precedence: self.precedence,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.equivalences.is_empty() && self.exclusions.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct RulesSummary {
pub equivalence_groups: usize,
pub total_aliases: usize,
pub exclusion_rules: usize,
pub precedence: RulePrecedence,
}
impl std::fmt::Display for RulesSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} equivalence group(s) ({} aliases), {} exclusion rule(s), precedence: {}",
self.equivalence_groups, self.total_aliases, self.exclusion_rules, self.precedence
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_yaml_config() {
let yaml = r#"
precedence: first-match
equivalences:
- name: "Log4j family"
canonical: "pkg:maven/org.apache.logging.log4j/log4j-core"
aliases:
- "pkg:maven/org.apache.logging.log4j/log4j-api"
- pattern: "pkg:maven/org.apache.logging.log4j/log4j-*"
exclusions:
- "pkg:maven/junit/junit"
- ecosystem: "npm"
scope: "dev"
reason: "Excluding npm dev dependencies"
"#;
let config = MatchingRulesConfig::from_yaml(yaml).expect("Failed to parse YAML");
assert_eq!(config.precedence, RulePrecedence::FirstMatch);
assert_eq!(config.equivalences.len(), 1);
assert_eq!(config.equivalences[0].aliases.len(), 2);
assert_eq!(config.exclusions.len(), 2);
}
#[test]
fn test_empty_config() {
let config = MatchingRulesConfig::default();
assert!(config.is_empty());
assert_eq!(config.precedence, RulePrecedence::FirstMatch);
}
#[test]
fn test_alias_pattern_description() {
let exact = AliasPattern::exact("pkg:npm/lodash");
assert!(exact.description().contains("exact:"));
let glob = AliasPattern::glob("pkg:maven/*");
assert!(glob.description().contains("pattern:"));
}
#[test]
fn test_exclusion_rule_description() {
let exact = ExclusionRule::exact("pkg:npm/jest");
assert!(exact.description().contains("exact:"));
let ecosystem = ExclusionRule::ecosystem("npm");
assert!(ecosystem.description().contains("ecosystem:"));
}
#[test]
fn test_rules_summary() {
let config = MatchingRulesConfig {
precedence: RulePrecedence::MostSpecific,
equivalences: vec![EquivalenceGroup {
name: Some("Test".to_string()),
canonical: "pkg:npm/test".to_string(),
aliases: vec![
AliasPattern::exact("pkg:npm/test-alias"),
AliasPattern::exact("pkg:npm/test-other"),
],
version_sensitive: false,
}],
exclusions: vec![ExclusionRule::exact("pkg:npm/jest")],
};
let summary = config.summary();
assert_eq!(summary.equivalence_groups, 1);
assert_eq!(summary.total_aliases, 2);
assert_eq!(summary.exclusion_rules, 1);
assert_eq!(summary.precedence, RulePrecedence::MostSpecific);
}
}