Skip to main content

commit_wizard/engine/capabilities/commit/
check.rs

1use crate::engine::models::{
2    git::CommitSummary,
3    policy::commit::{CommitModel, ScopeRequirement},
4};
5use regex::Regex;
6
7/// A single violation in commit validation
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum CommitViolation {
10    InvalidHeaderFormat,
11    InvalidType(String),
12    MissingScopeWhenRequired,
13    ScopeNotAllowed(String),
14    InvalidScope(String),
15    EmptySubject,
16    SubjectTooLong { length: usize, max: u32 },
17    MissingBreakingHeader,
18    MissingBreakingFooter,
19    InvalidBreakingFooter,
20    MissingTicket,
21    InvalidTicketFormat(String),
22    EmojiNotAllowed,
23}
24
25impl CommitViolation {
26    pub fn message(&self) -> String {
27        match self {
28            Self::InvalidHeaderFormat => {
29                "Header does not match conventional commit format".to_string()
30            }
31            Self::InvalidType(t) => format!("Type '{}' is not allowed", t),
32            Self::MissingScopeWhenRequired => "Scope is required but not provided".to_string(),
33            Self::ScopeNotAllowed(s) => format!(
34                "Scope '{}' is not allowed (commit.scopes.mode = disabled)",
35                s
36            ),
37            Self::InvalidScope(s) => format!("Scope '{}' is not allowed", s),
38            Self::EmptySubject => "Subject must not be empty".to_string(),
39            Self::SubjectTooLong { length, max } => {
40                format!("Subject is {} chars, but max is {}", length, max)
41            }
42            Self::MissingBreakingHeader => {
43                "Breaking change marker '!' required in header".to_string()
44            }
45            Self::MissingBreakingFooter => {
46                "Breaking change footer required (e.g., 'BREAKING CHANGE: ...')".to_string()
47            }
48            Self::InvalidBreakingFooter => "Breaking change footer format is invalid".to_string(),
49            Self::MissingTicket => "Ticket is required but not provided".to_string(),
50            Self::InvalidTicketFormat(msg) => format!("Ticket format invalid: {}", msg),
51            Self::EmojiNotAllowed => {
52                "Emoji prefix is not allowed (commit.use_emojis = false)".to_string()
53            }
54        }
55    }
56}
57
58/// Parsed components of a commit header
59#[derive(Debug, Clone)]
60pub struct ParsedHeader {
61    pub type_name: String,
62    pub scope: Option<String>,
63    pub is_breaking: bool,
64    pub subject: String,
65    pub emoji: Option<String>,
66}
67
68impl ParsedHeader {
69    /// Parse a commit header using regex
70    /// Supports:
71    /// - Standard: type(scope)?: subject
72    /// - Gitmoji: emoji type(scope)?: subject
73    pub fn parse(header: &str) -> Option<Self> {
74        let header = header.trim();
75
76        // Try to extract leading emoji and the rest
77        let (emoji, rest) = Self::extract_emoji(header);
78
79        // Pattern: type(scope)?: subject or type(scope)!: subject
80        let pattern = r"^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$";
81        let re = Regex::new(pattern).unwrap();
82
83        re.captures(rest).map(|caps| ParsedHeader {
84            type_name: caps.get(1).unwrap().as_str().to_string(),
85            scope: caps.get(2).map(|m| m.as_str().to_string()),
86            is_breaking: caps.get(3).is_some(),
87            subject: caps.get(4).unwrap().as_str().to_string(),
88            emoji,
89        })
90    }
91
92    /// Extract leading emoji from header if present
93    /// Returns (emoji, rest_of_header)
94    fn extract_emoji(s: &str) -> (Option<String>, &str) {
95        // Emoji detection: check if first character(s) is emoji
96        // Emojis are typically 1-2 UTF-8 code points, followed by space
97        let chars: Vec<char> = s.chars().collect();
98        if chars.is_empty() {
99            return (None, s);
100        }
101
102        // Check if first char is emoji (rough heuristic: not ASCII alphanumeric and not punctuation)
103        if is_emoji_char(chars[0]) {
104            // Find the space after emoji
105            if let Some(space_pos) = s.find(' ') {
106                let emoji_part = &s[..space_pos];
107                let rest = &s[space_pos + 1..];
108                return (Some(emoji_part.to_string()), rest);
109            }
110        }
111        (None, s)
112    }
113}
114
115/// Simple emoji detection: characters outside common ASCII ranges
116fn is_emoji_char(c: char) -> bool {
117    let code = c as u32;
118    // Emoji ranges in Unicode (very simplified)
119    // Includes common emoji blocks: 1F600-1F64F (Emoticons), 2600-26FF (Misc), etc.
120    (0x1F300..=0x1F9FF).contains(&code) || // Main emoji block
121    (0x2600..=0x27BF).contains(&code) ||   // Misc symbols
122    (0x2300..=0x23FF).contains(&code) // Misc technical
123}
124
125/// The full body of a commit (body + footer sections)
126#[derive(Debug, Clone)]
127pub struct CommitBody {
128    pub body_text: String,
129    pub footer_lines: Vec<String>,
130}
131
132impl CommitBody {
133    /// Parse commit body to extract footer lines
134    fn parse(full_message: &str) -> Self {
135        // Split on first blank line to separate header from body
136        let parts: Vec<&str> = full_message.splitn(2, '\n').collect();
137        if parts.len() < 2 {
138            return CommitBody {
139                body_text: String::new(),
140                footer_lines: Vec::new(),
141            };
142        }
143
144        let body_and_footer = parts[1];
145        // Look for footer section (key: value pattern)
146        let footer_lines = body_and_footer
147            .lines()
148            .filter(|line| line.contains(": "))
149            .map(|s| s.to_string())
150            .collect();
151
152        CommitBody {
153            body_text: body_and_footer.to_string(),
154            footer_lines,
155        }
156    }
157
158    /// Check if breaking change footer exists
159    fn has_breaking_footer(&self, footer_key: &str) -> bool {
160        self.footer_lines
161            .iter()
162            .any(|line| line.starts_with(footer_key))
163    }
164}
165
166/// Result of validating a single commit
167#[derive(Debug, Clone)]
168pub struct ValidatedCommit {
169    pub hash: String,
170    pub summary: String,
171    pub valid: bool,
172    pub violations: Vec<CommitViolation>,
173}
174
175/// Overall validation report
176#[derive(Debug, Clone)]
177pub struct ValidateReport {
178    pub total: usize,
179    pub invalid_count: usize,
180    pub commits: Vec<ValidatedCommit>,
181}
182
183/// The validator performs full commit validation against a policy
184pub struct CommitValidator<'a> {
185    policy: &'a CommitModel,
186}
187
188impl<'a> CommitValidator<'a> {
189    pub fn new(policy: &'a CommitModel) -> Self {
190        Self { policy }
191    }
192
193    /// Validate a single commit message
194    pub fn validate_message(&self, message: &str) -> Vec<CommitViolation> {
195        let mut violations = Vec::new();
196
197        // If conventional commits are not required, skip format validation
198        if !self.policy.require_conventional {
199            // Still validate other aspects (emoji, tickets, etc) but skip commit format checks
200            return violations;
201        }
202
203        // Split header from rest
204        let header_line = message.lines().next().unwrap_or("");
205        let parsed = match ParsedHeader::parse(header_line) {
206            Some(h) => h,
207            None => {
208                violations.push(CommitViolation::InvalidHeaderFormat);
209                return violations;
210            }
211        };
212
213        // Validate emoji usage
214        if parsed.emoji.is_some() && !self.policy.use_emojis {
215            violations.push(CommitViolation::EmojiNotAllowed);
216        }
217
218        // Validate type
219        if !self.policy.allows_type(&parsed.type_name) {
220            violations.push(CommitViolation::InvalidType(parsed.type_name.clone()));
221        }
222
223        // Validate scope
224        match &parsed.scope {
225            Some(scope) => {
226                if self.policy.scope_requirement == ScopeRequirement::Disabled {
227                    violations.push(CommitViolation::ScopeNotAllowed(scope.clone()));
228                } else if self.policy.restrict_scopes_to_defined && !self.policy.allows_scope(scope)
229                {
230                    violations.push(CommitViolation::InvalidScope(scope.clone()));
231                }
232            }
233            None => {
234                if self.policy.scope_requirement == ScopeRequirement::Required {
235                    violations.push(CommitViolation::MissingScopeWhenRequired);
236                }
237            }
238        }
239
240        // Validate subject
241        if parsed.subject.is_empty() {
242            violations.push(CommitViolation::EmptySubject);
243        } else if parsed.subject.len() as u32 > self.policy.subject_max_length {
244            violations.push(CommitViolation::SubjectTooLong {
245                length: parsed.subject.len(),
246                max: self.policy.subject_max_length,
247            });
248        }
249
250        // Validate breaking changes
251        let body = CommitBody::parse(message);
252        if self.policy.breaking_header_required && parsed.is_breaking {
253            // If header has ! and we found it, that's good
254        } else if self.policy.breaking_header_required && !parsed.is_breaking {
255            // Check if footer exists to validate consistency
256            if body.has_breaking_footer(&self.policy.breaking_footer_key) {
257                violations.push(CommitViolation::MissingBreakingHeader);
258            }
259        }
260
261        // Only require breaking footer if this is actually a breaking change commit
262        if self.policy.breaking_footer_required
263            && parsed.is_breaking
264            && !body.has_breaking_footer(&self.policy.breaking_footer_key)
265        {
266            violations.push(CommitViolation::MissingBreakingFooter);
267        }
268
269        // Validate ticket
270        if self.policy.ticket.enabled {
271            let ticket_found = if let Some(ticket_regex) = &self.policy.ticket.regex {
272                match Regex::new(ticket_regex) {
273                    Ok(re) => re.is_match(message),
274                    Err(_) => {
275                        violations.push(CommitViolation::InvalidTicketFormat(format!(
276                            "Invalid ticket regex pattern: {}",
277                            ticket_regex
278                        )));
279                        return violations;
280                    }
281                }
282            } else {
283                // No regex configured — ticket cannot be validated
284                !self.policy.ticket.required
285            };
286
287            if self.policy.ticket.required && !ticket_found {
288                if self.policy.ticket.regex.is_some() {
289                    violations.push(CommitViolation::InvalidTicketFormat(format!(
290                        "Does not match pattern: {}",
291                        self.policy.ticket.regex.as_deref().unwrap_or("")
292                    )));
293                } else {
294                    violations.push(CommitViolation::MissingTicket);
295                }
296            }
297        }
298
299        violations
300    }
301}
302
303/// Validate multiple commits
304pub fn validate_commits(
305    commits: impl IntoIterator<Item = CommitSummary>,
306    policy: &CommitModel,
307) -> ValidateReport {
308    let validator = CommitValidator::new(policy);
309    let mut validated = Vec::new();
310    let mut invalid_count = 0usize;
311
312    for commit in commits {
313        // Use the full message (header + body + footer) for validation when available,
314        // so breaking footer, ticket, and body checks work correctly.
315        let full_msg = commit.full_message.as_deref().unwrap_or(&commit.summary);
316        let violations = validator.validate_message(full_msg);
317        let valid = violations.is_empty();
318
319        if !valid {
320            invalid_count += 1;
321        }
322
323        validated.push(ValidatedCommit {
324            hash: commit.hash,
325            summary: commit.summary,
326            valid,
327            violations,
328        });
329    }
330
331    ValidateReport {
332        total: validated.len(),
333        invalid_count,
334        commits: validated,
335    }
336}