garbage_code_hunter/pr_title_hunter/
rules.rs1use super::types::{PrEntry, PrIssue, Severity};
4use regex::Regex;
5use std::sync::LazyLock;
6
7pub trait PrRule: Send + Sync {
9 fn id(&self) -> &str;
10 fn check(&self, pr: &PrEntry) -> Option<PrIssue>;
11}
12
13pub struct EmptyTitleRule;
15
16impl PrRule for EmptyTitleRule {
17 fn id(&self) -> &str {
18 "empty-title"
19 }
20
21 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
22 if pr.title.trim().is_empty() {
23 Some(PrIssue {
24 rule_id: self.id().to_string(),
25 severity: Severity::Critical,
26 message: "PR title is empty — did you submit by accident?".to_string(),
27 pr_id: pr.id.clone(),
28 pr_title: pr.title.clone(),
29 })
30 } else {
31 None
32 }
33 }
34}
35
36pub struct TooShortRule {
38 pub min_length: usize,
39}
40
41impl PrRule for TooShortRule {
42 fn id(&self) -> &str {
43 "too-short"
44 }
45
46 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
47 let trimmed = pr.title.trim();
48 if !trimmed.is_empty() && trimmed.chars().count() <= self.min_length {
49 Some(PrIssue {
50 rule_id: self.id().to_string(),
51 severity: Severity::High,
52 message: format!(
53 "PR title '{}' is {} chars — sending a telegram?",
54 trimmed,
55 trimmed.chars().count()
56 ),
57 pr_id: pr.id.clone(),
58 pr_title: pr.title.clone(),
59 })
60 } else {
61 None
62 }
63 }
64}
65
66static GENERIC_RE: LazyLock<Regex> = LazyLock::new(|| {
68 Regex::new(r"(?i)^(fix|update|change|modify|refactor|patch|chore|misc|wip|tmp|test)$").unwrap()
69});
70
71pub struct GenericTitleRule;
72
73impl PrRule for GenericTitleRule {
74 fn id(&self) -> &str {
75 "generic-title"
76 }
77
78 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
79 let trimmed = pr.title.trim();
80 if GENERIC_RE.is_match(trimmed) {
81 Some(PrIssue {
82 rule_id: self.id().to_string(),
83 severity: Severity::High,
84 message: format!(
85 "PR title is '{}' — are you a robot? Say what you actually changed.",
86 trimmed
87 ),
88 pr_id: pr.id.clone(),
89 pr_title: pr.title.clone(),
90 })
91 } else {
92 None
93 }
94 }
95}
96
97static TICKET_ONLY_RE: LazyLock<Regex> =
99 LazyLock::new(|| Regex::new(r"^([A-Z]+-\d+|#\d+)$").unwrap());
100
101pub struct TicketOnlyRule;
102
103impl PrRule for TicketOnlyRule {
104 fn id(&self) -> &str {
105 "ticket-only"
106 }
107
108 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
109 let trimmed = pr.title.trim();
110 if TICKET_ONLY_RE.is_match(trimmed) {
111 Some(PrIssue {
112 rule_id: self.id().to_string(),
113 severity: Severity::Medium,
114 message: format!(
115 "PR title is just '{}' — titles are for humans, not JIRA.",
116 trimmed
117 ),
118 pr_id: pr.id.clone(),
119 pr_title: pr.title.clone(),
120 })
121 } else {
122 None
123 }
124 }
125}
126
127static WIP_RE: LazyLock<Regex> =
129 LazyLock::new(|| Regex::new(r"(?i)^(wip|draft|do not merge|dnm|work.in.progress)").unwrap());
130
131pub struct WipTitleRule;
132
133impl PrRule for WipTitleRule {
134 fn id(&self) -> &str {
135 "wip-title"
136 }
137
138 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
139 let trimmed = pr.title.trim();
140 if WIP_RE.is_match(trimmed) {
141 Some(PrIssue {
142 rule_id: self.id().to_string(),
143 severity: Severity::Info,
144 message: "WIP PR? Then why open a PR at all?".to_string(),
145 pr_id: pr.id.clone(),
146 pr_title: pr.title.clone(),
147 })
148 } else {
149 None
150 }
151 }
152}
153
154pub struct ExclamationRule;
156
157impl PrRule for ExclamationRule {
158 fn id(&self) -> &str {
159 "exclamation-marks"
160 }
161
162 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
163 let count = pr.title.matches('!').count();
164 if count >= 3 {
165 Some(PrIssue {
166 rule_id: self.id().to_string(),
167 severity: Severity::Low,
168 message: format!("{} exclamation marks? How excited are you?", count),
169 pr_id: pr.id.clone(),
170 pr_title: pr.title.clone(),
171 })
172 } else {
173 None
174 }
175 }
176}
177
178pub struct AllCapsRule;
180
181impl PrRule for AllCapsRule {
182 fn id(&self) -> &str {
183 "all-caps"
184 }
185
186 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
187 let trimmed = pr.title.trim();
188 let alpha_chars: String = trimmed.chars().filter(|c| c.is_alphabetic()).collect();
189 if alpha_chars.len() >= 3 && alpha_chars == alpha_chars.to_uppercase() {
190 Some(PrIssue {
191 rule_id: self.id().to_string(),
192 severity: Severity::Low,
193 message: "ALL CAPS TITLE? STOP SHOUTING!".to_string(),
194 pr_id: pr.id.clone(),
195 pr_title: pr.title.clone(),
196 })
197 } else {
198 None
199 }
200 }
201}
202
203static MASH_RE: LazyLock<Regex> =
205 LazyLock::new(|| Regex::new(r"(?i)^(asdf|qwer|zxcv| hjkl|aaaa+|xxx+|zzz+)$").unwrap());
206
207pub struct KeyboardMashRule;
208
209impl PrRule for KeyboardMashRule {
210 fn id(&self) -> &str {
211 "keyboard-mash"
212 }
213
214 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
215 let trimmed = pr.title.trim();
216 if MASH_RE.is_match(trimmed) {
217 Some(PrIssue {
218 rule_id: self.id().to_string(),
219 severity: Severity::Critical,
220 message: "Keyboard mash as PR title? You're not testing your keyboard.".to_string(),
221 pr_id: pr.id.clone(),
222 pr_title: pr.title.clone(),
223 })
224 } else {
225 None
226 }
227 }
228}
229
230static CONVENTIONAL_RE: LazyLock<Regex> = LazyLock::new(|| {
232 Regex::new(r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?(!)?:\s")
233 .unwrap()
234});
235
236pub struct LowercaseStartRule;
237
238impl PrRule for LowercaseStartRule {
239 fn id(&self) -> &str {
240 "lowercase-start"
241 }
242
243 fn check(&self, pr: &PrEntry) -> Option<PrIssue> {
244 let trimmed = pr.title.trim();
245 if CONVENTIONAL_RE.is_match(trimmed) {
246 return None;
247 }
248 if let Some(first) = trimmed.chars().next() {
249 if first.is_ascii_lowercase() && trimmed.len() > 3 {
250 return Some(PrIssue {
251 rule_id: self.id().to_string(),
252 severity: Severity::Low,
253 message: format!(
254 "Title starts with lowercase '{}' — proper nouns deserve capital letters.",
255 first
256 ),
257 pr_id: pr.id.clone(),
258 pr_title: pr.title.clone(),
259 });
260 }
261 }
262 None
263 }
264}
265
266pub fn default_rules() -> Vec<Box<dyn PrRule>> {
268 vec![
269 Box::new(EmptyTitleRule),
270 Box::new(TooShortRule { min_length: 5 }),
271 Box::new(GenericTitleRule),
272 Box::new(TicketOnlyRule),
273 Box::new(WipTitleRule),
274 Box::new(ExclamationRule),
275 Box::new(AllCapsRule),
276 Box::new(KeyboardMashRule),
277 Box::new(LowercaseStartRule),
278 ]
279}
280
281pub fn check_pr(pr: &PrEntry) -> Vec<PrIssue> {
283 default_rules()
284 .iter()
285 .filter_map(|rule| rule.check(pr))
286 .collect()
287}
288
289pub fn check_prs(prs: &[PrEntry]) -> Vec<PrIssue> {
291 prs.iter().flat_map(check_pr).collect()
292}
293
294#[cfg(test)]
295mod tests {
296 use super::super::types::PrSource;
297 use super::*;
298
299 fn make_pr(id: &str, title: &str) -> PrEntry {
300 PrEntry {
301 id: id.to_string(),
302 title: title.to_string(),
303 author: None,
304 source: PrSource::Local,
305 }
306 }
307
308 #[test]
309 fn test_empty_title() {
310 let issues = check_pr(&make_pr("1", ""));
311 assert!(issues.iter().any(|i| i.rule_id == "empty-title"));
312 }
313
314 #[test]
315 fn test_whitespace_title() {
316 let issues = check_pr(&make_pr("1", " "));
317 assert!(issues.iter().any(|i| i.rule_id == "empty-title"));
318 }
319
320 #[test]
321 fn test_too_short() {
322 let issues = check_pr(&make_pr("1", "fix"));
323 assert!(issues.iter().any(|i| i.rule_id == "too-short"));
324 }
325
326 #[test]
327 fn test_normal_title_no_issues() {
328 let issues = check_pr(&make_pr(
329 "1",
330 "feat(auth): implement OAuth2 login flow with PKCE",
331 ));
332 assert!(issues.is_empty());
333 }
334
335 #[test]
336 fn test_generic_title_fix() {
337 let issues = check_pr(&make_pr("1", "fix"));
338 assert!(issues.iter().any(|i| i.rule_id == "generic-title"));
340 }
341
342 #[test]
343 fn test_generic_title_update() {
344 let issues = check_pr(&make_pr("1", "update"));
345 assert!(issues.iter().any(|i| i.rule_id == "generic-title"));
346 }
347
348 #[test]
349 fn test_ticket_only() {
350 let issues = check_pr(&make_pr("1", "PROJ-123"));
351 assert!(issues.iter().any(|i| i.rule_id == "ticket-only"));
352 }
353
354 #[test]
355 fn test_ticket_hash() {
356 let issues = check_pr(&make_pr("1", "#456"));
357 assert!(issues.iter().any(|i| i.rule_id == "ticket-only"));
358 }
359
360 #[test]
361 fn test_wip_title() {
362 let issues = check_pr(&make_pr("1", "WIP: new feature"));
363 assert!(issues.iter().any(|i| i.rule_id == "wip-title"));
364 }
365
366 #[test]
367 fn test_draft_title() {
368 let issues = check_pr(&make_pr("1", "Draft: refactoring"));
369 assert!(issues.iter().any(|i| i.rule_id == "wip-title"));
370 }
371
372 #[test]
373 fn test_exclamation_marks() {
374 let issues = check_pr(&make_pr("1", "fix the bug!!!"));
375 assert!(issues.iter().any(|i| i.rule_id == "exclamation-marks"));
376 }
377
378 #[test]
379 fn test_all_caps() {
380 let issues = check_pr(&make_pr("1", "FIX ALL THE THINGS"));
381 assert!(issues.iter().any(|i| i.rule_id == "all-caps"));
382 }
383
384 #[test]
385 fn test_keyboard_mash() {
386 let issues = check_pr(&make_pr("1", "asdf"));
387 assert!(issues.iter().any(|i| i.rule_id == "keyboard-mash"));
388 }
389
390 #[test]
391 fn test_lowercase_start() {
392 let issues = check_pr(&make_pr("1", "fix the login bug"));
393 assert!(issues.iter().any(|i| i.rule_id == "lowercase-start"));
394 }
395
396 #[test]
397 fn test_conventional_commit_ok() {
398 let issues = check_pr(&make_pr("1", "feat(api): add user search endpoint"));
399 assert!(issues.is_empty());
400 }
401
402 #[test]
403 fn test_check_prs_multiple() {
404 let prs = vec![
405 make_pr("1", "feat: good title"),
406 make_pr("2", "fix"),
407 make_pr("3", "asdf"),
408 ];
409 let issues = check_prs(&prs);
410 assert!(issues.len() >= 3); }
412}