1use regex::Regex;
2use serde::Serialize;
3
4use crate::error::ReleaseError;
5use crate::version::BumpLevel;
6
7#[derive(Debug, Clone)]
9pub struct Commit {
10 pub sha: String,
11 pub message: String,
12}
13
14#[derive(Debug, Clone, Serialize)]
16pub struct ConventionalCommit {
17 pub sha: String,
18 pub r#type: String,
19 pub scope: Option<String>,
20 pub description: String,
21 pub body: Option<String>,
22 pub breaking: bool,
23}
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct CommitType {
28 pub name: String,
29 pub bump: Option<BumpLevel>,
30}
31
32pub fn build_commit_pattern(type_names: &[&str]) -> String {
36 let types_alternation = type_names.join("|");
37 format!(
38 r"^(?P<type>{types_alternation})(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)"
39 )
40}
41
42pub trait CommitClassifier: Send + Sync {
44 fn types(&self) -> &[CommitType];
45 fn pattern(&self) -> &str;
46
47 fn bump_level(&self, type_name: &str, breaking: bool) -> Option<BumpLevel> {
48 if breaking {
49 return Some(BumpLevel::Major);
50 }
51 self.types().iter().find(|t| t.name == type_name)?.bump
52 }
53
54 fn is_allowed(&self, type_name: &str) -> bool {
55 self.types().iter().any(|t| t.name == type_name)
56 }
57}
58
59pub struct DefaultCommitClassifier {
60 types: Vec<CommitType>,
61 pattern: String,
62}
63
64impl DefaultCommitClassifier {
65 pub fn new(types: Vec<CommitType>) -> Self {
66 let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
67 let pattern = build_commit_pattern(&names);
68 Self { types, pattern }
69 }
70}
71
72impl Default for DefaultCommitClassifier {
73 fn default() -> Self {
74 Self::new(default_commit_types())
75 }
76}
77
78impl CommitClassifier for DefaultCommitClassifier {
79 fn types(&self) -> &[CommitType] {
80 &self.types
81 }
82 fn pattern(&self) -> &str {
83 &self.pattern
84 }
85}
86
87pub fn default_commit_types() -> Vec<CommitType> {
88 use crate::config::CommitTypesConfig;
89 CommitTypesConfig::default().into_commit_types()
90}
91
92pub trait CommitParser: Send + Sync {
94 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
95}
96
97pub struct TypedCommitParser {
99 pattern: String,
100}
101
102impl TypedCommitParser {
103 pub fn new(type_names: &[&str]) -> Self {
104 Self {
105 pattern: build_commit_pattern(type_names),
106 }
107 }
108
109 pub fn from_types(types: &[CommitType]) -> Self {
110 let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
111 Self::new(&names)
112 }
113}
114
115impl Default for TypedCommitParser {
116 fn default() -> Self {
117 Self::from_types(&default_commit_types())
118 }
119}
120
121impl CommitParser for TypedCommitParser {
122 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
123 let re = Regex::new(&self.pattern).map_err(|e| ReleaseError::Config(e.to_string()))?;
124
125 let first_line = commit.message.lines().next().unwrap_or("");
126
127 let caps = re.captures(first_line).ok_or_else(|| {
128 ReleaseError::Config(format!("not a conventional commit: {}", commit.message))
129 })?;
130
131 let r#type = caps.name("type").unwrap().as_str().to_string();
132 let scope = caps.name("scope").map(|m| m.as_str().to_string());
133 let breaking = caps.name("breaking").is_some();
134 let description = caps.name("description").unwrap().as_str().to_string();
135
136 let body = commit
137 .message
138 .split_once("\n\n")
139 .map(|x| x.1)
140 .map(|b| b.to_string());
141
142 let breaking = breaking
144 || body.as_deref().is_some_and(|b| {
145 b.lines().any(|line| {
146 line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:")
147 })
148 });
149
150 Ok(ConventionalCommit {
151 sha: commit.sha.clone(),
152 r#type,
153 scope,
154 description,
155 body,
156 breaking,
157 })
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 fn raw(message: &str) -> Commit {
166 Commit {
167 sha: "abc1234".into(),
168 message: message.into(),
169 }
170 }
171
172 fn parser() -> TypedCommitParser {
173 TypedCommitParser::default()
174 }
175
176 #[test]
177 fn build_pattern_from_types() {
178 let pattern = build_commit_pattern(&["feat", "fix", "chore"]);
179 assert!(pattern.contains("feat|fix|chore"));
180 let re = Regex::new(&pattern).unwrap();
181 assert!(re.is_match("feat: add button"));
182 assert!(re.is_match("fix(core): null check"));
183 assert!(!re.is_match("unknown: something"));
184 }
185
186 #[test]
187 fn parse_simple_feat() {
188 let result = parser().parse(&raw("feat: add button")).unwrap();
189 assert_eq!(result.r#type, "feat");
190 assert_eq!(result.description, "add button");
191 assert_eq!(result.scope, None);
192 assert!(!result.breaking);
193 }
194
195 #[test]
196 fn parse_scoped_fix() {
197 let result = parser().parse(&raw("fix(core): null check")).unwrap();
198 assert_eq!(result.r#type, "fix");
199 assert_eq!(result.scope.as_deref(), Some("core"));
200 }
201
202 #[test]
203 fn parse_breaking_bang() {
204 let result = parser().parse(&raw("feat!: new API")).unwrap();
205 assert!(result.breaking);
206 }
207
208 #[test]
209 fn parse_with_body() {
210 let result = parser().parse(&raw("fix: x\n\ndetails")).unwrap();
211 assert_eq!(result.body.as_deref(), Some("details"));
212 }
213
214 #[test]
215 fn parse_breaking_change_footer() {
216 let result = parser()
217 .parse(&raw(
218 "feat: new API\n\nBREAKING CHANGE: removed old endpoint",
219 ))
220 .unwrap();
221 assert!(result.breaking);
222 }
223
224 #[test]
225 fn parse_breaking_change_hyphenated_footer() {
226 let result = parser()
227 .parse(&raw("fix: update schema\n\nBREAKING-CHANGE: field renamed"))
228 .unwrap();
229 assert!(result.breaking);
230 }
231
232 #[test]
233 fn parse_no_breaking_change_in_body() {
234 let result = parser()
235 .parse(&raw("fix: tweak\n\nThis is not a BREAKING CHANGE footer"))
236 .unwrap();
237 assert!(!result.breaking);
238 }
239
240 #[test]
241 fn parse_no_breaking_change_indented_bullet() {
242 let result = parser()
243 .parse(&raw(
244 "feat(mcp): add breaking flag\n\n- add `breaking` field — sets \"!\" and adds\n BREAKING CHANGE footer automatically",
245 ))
246 .unwrap();
247 assert!(!result.breaking);
248 }
249
250 #[test]
251 fn parse_invalid_message() {
252 let result = parser().parse(&raw("not conventional"));
253 assert!(result.is_err());
254 }
255
256 #[test]
257 fn parse_unknown_type_rejected() {
258 let result = parser().parse(&raw("unknown: something"));
259 assert!(result.is_err());
260 }
261
262 #[test]
265 fn classifier_bump_level_feat() {
266 let c = DefaultCommitClassifier::default();
267 assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
268 }
269
270 #[test]
271 fn classifier_bump_level_fix() {
272 let c = DefaultCommitClassifier::default();
273 assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
274 }
275
276 #[test]
277 fn classifier_bump_level_breaking_overrides() {
278 let c = DefaultCommitClassifier::default();
279 assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
280 assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
281 }
282
283 #[test]
284 fn classifier_bump_level_no_bump_type() {
285 let c = DefaultCommitClassifier::default();
286 assert_eq!(c.bump_level("chore", false), None);
287 assert_eq!(c.bump_level("docs", false), None);
288 }
289
290 #[test]
291 fn classifier_is_allowed() {
292 let c = DefaultCommitClassifier::default();
293 assert!(c.is_allowed("feat"));
294 assert!(c.is_allowed("chore"));
295 assert!(!c.is_allowed("unknown"));
296 }
297
298 #[test]
299 fn classifier_pattern_built_from_types() {
300 let c = DefaultCommitClassifier::default();
301 assert!(c.pattern().contains("feat"));
302 assert!(c.pattern().contains("fix"));
303 assert!(c.pattern().contains("chore"));
304 }
305
306 #[test]
307 fn default_commit_types_count() {
308 let types = default_commit_types();
309 assert_eq!(types.len(), 11);
310 }
311}