commit_wizard/engine/capabilities/commit/
check.rs1use crate::engine::models::{
2 git::CommitSummary,
3 policy::commit::{CommitModel, ScopeRequirement},
4};
5use regex::Regex;
6
7#[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#[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 pub fn parse(header: &str) -> Option<Self> {
74 let header = header.trim();
75
76 let (emoji, rest) = Self::extract_emoji(header);
78
79 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 fn extract_emoji(s: &str) -> (Option<String>, &str) {
95 let chars: Vec<char> = s.chars().collect();
98 if chars.is_empty() {
99 return (None, s);
100 }
101
102 if is_emoji_char(chars[0]) {
104 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
115fn is_emoji_char(c: char) -> bool {
117 let code = c as u32;
118 (0x1F300..=0x1F9FF).contains(&code) || (0x2600..=0x27BF).contains(&code) || (0x2300..=0x23FF).contains(&code) }
124
125#[derive(Debug, Clone)]
127pub struct CommitBody {
128 pub body_text: String,
129 pub footer_lines: Vec<String>,
130}
131
132impl CommitBody {
133 fn parse(full_message: &str) -> Self {
135 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 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 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#[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#[derive(Debug, Clone)]
177pub struct ValidateReport {
178 pub total: usize,
179 pub invalid_count: usize,
180 pub commits: Vec<ValidatedCommit>,
181}
182
183pub 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 pub fn validate_message(&self, message: &str) -> Vec<CommitViolation> {
195 let mut violations = Vec::new();
196
197 if !self.policy.require_conventional {
199 return violations;
201 }
202
203 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 if parsed.emoji.is_some() && !self.policy.use_emojis {
215 violations.push(CommitViolation::EmojiNotAllowed);
216 }
217
218 if !self.policy.allows_type(&parsed.type_name) {
220 violations.push(CommitViolation::InvalidType(parsed.type_name.clone()));
221 }
222
223 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 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 let body = CommitBody::parse(message);
252 if self.policy.breaking_header_required && parsed.is_breaking {
253 } else if self.policy.breaking_header_required && !parsed.is_breaking {
255 if body.has_breaking_footer(&self.policy.breaking_footer_key) {
257 violations.push(CommitViolation::MissingBreakingHeader);
258 }
259 }
260
261 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 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 !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
303pub 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 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}