use serde::{Deserialize, Deserializer, Serialize};
fn deserialize_patterns_field<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVecOrNull {
Single(String),
Many(Vec<String>),
Null,
}
match StringOrVecOrNull::deserialize(deserializer)? {
StringOrVecOrNull::Single(s) => Ok(vec![s]),
StringOrVecOrNull::Many(v) => Ok(v),
StringOrVecOrNull::Null => Ok(vec![]),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub id: String,
pub category: String,
#[serde(default)]
pub subcategory: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(
default,
alias = "pattern",
deserialize_with = "deserialize_patterns_field"
)]
pub patterns: Vec<String>,
#[serde(default = "default_rule_priority")]
pub priority: i32,
#[serde(default = "default_confidence")]
pub confidence: f64,
}
fn default_confidence() -> f64 {
0.85
}
fn default_rule_priority() -> i32 {
110
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleSet {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub extend_defaults: bool,
pub rules: Vec<Rule>,
}
impl RuleSet {
pub fn by_priority(&self) -> Vec<&Rule> {
let mut refs: Vec<&Rule> = self.rules.iter().collect();
refs.sort_by_key(|r| std::cmp::Reverse(r.priority));
refs
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rule_singular_pattern_deserializes() {
let yaml = r#"
id: test-1
category: new_feature
pattern: "(?i)^feat"
"#;
let rule: Rule = serde_yaml::from_str(yaml).expect("deserialize");
assert_eq!(
rule.patterns,
vec!["(?i)^feat".to_string()],
"singular `pattern:` must be coerced to a single-element vec"
);
}
#[test]
fn rule_plural_patterns_deserializes() {
let yaml = r#"
id: test-2
category: new_feature
patterns:
- "(?i)^feat"
- "(?i)^feature"
"#;
let rule: Rule = serde_yaml::from_str(yaml).expect("deserialize");
assert_eq!(rule.patterns.len(), 2);
assert_eq!(rule.patterns[0], "(?i)^feat");
assert_eq!(rule.patterns[1], "(?i)^feature");
}
#[test]
fn rule_missing_patterns_field_gives_empty_vec() {
let yaml = r#"
id: test-3
category: bugfix
keywords:
- "fix:"
"#;
let rule: Rule = serde_yaml::from_str(yaml).expect("deserialize");
assert!(rule.patterns.is_empty());
assert_eq!(rule.keywords, vec!["fix:".to_string()]);
}
#[test]
fn rule_singular_pattern_regex_compiles_and_matches() {
let yaml = r#"
id: test-4
category: new_feature
pattern: "(?i)^feat[:(]"
"#;
let rule: Rule = serde_yaml::from_str(yaml).expect("deserialize");
assert_eq!(rule.patterns.len(), 1);
let re = regex::Regex::new(&rule.patterns[0]).expect("compile");
assert!(re.is_match("feat: add login flow"));
assert!(re.is_match("feat(api): new endpoint"));
assert!(!re.is_match("fix: null deref"));
}
}