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