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)]
29pub enum ParseError {
30    /// The message is empty.
31    EmptyMessage,
32    /// The message does not conform to the conventional commit format.
33    InvalidFormat(String),
34}
35
36impl std::fmt::Display for ParseError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            ParseError::EmptyMessage => write!(f, "commit message is empty"),
40            ParseError::InvalidFormat(msg) => write!(f, "{msg}"),
41        }
42    }
43}
44
45impl std::error::Error for ParseError {}
46
47/// Parse a commit message string into a [`ConventionalCommit`].
48///
49/// Validates that the message conforms to the
50/// [Conventional Commits](https://www.conventionalcommits.org/) specification:
51/// `<type>[(<scope>)][!]: <description>`, with optional body and footers.
52///
53/// The type must be lowercase ASCII (`[a-z]+`).
54pub fn parse(message: &str) -> Result<ConventionalCommit, ParseError> {
55    let message = message.trim();
56    if message.is_empty() {
57        return Err(ParseError::EmptyMessage);
58    }
59
60    let commit = git_conventional::Commit::parse(message)
61        .map_err(|e| ParseError::InvalidFormat(e.to_string()))?;
62
63    let ty = commit.type_().as_str();
64    if !ty.bytes().all(|b| b.is_ascii_lowercase()) {
65        return Err(ParseError::InvalidFormat(format!(
66            "type must be lowercase: '{ty}'"
67        )));
68    }
69
70    let footers = commit
71        .footers()
72        .iter()
73        .map(|f| Footer {
74            token: f.token().to_string(),
75            value: f.value().to_string(),
76        })
77        .collect();
78
79    Ok(ConventionalCommit {
80        r#type: commit.type_().to_string(),
81        scope: commit.scope().map(|s| s.to_string()),
82        description: commit.description().to_string(),
83        body: commit.body().map(|b| b.to_string()),
84        footers,
85        is_breaking: commit.breaking(),
86    })
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn empty_message() {
95        assert_eq!(parse(""), Err(ParseError::EmptyMessage));
96        assert_eq!(parse("   "), Err(ParseError::EmptyMessage));
97    }
98
99    #[test]
100    fn type_only_no_colon() {
101        assert!(parse("feat").is_err());
102    }
103
104    #[test]
105    fn missing_space_after_colon() {
106        let result = parse("feat:no space");
107        if let Ok(commit) = result {
108            assert_eq!(commit.r#type, "feat");
109        }
110    }
111
112    #[test]
113    fn minimal_commit() {
114        let commit = parse("feat: add login").unwrap();
115        assert_eq!(commit.r#type, "feat");
116        assert_eq!(commit.scope, None);
117        assert_eq!(commit.description, "add login");
118        assert_eq!(commit.body, None);
119        assert!(commit.footers.is_empty());
120        assert!(!commit.is_breaking);
121    }
122
123    #[test]
124    fn with_scope() {
125        let commit = parse("fix(auth): handle expired tokens").unwrap();
126        assert_eq!(commit.r#type, "fix");
127        assert_eq!(commit.scope.as_deref(), Some("auth"));
128        assert_eq!(commit.description, "handle expired tokens");
129        assert!(!commit.is_breaking);
130    }
131
132    #[test]
133    fn breaking_with_bang() {
134        let commit = parse("feat!: remove legacy API").unwrap();
135        assert_eq!(commit.r#type, "feat");
136        assert!(commit.is_breaking);
137    }
138
139    #[test]
140    fn breaking_with_scope_and_bang() {
141        let commit = parse("refactor(runtime)!: drop Python 2 support").unwrap();
142        assert_eq!(commit.r#type, "refactor");
143        assert_eq!(commit.scope.as_deref(), Some("runtime"));
144        assert!(commit.is_breaking);
145    }
146
147    #[test]
148    fn uppercase_type_rejected() {
149        assert!(parse("FEAT: add login").is_err());
150    }
151}