Skip to main content

garbage_code_hunter/commit_roaster/
rules.rs

1//! Commit message rules and matching engine.
2//!
3//! Defines rules for detecting bad commit message patterns
4//! and provides a matching engine to evaluate commits.
5
6use crate::common::Severity;
7use serde::Deserialize;
8
9/// Pattern type for rule matching.
10#[derive(Debug, Clone, Deserialize)]
11#[serde(tag = "type", content = "value")]
12pub enum Pattern {
13    /// Match via regex.
14    Regex(String),
15    /// Match if message contains substring.
16    Contains(String),
17    /// Match if message starts with prefix.
18    StartsWith(String),
19    /// Match if message length is within bounds.
20    Length {
21        min: Option<usize>,
22        max: Option<usize>,
23    },
24}
25
26/// A single commit message rule.
27#[derive(Debug, Clone, Deserialize)]
28pub struct Rule {
29    pub id: String,
30    pub name: String,
31    pub description: String,
32    pub severity: Severity,
33    pub pattern: Pattern,
34    pub message: String,
35    #[serde(default = "default_enabled")]
36    pub enabled: bool,
37}
38
39fn default_enabled() -> bool {
40    true
41}
42
43/// Collection of rules loaded from TOML.
44#[derive(Debug, Clone, Deserialize)]
45pub struct RuleSet {
46    pub rules: Vec<Rule>,
47}
48
49impl RuleSet {
50    /// Load rules from a TOML string.
51    pub fn from_toml(content: &str) -> Result<Self, toml::de::Error> {
52        toml::from_str(content)
53    }
54
55    /// Returns only enabled rules.
56    pub fn active_rules(&self) -> Vec<&Rule> {
57        self.rules.iter().filter(|r| r.enabled).collect()
58    }
59}
60
61/// A matched issue from a commit message.
62#[derive(Debug, Clone)]
63pub struct Issue {
64    pub rule_id: String,
65    pub rule_name: String,
66    pub severity: Severity,
67    pub message: String,
68}
69
70/// Match a commit message against all rules in the set.
71pub fn match_rules(message: &str, rules: &[&Rule]) -> Vec<Issue> {
72    let mut issues = Vec::new();
73    let trimmed = message.trim();
74
75    for rule in rules {
76        let matched = match &rule.pattern {
77            Pattern::Regex(pattern) => regex::Regex::new(pattern)
78                .map(|re| re.is_match(trimmed))
79                .unwrap_or(false),
80            Pattern::Contains(sub) => trimmed.contains(sub.as_str()),
81            Pattern::StartsWith(prefix) => trimmed.starts_with(prefix.as_str()),
82            Pattern::Length { min, max } => {
83                let len = trimmed.len();
84                let above_min = min.is_none_or(|m| len >= m);
85                let below_max = max.is_none_or(|m| len <= m);
86                above_min && below_max
87            }
88        };
89
90        if matched {
91            issues.push(Issue {
92                rule_id: rule.id.clone(),
93                rule_name: rule.name.clone(),
94                severity: rule.severity,
95                message: rule.message.clone(),
96            });
97        }
98    }
99
100    issues
101}
102
103/// Returns the default built-in rules as a TOML string.
104pub fn default_rules_toml() -> &'static str {
105    include_str!("rules/commit_rules.toml")
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn load_default_rules() -> RuleSet {
113        RuleSet::from_toml(default_rules_toml()).expect("Failed to parse default rules TOML")
114    }
115
116    #[test]
117    fn test_parse_default_rules() {
118        let ruleset = load_default_rules();
119        assert!(
120            !ruleset.rules.is_empty(),
121            "Default rules should not be empty"
122        );
123    }
124
125    #[test]
126    fn test_empty_message_matches() {
127        let ruleset = load_default_rules();
128        let active = ruleset.active_rules();
129        let issues = match_rules("", &active);
130        assert!(
131            issues.iter().any(|i| i.rule_id == "empty-message"),
132            "Empty message should trigger empty-message rule"
133        );
134    }
135
136    #[test]
137    fn test_short_message_matches() {
138        let ruleset = load_default_rules();
139        let active = ruleset.active_rules();
140        let issues = match_rules("fix", &active);
141        assert!(
142            issues.iter().any(|i| i.rule_id == "too-short"),
143            "Short message should trigger too-short rule"
144        );
145    }
146
147    #[test]
148    fn test_wip_message_matches() {
149        let ruleset = load_default_rules();
150        let active = ruleset.active_rules();
151        let issues = match_rules("WIP: working on auth", &active);
152        assert!(
153            issues.iter().any(|i| i.rule_id == "wip-commit"),
154            "WIP message should trigger wip-commit rule"
155        );
156    }
157
158    #[test]
159    fn test_good_message_no_issues() {
160        let ruleset = load_default_rules();
161        let active = ruleset.active_rules();
162        let issues = match_rules("feat(auth): implement OAuth2 login flow with PKCE", &active);
163        let critical_or_high: Vec<_> = issues
164            .iter()
165            .filter(|i| i.severity == Severity::Critical || i.severity == Severity::High)
166            .collect();
167        assert!(
168            critical_or_high.is_empty(),
169            "Good message should not trigger critical/high rules, got: {:?}",
170            critical_or_high
171        );
172    }
173
174    #[test]
175    fn test_disabled_rules_not_matched() {
176        let toml = r#"
177[[rules]]
178id = "test-rule"
179name = "Test"
180description = "Test rule"
181severity = "High"
182pattern = { type = "Contains", value = "test" }
183message = "Test matched"
184enabled = false
185"#;
186        let ruleset = RuleSet::from_toml(toml).unwrap();
187        let active = ruleset.active_rules();
188        let issues = match_rules("this is a test", &active);
189        assert!(issues.is_empty(), "Disabled rules should not be matched");
190    }
191}