Skip to main content

garbage_code_hunter/pr_title_hunter/
rules.rs

1//! PR title quality rules.
2
3use super::types::{PrEntry, PrIssue, Severity};
4use regex::Regex;
5use std::sync::LazyLock;
6
7/// A rule that checks PR titles.
8pub trait PrRule: Send + Sync {
9    fn id(&self) -> &str;
10    fn check(&self, pr: &PrEntry) -> Option<PrIssue>;
11}
12
13/// Title is empty or whitespace only.
14pub 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
36/// Title is too short (<= 5 chars).
37pub 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
66/// Generic / meaningless single-word title.
67static 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
97/// Title is only a ticket/issue number (e.g., "PROJ-123", "#456").
98static 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
127/// WIP / Draft PR title.
128static 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
154/// Excessive exclamation marks.
155pub 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
178/// ALL CAPS title.
179pub 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
203/// Keyboard mash detection (asdf, qwer, etc.).
204static 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
230/// Title starts with lowercase letter (excluding conventional commits).
231static 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
266/// Get all default PR title rules.
267pub 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
281/// Apply all rules to a PR entry and return issues.
282pub fn check_pr(pr: &PrEntry) -> Vec<PrIssue> {
283    default_rules()
284        .iter()
285        .filter_map(|rule| rule.check(pr))
286        .collect()
287}
288
289/// Check multiple PRs and return all issues.
290pub 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        // Should trigger both too-short and generic-title (and maybe others)
339        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); // At least one issue per bad PR
411    }
412}