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}