Skip to main content

standard_commit/
parse.rs

1/// A parsed conventional commit message.
2#[derive(Debug, Clone, PartialEq, Eq)]
3pub struct ConventionalCommit {
4    /// The commit type (e.g. `feat`, `fix`).
5    pub r#type: String,
6    /// The optional scope (e.g. `auth`).
7    pub scope: Option<String>,
8    /// The commit description (subject line after `type(scope): `).
9    pub description: String,
10    /// The optional body, separated from the header by a blank line.
11    pub body: Option<String>,
12    /// Trailer footers.
13    pub footers: Vec<Footer>,
14    /// Whether this is a breaking change (`!` suffix or `BREAKING CHANGE` footer).
15    pub is_breaking: bool,
16}
17
18/// A commit message footer (trailer).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Footer {
21    /// The footer token (e.g. `BREAKING CHANGE`, `Refs`).
22    pub token: String,
23    /// The footer value.
24    pub value: String,
25}
26
27/// Errors that can occur when parsing a conventional commit message.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum ParseError {
30    /// The message is empty.
31    #[error("commit message is empty")]
32    EmptyMessage,
33    /// The message does not conform to the conventional commit format.
34    #[error("{0}")]
35    InvalidFormat(String),
36}
37
38/// Parse a commit message string into a [`ConventionalCommit`].
39///
40/// Validates that the message conforms to the
41/// [Conventional Commits](https://www.conventionalcommits.org/) specification:
42/// `<type>[(<scope>)][!]: <description>`, with optional body and footers.
43///
44/// The type must be lowercase ASCII (`[a-z]+`).
45pub fn parse(message: &str) -> Result<ConventionalCommit, ParseError> {
46    let message = message.trim();
47    if message.is_empty() {
48        return Err(ParseError::EmptyMessage);
49    }
50
51    let commit = git_conventional::Commit::parse(message)
52        .map_err(|e| ParseError::InvalidFormat(e.to_string()))?;
53
54    let ty = commit.type_().as_str();
55    if !ty.bytes().all(|b| b.is_ascii_lowercase()) {
56        return Err(ParseError::InvalidFormat(format!(
57            "type must be lowercase: '{ty}'"
58        )));
59    }
60
61    let footers = commit
62        .footers()
63        .iter()
64        .map(|f| Footer {
65            token: f.token().to_string(),
66            value: f.value().to_string(),
67        })
68        .collect();
69
70    Ok(ConventionalCommit {
71        r#type: commit.type_().to_string(),
72        scope: commit.scope().map(|s| s.to_string()),
73        description: commit.description().to_string(),
74        body: commit.body().map(|b| b.to_string()),
75        footers,
76        is_breaking: commit.breaking(),
77    })
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn empty_message() {
86        assert_eq!(parse(""), Err(ParseError::EmptyMessage));
87        assert_eq!(parse("   "), Err(ParseError::EmptyMessage));
88    }
89
90    #[test]
91    fn type_only_no_colon() {
92        assert!(parse("feat").is_err());
93    }
94
95    #[test]
96    fn missing_space_after_colon() {
97        let result = parse("feat:no space");
98        if let Ok(commit) = result {
99            assert_eq!(commit.r#type, "feat");
100        }
101    }
102
103    #[test]
104    fn minimal_commit() {
105        let commit = parse("feat: add login").unwrap();
106        assert_eq!(commit.r#type, "feat");
107        assert_eq!(commit.scope, None);
108        assert_eq!(commit.description, "add login");
109        assert_eq!(commit.body, None);
110        assert!(commit.footers.is_empty());
111        assert!(!commit.is_breaking);
112    }
113
114    #[test]
115    fn with_scope() {
116        let commit = parse("fix(auth): handle expired tokens").unwrap();
117        assert_eq!(commit.r#type, "fix");
118        assert_eq!(commit.scope.as_deref(), Some("auth"));
119        assert_eq!(commit.description, "handle expired tokens");
120        assert!(!commit.is_breaking);
121    }
122
123    #[test]
124    fn breaking_with_bang() {
125        let commit = parse("feat!: remove legacy API").unwrap();
126        assert_eq!(commit.r#type, "feat");
127        assert!(commit.is_breaking);
128    }
129
130    #[test]
131    fn breaking_with_scope_and_bang() {
132        let commit = parse("refactor(runtime)!: drop Python 2 support").unwrap();
133        assert_eq!(commit.r#type, "refactor");
134        assert_eq!(commit.scope.as_deref(), Some("runtime"));
135        assert!(commit.is_breaking);
136    }
137
138    #[test]
139    fn uppercase_type_rejected() {
140        assert!(parse("FEAT: add login").is_err());
141    }
142}