1#[derive(Debug, Clone, PartialEq, Eq)]
3pub struct ConventionalCommit {
4 pub r#type: String,
6 pub scope: Option<String>,
8 pub description: String,
10 pub body: Option<String>,
12 pub footers: Vec<Footer>,
14 pub is_breaking: bool,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Footer {
21 pub token: String,
23 pub value: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum ParseError {
30 #[error("commit message is empty")]
32 EmptyMessage,
33 #[error("{0}")]
35 InvalidFormat(String),
36}
37
38pub 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}