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)$")
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
98/// Title is only a ticket/issue number (e.g., "PROJ-123", "#456").
99static 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
129/// WIP / Draft PR title.
130static 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
158/// Excessive exclamation marks.
159pub 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
182/// ALL CAPS title.
183pub 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
207/// Keyboard mash detection (asdf, qwer, etc.).
208static 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
236/// Title starts with lowercase letter (excluding conventional commits).
237static 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
272/// Get all default PR title rules.
273pub 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
287/// Apply all rules to a PR entry and return issues.
288pub fn check_pr(pr: &PrEntry) -> Vec<PrIssue> {
289    default_rules()
290        .iter()
291        .filter_map(|rule| rule.check(pr))
292        .collect()
293}
294
295/// Check multiple PRs and return all issues.
296pub 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        // Should trigger both too-short and generic-title (and maybe others)
345        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); // At least one issue per bad PR
417    }
418}