1use regex::Regex;
2use serde::{Deserialize, 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, Serialize, Deserialize, PartialEq)]
27pub struct CommitType {
28 pub name: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub bump: Option<BumpLevel>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub section: Option<String>,
34}
35
36pub trait CommitClassifier: Send + Sync {
38 fn types(&self) -> &[CommitType];
39
40 fn pattern(&self) -> &str;
42
43 fn bump_level(&self, type_name: &str, breaking: bool) -> Option<BumpLevel> {
44 if breaking {
45 return Some(BumpLevel::Major);
46 }
47 self.types().iter().find(|t| t.name == type_name)?.bump
48 }
49
50 fn changelog_section(&self, type_name: &str) -> Option<&str> {
51 self.types()
52 .iter()
53 .find(|t| t.name == type_name)?
54 .section
55 .as_deref()
56 }
57
58 fn is_allowed(&self, type_name: &str) -> bool {
59 self.types().iter().any(|t| t.name == type_name)
60 }
61}
62
63pub const DEFAULT_COMMIT_PATTERN: &str =
66 r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)";
67
68pub struct DefaultCommitClassifier {
69 types: Vec<CommitType>,
70 pattern: String,
71}
72
73impl DefaultCommitClassifier {
74 pub fn new(types: Vec<CommitType>, pattern: String) -> Self {
75 Self { types, pattern }
76 }
77}
78
79impl Default for DefaultCommitClassifier {
80 fn default() -> Self {
81 Self::new(default_commit_types(), DEFAULT_COMMIT_PATTERN.into())
82 }
83}
84
85impl CommitClassifier for DefaultCommitClassifier {
86 fn types(&self) -> &[CommitType] {
87 &self.types
88 }
89 fn pattern(&self) -> &str {
90 &self.pattern
91 }
92}
93
94pub fn default_commit_types() -> Vec<CommitType> {
95 vec![
96 CommitType {
97 name: "feat".into(),
98 bump: Some(BumpLevel::Minor),
99 section: Some("Features".into()),
100 },
101 CommitType {
102 name: "fix".into(),
103 bump: Some(BumpLevel::Patch),
104 section: Some("Bug Fixes".into()),
105 },
106 CommitType {
107 name: "perf".into(),
108 bump: Some(BumpLevel::Patch),
109 section: Some("Performance".into()),
110 },
111 CommitType {
112 name: "docs".into(),
113 bump: None,
114 section: Some("Documentation".into()),
115 },
116 CommitType {
117 name: "refactor".into(),
118 bump: None,
119 section: Some("Refactoring".into()),
120 },
121 CommitType {
122 name: "revert".into(),
123 bump: None,
124 section: Some("Reverts".into()),
125 },
126 CommitType {
127 name: "chore".into(),
128 bump: None,
129 section: None,
130 },
131 CommitType {
132 name: "ci".into(),
133 bump: None,
134 section: None,
135 },
136 CommitType {
137 name: "test".into(),
138 bump: None,
139 section: None,
140 },
141 CommitType {
142 name: "build".into(),
143 bump: None,
144 section: None,
145 },
146 CommitType {
147 name: "style".into(),
148 bump: None,
149 section: None,
150 },
151 ]
152}
153
154pub trait CommitParser: Send + Sync {
156 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
157}
158
159pub struct DefaultCommitParser;
161
162impl CommitParser for DefaultCommitParser {
163 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
164 let re =
165 Regex::new(DEFAULT_COMMIT_PATTERN).map_err(|e| ReleaseError::Config(e.to_string()))?;
166
167 let caps = re.captures(&commit.message).ok_or_else(|| {
168 ReleaseError::Config(format!("not a conventional commit: {}", commit.message))
169 })?;
170
171 let r#type = caps.name("type").unwrap().as_str().to_string();
172 let scope = caps.name("scope").map(|m| m.as_str().to_string());
173 let breaking = caps.name("breaking").is_some();
174 let description = caps.name("description").unwrap().as_str().to_string();
175
176 let body = commit
177 .message
178 .split_once("\n\n")
179 .map(|x| x.1)
180 .map(|b| b.to_string());
181
182 let breaking = breaking
184 || body.as_deref().is_some_and(|b| {
185 b.lines().any(|line| {
186 let trimmed = line.trim();
187 trimmed.starts_with("BREAKING CHANGE:")
188 || trimmed.starts_with("BREAKING CHANGE ")
189 || trimmed.starts_with("BREAKING-CHANGE:")
190 || trimmed.starts_with("BREAKING-CHANGE ")
191 })
192 });
193
194 Ok(ConventionalCommit {
195 sha: commit.sha.clone(),
196 r#type,
197 scope,
198 description,
199 body,
200 breaking,
201 })
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn raw(message: &str) -> Commit {
210 Commit {
211 sha: "abc1234".into(),
212 message: message.into(),
213 }
214 }
215
216 #[test]
217 fn parse_simple_feat() {
218 let result = DefaultCommitParser.parse(&raw("feat: add button")).unwrap();
219 assert_eq!(result.r#type, "feat");
220 assert_eq!(result.description, "add button");
221 assert_eq!(result.scope, None);
222 assert!(!result.breaking);
223 }
224
225 #[test]
226 fn parse_scoped_fix() {
227 let result = DefaultCommitParser
228 .parse(&raw("fix(core): null check"))
229 .unwrap();
230 assert_eq!(result.r#type, "fix");
231 assert_eq!(result.scope.as_deref(), Some("core"));
232 }
233
234 #[test]
235 fn parse_breaking_bang() {
236 let result = DefaultCommitParser.parse(&raw("feat!: new API")).unwrap();
237 assert!(result.breaking);
238 }
239
240 #[test]
241 fn parse_with_body() {
242 let result = DefaultCommitParser
243 .parse(&raw("fix: x\n\ndetails"))
244 .unwrap();
245 assert_eq!(result.body.as_deref(), Some("details"));
246 }
247
248 #[test]
249 fn parse_breaking_change_footer() {
250 let result = DefaultCommitParser
251 .parse(&raw(
252 "feat: new API\n\nBREAKING CHANGE: removed old endpoint",
253 ))
254 .unwrap();
255 assert!(result.breaking);
256 assert_eq!(result.r#type, "feat");
257 }
258
259 #[test]
260 fn parse_breaking_change_hyphenated_footer() {
261 let result = DefaultCommitParser
262 .parse(&raw("fix: update schema\n\nBREAKING-CHANGE: field renamed"))
263 .unwrap();
264 assert!(result.breaking);
265 }
266
267 #[test]
268 fn parse_breaking_change_footer_with_bang() {
269 let result = DefaultCommitParser
271 .parse(&raw(
272 "feat!: overhaul\n\nBREAKING CHANGE: everything changed",
273 ))
274 .unwrap();
275 assert!(result.breaking);
276 }
277
278 #[test]
279 fn parse_no_breaking_change_in_body() {
280 let result = DefaultCommitParser
282 .parse(&raw("fix: tweak\n\nThis is not a BREAKING CHANGE footer"))
283 .unwrap();
284 assert!(!result.breaking);
285 }
286
287 #[test]
288 fn parse_invalid_message() {
289 let result = DefaultCommitParser.parse(&raw("not conventional"));
290 assert!(result.is_err());
291 }
292
293 #[test]
296 fn classifier_bump_level_feat() {
297 let c = DefaultCommitClassifier::default();
298 assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
299 }
300
301 #[test]
302 fn classifier_bump_level_fix() {
303 let c = DefaultCommitClassifier::default();
304 assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
305 }
306
307 #[test]
308 fn classifier_bump_level_breaking_overrides() {
309 let c = DefaultCommitClassifier::default();
310 assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
311 assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
312 }
313
314 #[test]
315 fn classifier_bump_level_no_bump_type() {
316 let c = DefaultCommitClassifier::default();
317 assert_eq!(c.bump_level("chore", false), None);
318 assert_eq!(c.bump_level("docs", false), None);
319 }
320
321 #[test]
322 fn classifier_bump_level_unknown_type() {
323 let c = DefaultCommitClassifier::default();
324 assert_eq!(c.bump_level("unknown", false), None);
325 }
326
327 #[test]
328 fn classifier_changelog_section() {
329 let c = DefaultCommitClassifier::default();
330 assert_eq!(c.changelog_section("feat"), Some("Features"));
331 assert_eq!(c.changelog_section("fix"), Some("Bug Fixes"));
332 assert_eq!(c.changelog_section("perf"), Some("Performance"));
333 assert_eq!(c.changelog_section("docs"), Some("Documentation"));
334 assert_eq!(c.changelog_section("refactor"), Some("Refactoring"));
335 assert_eq!(c.changelog_section("revert"), Some("Reverts"));
336 assert_eq!(c.changelog_section("chore"), None);
337 assert_eq!(c.changelog_section("unknown"), None);
338 }
339
340 #[test]
341 fn classifier_is_allowed() {
342 let c = DefaultCommitClassifier::default();
343 assert!(c.is_allowed("feat"));
344 assert!(c.is_allowed("chore"));
345 assert!(!c.is_allowed("unknown"));
346 }
347
348 #[test]
349 fn classifier_pattern() {
350 let c = DefaultCommitClassifier::default();
351 assert_eq!(c.pattern(), DEFAULT_COMMIT_PATTERN);
352 }
353
354 #[test]
355 fn default_commit_types_count() {
356 let types = default_commit_types();
357 assert_eq!(types.len(), 11);
358 }
359
360 #[test]
361 fn commit_type_serialization_roundtrip() {
362 let ct = CommitType {
363 name: "feat".into(),
364 bump: Some(BumpLevel::Minor),
365 section: Some("Features".into()),
366 };
367 let yaml = serde_yaml_ng::to_string(&ct).unwrap();
368 let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
369 assert_eq!(parsed, ct);
370 }
371
372 #[test]
373 fn commit_type_no_bump_no_section_roundtrip() {
374 let ct = CommitType {
375 name: "chore".into(),
376 bump: None,
377 section: None,
378 };
379 let yaml = serde_yaml_ng::to_string(&ct).unwrap();
380 assert!(!yaml.contains("bump"));
381 assert!(!yaml.contains("section"));
382 let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
383 assert_eq!(parsed, ct);
384 }
385}