garbage_code_hunter/commit_roaster/
rules.rs1use crate::common::Severity;
7use serde::Deserialize;
8
9#[derive(Debug, Clone, Deserialize)]
11#[serde(tag = "type", content = "value")]
12pub enum Pattern {
13 Regex(String),
15 Contains(String),
17 StartsWith(String),
19 Length {
21 min: Option<usize>,
22 max: Option<usize>,
23 },
24}
25
26#[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#[derive(Debug, Clone, Deserialize)]
45pub struct RuleSet {
46 pub rules: Vec<Rule>,
47}
48
49impl RuleSet {
50 pub fn from_toml(content: &str) -> Result<Self, toml::de::Error> {
52 toml::from_str(content)
53 }
54
55 pub fn active_rules(&self) -> Vec<&Rule> {
57 self.rules.iter().filter(|r| r.enabled).collect()
58 }
59}
60
61#[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
70pub 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
103pub 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}