conventional_commits/
lib.rs

1//! # Conventional Commits Validator
2//!
3//! A lightweight library for validating Git commit messages according to the
4//! [Conventional Commits](https://conventionalcommits.org/) specification.
5//!
6//! ## Usage
7//!
8//! ```rust
9//! use conventional_commits::{validate_commit, CommitType};
10//!
11//! let message = "feat: add new user authentication";
12//! match validate_commit(message) {
13//!     Ok(commit) => {
14//!         println!("Valid commit: {:?}", commit.commit_type);
15//!     }
16//!     Err(e) => {
17//!         eprintln!("Invalid commit: {}", e);
18//!     }
19//! }
20//! ```
21
22use regex::Regex;
23use std::fmt;
24use thiserror::Error;
25
26/// Represents the type of a conventional commit
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum CommitType {
29    /// A new feature
30    Feat,
31    /// A bug fix
32    Fix,
33    /// Documentation only changes
34    Docs,
35    /// Changes that do not affect the meaning of the code
36    Style,
37    /// A code change that neither fixes a bug nor adds a feature
38    Refactor,
39    /// A code change that improves performance
40    Perf,
41    /// Adding missing tests or correcting existing tests
42    Test,
43    /// Changes to the build process or auxiliary tools and libraries
44    Build,
45    /// Changes to CI configuration files and scripts
46    Ci,
47    /// Other changes that don't modify src or test files
48    Chore,
49    /// Reverts a previous commit
50    Revert,
51    /// Custom type not in the standard list
52    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/// Represents a parsed conventional commit
94#[derive(Debug, Clone)]
95pub struct ConventionalCommit {
96    /// The type of the commit
97    pub commit_type: CommitType,
98    /// Optional scope of the commit
99    pub scope: Option<String>,
100    /// Whether this commit contains breaking changes
101    pub breaking_change: bool,
102    /// The description of the commit
103    pub description: String,
104    /// Optional body of the commit message
105    pub body: Option<String>,
106    /// Optional footers of the commit message
107    pub footers: Vec<String>,
108}
109
110impl ConventionalCommit {
111    /// Returns true if this commit represents a breaking change
112    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/// Errors that can occur when validating commit messages
121#[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/// Configuration for commit validation
149#[derive(Debug, Clone)]
150pub struct ValidationConfig {
151    /// Maximum length for the description
152    pub max_description_length: usize,
153    /// Whether to allow custom commit types
154    pub allow_custom_types: bool,
155    /// Whether to enforce lowercase description
156    pub enforce_lowercase_description: bool,
157    /// Whether to disallow period at end of description
158    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
172/// Validates a commit message according to Conventional Commits specification
173///
174/// # Arguments
175///
176/// * `message` - The commit message to validate
177///
178/// # Returns
179///
180/// Returns `Ok(ConventionalCommit)` if valid, or `Err(CommitValidationError)` if invalid
181///
182/// # Examples
183///
184/// ```
185/// use conventional_commits::validate_commit;
186///
187/// let result = validate_commit("feat: add user authentication");
188/// assert!(result.is_ok());
189///
190/// let result = validate_commit("invalid commit message");
191/// assert!(result.is_err());
192/// ```
193pub fn validate_commit(message: &str) -> Result<ConventionalCommit, CommitValidationError> {
194    validate_commit_with_config(message, &ValidationConfig::default())
195}
196
197/// Validates a commit message with custom configuration
198///
199/// # Arguments
200///
201/// * `message` - The commit message to validate
202/// * `config` - Custom validation configuration
203///
204/// # Returns
205///
206/// Returns `Ok(ConventionalCommit)` if valid, or `Err(CommitValidationError)` if invalid
207pub 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    // Parse the header using regex
219    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    // Validate commit type
232    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    // Validate description
240    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    // Parse body and footers
257    let body = if lines.len() > 1 && !lines[1].trim().is_empty() {
258        None // First non-empty line after header is start of body
259    } 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    // Parse footers
276    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", // Uppercase
323            "feat: add user authentication.", // Period
324        ];
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}