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 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub pattern: Option<String>,
40}
41
42pub trait CommitClassifier: Send + Sync {
44 fn types(&self) -> &[CommitType];
45
46 fn pattern(&self) -> &str;
48
49 fn bump_level(&self, type_name: &str, breaking: bool) -> Option<BumpLevel> {
50 if breaking {
51 return Some(BumpLevel::Major);
52 }
53 self.types().iter().find(|t| t.name == type_name)?.bump
54 }
55
56 fn changelog_section(&self, type_name: &str) -> Option<&str> {
57 self.types()
58 .iter()
59 .find(|t| t.name == type_name)?
60 .section
61 .as_deref()
62 }
63
64 fn is_allowed(&self, type_name: &str) -> bool {
65 self.types().iter().any(|t| t.name == type_name)
66 }
67}
68
69pub const DEFAULT_COMMIT_PATTERN: &str =
72 r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)";
73
74pub struct DefaultCommitClassifier {
75 types: Vec<CommitType>,
76 pattern: String,
77}
78
79impl DefaultCommitClassifier {
80 pub fn new(types: Vec<CommitType>, pattern: String) -> Self {
81 Self { types, pattern }
82 }
83}
84
85impl Default for DefaultCommitClassifier {
86 fn default() -> Self {
87 Self::new(default_commit_types(), DEFAULT_COMMIT_PATTERN.into())
88 }
89}
90
91impl CommitClassifier for DefaultCommitClassifier {
92 fn types(&self) -> &[CommitType] {
93 &self.types
94 }
95 fn pattern(&self) -> &str {
96 &self.pattern
97 }
98}
99
100pub fn default_commit_types() -> Vec<CommitType> {
101 vec![
102 CommitType {
103 name: "feat".into(),
104 bump: Some(BumpLevel::Minor),
105 section: Some("Features".into()),
106 pattern: None,
107 },
108 CommitType {
109 name: "fix".into(),
110 bump: Some(BumpLevel::Patch),
111 section: Some("Bug Fixes".into()),
112 pattern: None,
113 },
114 CommitType {
115 name: "perf".into(),
116 bump: Some(BumpLevel::Patch),
117 section: Some("Performance".into()),
118 pattern: None,
119 },
120 CommitType {
121 name: "docs".into(),
122 bump: None,
123 section: Some("Documentation".into()),
124 pattern: None,
125 },
126 CommitType {
127 name: "refactor".into(),
128 bump: Some(BumpLevel::Patch),
129 section: Some("Refactoring".into()),
130 pattern: None,
131 },
132 CommitType {
133 name: "revert".into(),
134 bump: None,
135 section: Some("Reverts".into()),
136 pattern: None,
137 },
138 CommitType {
139 name: "chore".into(),
140 bump: None,
141 section: None,
142 pattern: None,
143 },
144 CommitType {
145 name: "ci".into(),
146 bump: None,
147 section: None,
148 pattern: None,
149 },
150 CommitType {
151 name: "test".into(),
152 bump: None,
153 section: None,
154 pattern: None,
155 },
156 CommitType {
157 name: "build".into(),
158 bump: None,
159 section: None,
160 pattern: None,
161 },
162 CommitType {
163 name: "style".into(),
164 bump: None,
165 section: None,
166 pattern: None,
167 },
168 ]
169}
170
171pub trait CommitParser: Send + Sync {
173 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
174}
175
176pub struct DefaultCommitParser;
178
179impl CommitParser for DefaultCommitParser {
180 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
181 let re =
182 Regex::new(DEFAULT_COMMIT_PATTERN).map_err(|e| ReleaseError::Config(e.to_string()))?;
183
184 let caps = re.captures(&commit.message).ok_or_else(|| {
185 ReleaseError::Config(format!("not a conventional commit: {}", commit.message))
186 })?;
187
188 let r#type = caps.name("type").unwrap().as_str().to_string();
189 let scope = caps.name("scope").map(|m| m.as_str().to_string());
190 let breaking = caps.name("breaking").is_some();
191 let description = caps.name("description").unwrap().as_str().to_string();
192
193 let body = commit
194 .message
195 .split_once("\n\n")
196 .map(|x| x.1)
197 .map(|b| b.to_string());
198
199 let breaking = breaking
201 || body.as_deref().is_some_and(|b| {
202 b.lines().any(|line| {
203 let trimmed = line.trim();
204 trimmed.starts_with("BREAKING CHANGE:")
205 || trimmed.starts_with("BREAKING CHANGE ")
206 || trimmed.starts_with("BREAKING-CHANGE:")
207 || trimmed.starts_with("BREAKING-CHANGE ")
208 })
209 });
210
211 Ok(ConventionalCommit {
212 sha: commit.sha.clone(),
213 r#type,
214 scope,
215 description,
216 body,
217 breaking,
218 })
219 }
220}
221
222pub struct ConfiguredCommitParser {
225 types: Vec<CommitType>,
226 commit_pattern: String,
227}
228
229impl ConfiguredCommitParser {
230 pub fn new(types: Vec<CommitType>, commit_pattern: String) -> Self {
231 Self {
232 types,
233 commit_pattern,
234 }
235 }
236}
237
238impl CommitParser for ConfiguredCommitParser {
239 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
240 let re =
241 Regex::new(&self.commit_pattern).map_err(|e| ReleaseError::Config(e.to_string()))?;
242
243 let first_line = commit.message.lines().next().unwrap_or("");
244
245 if let Some(caps) = re.captures(first_line) {
247 let r#type = caps.name("type").unwrap().as_str().to_string();
248 let scope = caps.name("scope").map(|m| m.as_str().to_string());
249 let breaking = caps.name("breaking").is_some();
250 let description = caps.name("description").unwrap().as_str().to_string();
251
252 let body = commit
253 .message
254 .split_once("\n\n")
255 .map(|x| x.1)
256 .map(|b| b.to_string());
257
258 let breaking = breaking
259 || body.as_deref().is_some_and(|b| {
260 b.lines().any(|line| {
261 let trimmed = line.trim();
262 trimmed.starts_with("BREAKING CHANGE:")
263 || trimmed.starts_with("BREAKING CHANGE ")
264 || trimmed.starts_with("BREAKING-CHANGE:")
265 || trimmed.starts_with("BREAKING-CHANGE ")
266 })
267 });
268
269 return Ok(ConventionalCommit {
270 sha: commit.sha.clone(),
271 r#type,
272 scope,
273 description,
274 body,
275 breaking,
276 });
277 }
278
279 for ct in &self.types {
281 let Some(ref pat) = ct.pattern else {
282 continue;
283 };
284 let Ok(type_re) = Regex::new(pat) else {
285 continue;
286 };
287 if let Some(caps) = type_re.captures(first_line) {
288 let scope = caps.name("scope").map(|m| m.as_str().to_string());
289 let breaking = caps.name("breaking").is_some();
290 let description = caps
291 .name("description")
292 .map(|m| m.as_str().to_string())
293 .unwrap_or_else(|| first_line.to_string());
294
295 let body = commit
296 .message
297 .split_once("\n\n")
298 .map(|x| x.1)
299 .map(|b| b.to_string());
300
301 return Ok(ConventionalCommit {
302 sha: commit.sha.clone(),
303 r#type: ct.name.clone(),
304 scope,
305 description,
306 body,
307 breaking,
308 });
309 }
310 }
311
312 Err(ReleaseError::Config(format!(
313 "not a conventional commit: {}",
314 commit.message
315 )))
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn raw(message: &str) -> Commit {
324 Commit {
325 sha: "abc1234".into(),
326 message: message.into(),
327 }
328 }
329
330 #[test]
331 fn parse_simple_feat() {
332 let result = DefaultCommitParser.parse(&raw("feat: add button")).unwrap();
333 assert_eq!(result.r#type, "feat");
334 assert_eq!(result.description, "add button");
335 assert_eq!(result.scope, None);
336 assert!(!result.breaking);
337 }
338
339 #[test]
340 fn parse_scoped_fix() {
341 let result = DefaultCommitParser
342 .parse(&raw("fix(core): null check"))
343 .unwrap();
344 assert_eq!(result.r#type, "fix");
345 assert_eq!(result.scope.as_deref(), Some("core"));
346 }
347
348 #[test]
349 fn parse_breaking_bang() {
350 let result = DefaultCommitParser.parse(&raw("feat!: new API")).unwrap();
351 assert!(result.breaking);
352 }
353
354 #[test]
355 fn parse_with_body() {
356 let result = DefaultCommitParser
357 .parse(&raw("fix: x\n\ndetails"))
358 .unwrap();
359 assert_eq!(result.body.as_deref(), Some("details"));
360 }
361
362 #[test]
363 fn parse_breaking_change_footer() {
364 let result = DefaultCommitParser
365 .parse(&raw(
366 "feat: new API\n\nBREAKING CHANGE: removed old endpoint",
367 ))
368 .unwrap();
369 assert!(result.breaking);
370 assert_eq!(result.r#type, "feat");
371 }
372
373 #[test]
374 fn parse_breaking_change_hyphenated_footer() {
375 let result = DefaultCommitParser
376 .parse(&raw("fix: update schema\n\nBREAKING-CHANGE: field renamed"))
377 .unwrap();
378 assert!(result.breaking);
379 }
380
381 #[test]
382 fn parse_breaking_change_footer_with_bang() {
383 let result = DefaultCommitParser
385 .parse(&raw(
386 "feat!: overhaul\n\nBREAKING CHANGE: everything changed",
387 ))
388 .unwrap();
389 assert!(result.breaking);
390 }
391
392 #[test]
393 fn parse_no_breaking_change_in_body() {
394 let result = DefaultCommitParser
396 .parse(&raw("fix: tweak\n\nThis is not a BREAKING CHANGE footer"))
397 .unwrap();
398 assert!(!result.breaking);
399 }
400
401 #[test]
402 fn parse_invalid_message() {
403 let result = DefaultCommitParser.parse(&raw("not conventional"));
404 assert!(result.is_err());
405 }
406
407 #[test]
410 fn classifier_bump_level_feat() {
411 let c = DefaultCommitClassifier::default();
412 assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
413 }
414
415 #[test]
416 fn classifier_bump_level_fix() {
417 let c = DefaultCommitClassifier::default();
418 assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
419 }
420
421 #[test]
422 fn classifier_bump_level_breaking_overrides() {
423 let c = DefaultCommitClassifier::default();
424 assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
425 assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
426 }
427
428 #[test]
429 fn classifier_bump_level_no_bump_type() {
430 let c = DefaultCommitClassifier::default();
431 assert_eq!(c.bump_level("chore", false), None);
432 assert_eq!(c.bump_level("docs", false), None);
433 }
434
435 #[test]
436 fn classifier_bump_level_unknown_type() {
437 let c = DefaultCommitClassifier::default();
438 assert_eq!(c.bump_level("unknown", false), None);
439 }
440
441 #[test]
442 fn classifier_changelog_section() {
443 let c = DefaultCommitClassifier::default();
444 assert_eq!(c.changelog_section("feat"), Some("Features"));
445 assert_eq!(c.changelog_section("fix"), Some("Bug Fixes"));
446 assert_eq!(c.changelog_section("perf"), Some("Performance"));
447 assert_eq!(c.changelog_section("docs"), Some("Documentation"));
448 assert_eq!(c.changelog_section("refactor"), Some("Refactoring"));
449 assert_eq!(c.changelog_section("revert"), Some("Reverts"));
450 assert_eq!(c.changelog_section("chore"), None);
451 assert_eq!(c.changelog_section("unknown"), None);
452 }
453
454 #[test]
455 fn classifier_is_allowed() {
456 let c = DefaultCommitClassifier::default();
457 assert!(c.is_allowed("feat"));
458 assert!(c.is_allowed("chore"));
459 assert!(!c.is_allowed("unknown"));
460 }
461
462 #[test]
463 fn classifier_pattern() {
464 let c = DefaultCommitClassifier::default();
465 assert_eq!(c.pattern(), DEFAULT_COMMIT_PATTERN);
466 }
467
468 #[test]
469 fn default_commit_types_count() {
470 let types = default_commit_types();
471 assert_eq!(types.len(), 11);
472 }
473
474 #[test]
475 fn commit_type_serialization_roundtrip() {
476 let ct = CommitType {
477 name: "feat".into(),
478 bump: Some(BumpLevel::Minor),
479 section: Some("Features".into()),
480 pattern: None,
481 };
482 let yaml = serde_yaml_ng::to_string(&ct).unwrap();
483 let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
484 assert_eq!(parsed, ct);
485 }
486
487 #[test]
488 fn commit_type_no_bump_no_section_roundtrip() {
489 let ct = CommitType {
490 name: "chore".into(),
491 bump: None,
492 section: None,
493 pattern: None,
494 };
495 let yaml = serde_yaml_ng::to_string(&ct).unwrap();
496 assert!(!yaml.contains("bump"));
497 assert!(!yaml.contains("section"));
498 assert!(!yaml.contains("pattern"));
499 let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
500 assert_eq!(parsed, ct);
501 }
502
503 #[test]
504 fn commit_type_with_pattern_roundtrip() {
505 let ct = CommitType {
506 name: "deps".into(),
507 bump: Some(BumpLevel::Patch),
508 section: Some("Dependencies".into()),
509 pattern: Some(r"^Bump .+ from .+ to .+".into()),
510 };
511 let yaml = serde_yaml_ng::to_string(&ct).unwrap();
512 assert!(yaml.contains("pattern"));
513 let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
514 assert_eq!(parsed, ct);
515 }
516
517 fn configured_parser_with_deps() -> ConfiguredCommitParser {
520 let mut types = default_commit_types();
521 types.push(CommitType {
522 name: "deps".into(),
523 bump: Some(BumpLevel::Patch),
524 section: Some("Dependencies".into()),
525 pattern: Some(r"^Bump (?P<description>.+)".into()),
526 });
527 ConfiguredCommitParser::new(types, DEFAULT_COMMIT_PATTERN.into())
528 }
529
530 #[test]
531 fn configured_parser_standard_match_preferred() {
532 let parser = configured_parser_with_deps();
533 let result = parser.parse(&raw("feat: add button")).unwrap();
534 assert_eq!(result.r#type, "feat");
535 assert_eq!(result.description, "add button");
536 }
537
538 #[test]
539 fn configured_parser_fallback_match() {
540 let parser = configured_parser_with_deps();
541 let result = parser
542 .parse(&raw("Bump serde from 1.0.0 to 1.1.0"))
543 .unwrap();
544 assert_eq!(result.r#type, "deps");
545 assert_eq!(result.description, "serde from 1.0.0 to 1.1.0");
546 }
547
548 #[test]
549 fn configured_parser_fallback_no_named_groups() {
550 let mut types = default_commit_types();
551 types.push(CommitType {
552 name: "deps".into(),
553 bump: Some(BumpLevel::Patch),
554 section: Some("Dependencies".into()),
555 pattern: Some(r"^Bump .+ from .+ to .+".into()),
556 });
557 let parser = ConfiguredCommitParser::new(types, DEFAULT_COMMIT_PATTERN.into());
558 let result = parser
559 .parse(&raw("Bump serde from 1.0.0 to 1.1.0"))
560 .unwrap();
561 assert_eq!(result.r#type, "deps");
562 assert_eq!(result.description, "Bump serde from 1.0.0 to 1.1.0");
564 }
565
566 #[test]
567 fn configured_parser_no_match() {
568 let parser = configured_parser_with_deps();
569 let result = parser.parse(&raw("random garbage message"));
570 assert!(result.is_err());
571 }
572}