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)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
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,
}
impl Rule {
pub fn new(id: impl Into<String>, category: impl Into<String>) -> Self {
Self {
id: id.into(),
category: category.into(),
subcategory: None,
keywords: Vec::new(),
patterns: Vec::new(),
priority: default_rule_priority(),
confidence: default_confidence(),
}
}
}
fn default_confidence() -> f64 {
0.85
}
fn default_rule_priority() -> i32 {
110
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct RuleSet {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub extend_defaults: bool,
pub rules: Vec<Rule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<CategoryDef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub buckets: Option<crate::core::config::BucketMap>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct CategoryDef {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
impl CategoryDef {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
}
}
}
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
}
pub fn merge(self, other: RuleSet) -> RuleSet {
use std::collections::HashMap;
let mut by_id: HashMap<String, Rule> = HashMap::with_capacity(self.rules.len());
let mut order: Vec<String> = Vec::with_capacity(self.rules.len());
for rule in self.rules {
order.push(rule.id.clone());
by_id.insert(rule.id.clone(), rule);
}
for rule in other.rules {
if !by_id.contains_key(&rule.id) {
order.push(rule.id.clone());
}
by_id.insert(rule.id.clone(), rule);
}
let rules: Vec<Rule> = order
.into_iter()
.filter_map(|id| by_id.remove(&id))
.collect();
let mut categories = self.categories;
for def in other.categories {
match categories.iter_mut().find(|c| c.name == def.name) {
Some(existing) => *existing = def,
None => categories.push(def),
}
}
RuleSet {
version: other.version.or(self.version),
extend_defaults: other.extend_defaults,
rules,
categories,
buckets: other.buckets.or(self.buckets),
}
}
}
#[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"));
}
#[test]
fn rule_unknown_field_is_rejected() {
let yaml = r#"
id: test-5
category: bug_fix
keywords: ["bugfix:"]
method: regex_rule
"#;
let result: Result<Rule, _> = serde_yaml::from_str(yaml);
assert!(
result.is_err(),
"Rule with unknown `method:` field must be rejected at parse time"
);
}
#[test]
fn rule_set_unknown_field_is_rejected() {
let yaml = r#"
extends_defaults: false
rules:
- id: my-rule
category: bug_fix
keywords: ["bugfix:"]
"#;
let result: Result<RuleSet, _> = serde_yaml::from_str(yaml);
assert!(
result.is_err(),
"RuleSet with unknown `extends_defaults:` (typo) must be rejected"
);
}
#[test]
fn categories_section_merges_by_name() {
let base: RuleSet = serde_yaml::from_str(
"rules: []\ncategories:\n - name: bug_fix\n description: old\n - name: feature\n",
)
.expect("base");
let overlay: RuleSet = serde_yaml::from_str(
"rules: []\ncategories:\n - name: bug_fix\n description: new\n",
)
.expect("overlay");
let merged = base.merge(overlay);
let names: Vec<&str> = merged.categories.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["bug_fix", "feature"]);
assert_eq!(merged.categories[0].description.as_deref(), Some("new"));
assert!(serde_yaml::from_str::<RuleSet>(
"rules: []\ncategories:\n - name: x\n desc: typo\n"
)
.is_err());
}
}