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)]
29pub enum ParseError {
30 EmptyMessage,
32 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
47pub 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}