1use regex::Regex;
23use std::fmt;
24use thiserror::Error;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum CommitType {
29 Feat,
31 Fix,
33 Docs,
35 Style,
37 Refactor,
39 Perf,
41 Test,
43 Build,
45 Ci,
47 Chore,
49 Revert,
51 Custom(String),
53}
54
55impl fmt::Display for CommitType {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 CommitType::Feat => write!(f, "feat"),
59 CommitType::Fix => write!(f, "fix"),
60 CommitType::Docs => write!(f, "docs"),
61 CommitType::Style => write!(f, "style"),
62 CommitType::Refactor => write!(f, "refactor"),
63 CommitType::Perf => write!(f, "perf"),
64 CommitType::Test => write!(f, "test"),
65 CommitType::Build => write!(f, "build"),
66 CommitType::Ci => write!(f, "ci"),
67 CommitType::Chore => write!(f, "chore"),
68 CommitType::Revert => write!(f, "revert"),
69 CommitType::Custom(s) => write!(f, "{}", s),
70 }
71 }
72}
73
74impl From<&str> for CommitType {
75 fn from(s: &str) -> Self {
76 match s.to_lowercase().as_str() {
77 "feat" => CommitType::Feat,
78 "fix" => CommitType::Fix,
79 "docs" => CommitType::Docs,
80 "style" => CommitType::Style,
81 "refactor" => CommitType::Refactor,
82 "perf" => CommitType::Perf,
83 "test" => CommitType::Test,
84 "build" => CommitType::Build,
85 "ci" => CommitType::Ci,
86 "chore" => CommitType::Chore,
87 "revert" => CommitType::Revert,
88 _ => CommitType::Custom(s.to_string()),
89 }
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct ConventionalCommit {
96 pub commit_type: CommitType,
98 pub scope: Option<String>,
100 pub breaking_change: bool,
102 pub description: String,
104 pub body: Option<String>,
106 pub footers: Vec<String>,
108}
109
110impl ConventionalCommit {
111 pub fn is_breaking_change(&self) -> bool {
113 self.breaking_change
114 || self.footers.iter().any(|f| {
115 f.starts_with("BREAKING CHANGE:") || f.starts_with("BREAKING-CHANGE:")
116 })
117 }
118}
119
120#[derive(Error, Debug)]
122pub enum CommitValidationError {
123 #[error("Commit message is empty")]
124 EmptyMessage,
125
126 #[error("Invalid format: expected 'type(scope): description'")]
127 InvalidFormat,
128
129 #[error("Missing commit type")]
130 MissingType,
131
132 #[error("Missing description")]
133 MissingDescription,
134
135 #[error("Description must start with lowercase letter")]
136 DescriptionNotLowercase,
137
138 #[error("Description must not end with a period")]
139 DescriptionEndsWithPeriod,
140
141 #[error("Description is too long (max 72 characters)")]
142 DescriptionTooLong,
143
144 #[error("Commit type '{0}' is not recognized")]
145 UnknownType(String),
146}
147
148#[derive(Debug, Clone)]
150pub struct ValidationConfig {
151 pub max_description_length: usize,
153 pub allow_custom_types: bool,
155 pub enforce_lowercase_description: bool,
157 pub disallow_description_period: bool,
159}
160
161impl Default for ValidationConfig {
162 fn default() -> Self {
163 Self {
164 max_description_length: 72,
165 allow_custom_types: true,
166 enforce_lowercase_description: true,
167 disallow_description_period: true,
168 }
169 }
170}
171
172pub fn validate_commit(message: &str) -> Result<ConventionalCommit, CommitValidationError> {
194 validate_commit_with_config(message, &ValidationConfig::default())
195}
196
197pub fn validate_commit_with_config(
208 message: &str,
209 config: &ValidationConfig,
210) -> Result<ConventionalCommit, CommitValidationError> {
211 if message.trim().is_empty() {
212 return Err(CommitValidationError::EmptyMessage);
213 }
214
215 let lines: Vec<&str> = message.lines().collect();
216 let header = lines[0];
217
218 let re = Regex::new(r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s*(?P<description>.+)$")
220 .unwrap();
221
222 let captures = re
223 .captures(header)
224 .ok_or(CommitValidationError::InvalidFormat)?;
225
226 let type_str = captures.name("type").unwrap().as_str();
227 let scope = captures.name("scope").map(|m| m.as_str().to_string());
228 let breaking_change = captures.name("breaking").is_some();
229 let description = captures.name("description").unwrap().as_str();
230
231 let commit_type = CommitType::from(type_str);
233 if !config.allow_custom_types {
234 if let CommitType::Custom(_) = commit_type {
235 return Err(CommitValidationError::UnknownType(type_str.to_string()));
236 }
237 }
238
239 if description.is_empty() {
241 return Err(CommitValidationError::MissingDescription);
242 }
243
244 if config.enforce_lowercase_description && !description.chars().next().unwrap().is_lowercase() {
245 return Err(CommitValidationError::DescriptionNotLowercase);
246 }
247
248 if config.disallow_description_period && description.ends_with('.') {
249 return Err(CommitValidationError::DescriptionEndsWithPeriod);
250 }
251
252 if description.len() > config.max_description_length {
253 return Err(CommitValidationError::DescriptionTooLong);
254 }
255
256 let body = if lines.len() > 1 && !lines[1].trim().is_empty() {
258 None } else if lines.len() > 2 {
260 let body_lines: Vec<&str> = lines[2..]
261 .iter()
262 .take_while(|&&line| !line.starts_with("BREAKING CHANGE:") && !line.starts_with("BREAKING-CHANGE:"))
263 .cloned()
264 .collect();
265
266 if body_lines.is_empty() {
267 None
268 } else {
269 Some(body_lines.join("\n"))
270 }
271 } else {
272 None
273 };
274
275 let footers: Vec<String> = lines
277 .iter()
278 .filter(|line| {
279 line.starts_with("BREAKING CHANGE:")
280 || line.starts_with("BREAKING-CHANGE:")
281 || line.contains(": ")
282 })
283 .map(|s| s.to_string())
284 .collect();
285
286 Ok(ConventionalCommit {
287 commit_type,
288 scope,
289 breaking_change,
290 description: description.to_string(),
291 body,
292 footers,
293 })
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_valid_commits() {
302 let valid_commits = vec![
303 "feat: add user authentication",
304 "fix: resolve login issue",
305 "feat(auth): add OAuth support",
306 "feat!: breaking change to API",
307 "chore: update dependencies",
308 "docs: update README",
309 ];
310
311 for commit in valid_commits {
312 assert!(validate_commit(commit).is_ok(), "Failed to validate: {}", commit);
313 }
314 }
315
316 #[test]
317 fn test_invalid_commits() {
318 let invalid_commits = vec![
319 "",
320 "add user authentication",
321 "feat:",
322 "feat: Add user authentication", "feat: add user authentication.", ];
325
326 for commit in invalid_commits {
327 assert!(validate_commit(commit).is_err(), "Should have failed: {}", commit);
328 }
329 }
330
331 #[test]
332 fn test_breaking_change_detection() {
333 let commit = validate_commit("feat!: breaking change").unwrap();
334 assert!(commit.breaking_change);
335
336 let commit_with_footer = validate_commit("feat: new feature\n\nBREAKING CHANGE: this breaks something").unwrap();
337 assert!(commit_with_footer.is_breaking_change());
338 }
339
340 #[test]
341 fn test_commit_types() {
342 assert_eq!(CommitType::from("feat"), CommitType::Feat);
343 assert_eq!(CommitType::from("fix"), CommitType::Fix);
344 assert_eq!(CommitType::from("custom"), CommitType::Custom("custom".to_string()));
345 }
346}