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
203 || body.as_deref().is_some_and(|b| {
204 b.lines().any(|line| {
205 line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:")
206 })
207 });
208
209 Ok(ConventionalCommit {
210 sha: commit.sha.clone(),
211 r#type,
212 scope,
213 description,
214 body,
215 breaking,
216 })
217 }
218}
219
220pub struct ConfiguredCommitParser {
223 types: Vec<CommitType>,
224 commit_pattern: String,
225}
226
227impl ConfiguredCommitParser {
228 pub fn new(types: Vec<CommitType>, commit_pattern: String) -> Self {
229 Self {
230 types,
231 commit_pattern,
232 }
233 }
234}
235
236impl CommitParser for ConfiguredCommitParser {
237 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
238 let re =
239 Regex::new(&self.commit_pattern).map_err(|e| ReleaseError::Config(e.to_string()))?;
240
241 let first_line = commit.message.lines().next().unwrap_or("");
242
243 if let Some(caps) = re.captures(first_line) {
245 let r#type = caps.name("type").unwrap().as_str().to_string();
246 let scope = caps.name("scope").map(|m| m.as_str().to_string());
247 let breaking = caps.name("breaking").is_some();
248 let description = caps.name("description").unwrap().as_str().to_string();
249
250 let body = commit
251 .message
252 .split_once("\n\n")
253 .map(|x| x.1)
254 .map(|b| b.to_string());
255
256 let breaking = breaking
257 || body.as_deref().is_some_and(|b| {
258 b.lines().any(|line| {
259 let trimmed = line.trim();
260 trimmed.starts_with("BREAKING CHANGE:")
261 || trimmed.starts_with("BREAKING CHANGE ")
262 || trimmed.starts_with("BREAKING-CHANGE:")
263 || trimmed.starts_with("BREAKING-CHANGE ")
264 })
265 });
266
267 return Ok(ConventionalCommit {
268 sha: commit.sha.clone(),
269 r#type,
270 scope,
271 description,
272 body,
273 breaking,
274 });
275 }
276
277 for ct in &self.types {
279 let Some(ref pat) = ct.pattern else {
280 continue;
281 };
282 let Ok(type_re) = Regex::new(pat) else {
283 continue;
284 };
285 if let Some(caps) = type_re.captures(first_line) {
286 let scope = caps.name("scope").map(|m| m.as_str().to_string());
287 let breaking = caps.name("breaking").is_some();
288 let description = caps
289 .name("description")
290 .map(|m| m.as_str().to_string())
291 .unwrap_or_else(|| first_line.to_string());
292
293 let body = commit
294 .message
295 .split_once("\n\n")
296 .map(|x| x.1)
297 .map(|b| b.to_string());
298
299 return Ok(ConventionalCommit {
300 sha: commit.sha.clone(),
301 r#type: ct.name.clone(),
302 scope,
303 description,
304 body,
305 breaking,
306 });
307 }
308 }
309
310 Err(ReleaseError::Config(format!(
311 "not a conventional commit: {}",
312 commit.message
313 )))
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 fn raw(message: &str) -> Commit {
322 Commit {
323 sha: "abc1234".into(),
324 message: message.into(),
325 }
326 }
327
328 #[test]
329 fn parse_simple_feat() {
330 let result = DefaultCommitParser.parse(&raw("feat: add button")).unwrap();
331 assert_eq!(result.r#type, "feat");
332 assert_eq!(result.description, "add button");
333 assert_eq!(result.scope, None);
334 assert!(!result.breaking);
335 }
336
337 #[test]
338 fn parse_scoped_fix() {
339 let result = DefaultCommitParser
340 .parse(&raw("fix(core): null check"))
341 .unwrap();
342 assert_eq!(result.r#type, "fix");
343 assert_eq!(result.scope.as_deref(), Some("core"));
344 }
345
346 #[test]
347 fn parse_breaking_bang() {
348 let result = DefaultCommitParser.parse(&raw("feat!: new API")).unwrap();
349 assert!(result.breaking);
350 }
351
352 #[test]
353 fn parse_with_body() {
354 let result = DefaultCommitParser
355 .parse(&raw("fix: x\n\ndetails"))
356 .unwrap();
357 assert_eq!(result.body.as_deref(), Some("details"));
358 }
359
360 #[test]
361 fn parse_breaking_change_footer() {
362 let result = DefaultCommitParser
363 .parse(&raw(
364 "feat: new API\n\nBREAKING CHANGE: removed old endpoint",
365 ))
366 .unwrap();
367 assert!(result.breaking);
368 assert_eq!(result.r#type, "feat");
369 }
370
371 #[test]
372 fn parse_breaking_change_hyphenated_footer() {
373 let result = DefaultCommitParser
374 .parse(&raw("fix: update schema\n\nBREAKING-CHANGE: field renamed"))
375 .unwrap();
376 assert!(result.breaking);
377 }
378
379 #[test]
380 fn parse_breaking_change_footer_with_bang() {
381 let result = DefaultCommitParser
383 .parse(&raw(
384 "feat!: overhaul\n\nBREAKING CHANGE: everything changed",
385 ))
386 .unwrap();
387 assert!(result.breaking);
388 }
389
390 #[test]
391 fn parse_no_breaking_change_in_body() {
392 let result = DefaultCommitParser
394 .parse(&raw("fix: tweak\n\nThis is not a BREAKING CHANGE footer"))
395 .unwrap();
396 assert!(!result.breaking);
397 }
398
399 #[test]
400 fn parse_no_breaking_change_indented_bullet() {
401 let result = DefaultCommitParser
403 .parse(&raw(
404 "feat(mcp): add breaking flag\n\n- add `breaking` field — sets \"!\" and adds\n BREAKING CHANGE footer automatically",
405 ))
406 .unwrap();
407 assert!(!result.breaking);
408 }
409
410 #[test]
411 fn parse_no_breaking_change_space_separator() {
412 let result = DefaultCommitParser
414 .parse(&raw("feat: something\n\nBREAKING CHANGE without colon"))
415 .unwrap();
416 assert!(!result.breaking);
417 }
418
419 #[test]
420 fn parse_invalid_message() {
421 let result = DefaultCommitParser.parse(&raw("not conventional"));
422 assert!(result.is_err());
423 }
424
425 #[test]
428 fn classifier_bump_level_feat() {
429 let c = DefaultCommitClassifier::default();
430 assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
431 }
432
433 #[test]
434 fn classifier_bump_level_fix() {
435 let c = DefaultCommitClassifier::default();
436 assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
437 }
438
439 #[test]
440 fn classifier_bump_level_breaking_overrides() {
441 let c = DefaultCommitClassifier::default();
442 assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
443 assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
444 }
445
446 #[test]
447 fn classifier_bump_level_no_bump_type() {
448 let c = DefaultCommitClassifier::default();
449 assert_eq!(c.bump_level("chore", false), None);
450 assert_eq!(c.bump_level("docs", false), None);
451 }
452
453 #[test]
454 fn classifier_bump_level_unknown_type() {
455 let c = DefaultCommitClassifier::default();
456 assert_eq!(c.bump_level("unknown", false), None);
457 }
458
459 #[test]
460 fn classifier_changelog_section() {
461 let c = DefaultCommitClassifier::default();
462 assert_eq!(c.changelog_section("feat"), Some("Features"));
463 assert_eq!(c.changelog_section("fix"), Some("Bug Fixes"));
464 assert_eq!(c.changelog_section("perf"), Some("Performance"));
465 assert_eq!(c.changelog_section("docs"), Some("Documentation"));
466 assert_eq!(c.changelog_section("refactor"), Some("Refactoring"));
467 assert_eq!(c.changelog_section("revert"), Some("Reverts"));
468 assert_eq!(c.changelog_section("chore"), None);
469 assert_eq!(c.changelog_section("unknown"), None);
470 }
471
472 #[test]
473 fn classifier_is_allowed() {
474 let c = DefaultCommitClassifier::default();
475 assert!(c.is_allowed("feat"));
476 assert!(c.is_allowed("chore"));
477 assert!(!c.is_allowed("unknown"));
478 }
479
480 #[test]
481 fn classifier_pattern() {
482 let c = DefaultCommitClassifier::default();
483 assert_eq!(c.pattern(), DEFAULT_COMMIT_PATTERN);
484 }
485
486 #[test]
487 fn default_commit_types_count() {
488 let types = default_commit_types();
489 assert_eq!(types.len(), 11);
490 }
491
492 #[test]
493 fn commit_type_serialization_roundtrip() {
494 let ct = CommitType {
495 name: "feat".into(),
496 bump: Some(BumpLevel::Minor),
497 section: Some("Features".into()),
498 pattern: None,
499 };
500 let yaml = serde_yaml_ng::to_string(&ct).unwrap();
501 let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
502 assert_eq!(parsed, ct);
503 }
504
505 #[test]
506 fn commit_type_no_bump_no_section_roundtrip() {
507 let ct = CommitType {
508 name: "chore".into(),
509 bump: None,
510 section: None,
511 pattern: None,
512 };
513 let yaml = serde_yaml_ng::to_string(&ct).unwrap();
514 assert!(!yaml.contains("bump"));
515 assert!(!yaml.contains("section"));
516 assert!(!yaml.contains("pattern"));
517 let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
518 assert_eq!(parsed, ct);
519 }
520
521 #[test]
522 fn commit_type_with_pattern_roundtrip() {
523 let ct = CommitType {
524 name: "deps".into(),
525 bump: Some(BumpLevel::Patch),
526 section: Some("Dependencies".into()),
527 pattern: Some(r"^Bump .+ from .+ to .+".into()),
528 };
529 let yaml = serde_yaml_ng::to_string(&ct).unwrap();
530 assert!(yaml.contains("pattern"));
531 let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
532 assert_eq!(parsed, ct);
533 }
534
535 fn configured_parser_with_deps() -> ConfiguredCommitParser {
538 let mut types = default_commit_types();
539 types.push(CommitType {
540 name: "deps".into(),
541 bump: Some(BumpLevel::Patch),
542 section: Some("Dependencies".into()),
543 pattern: Some(r"^Bump (?P<description>.+)".into()),
544 });
545 ConfiguredCommitParser::new(types, DEFAULT_COMMIT_PATTERN.into())
546 }
547
548 #[test]
549 fn configured_parser_standard_match_preferred() {
550 let parser = configured_parser_with_deps();
551 let result = parser.parse(&raw("feat: add button")).unwrap();
552 assert_eq!(result.r#type, "feat");
553 assert_eq!(result.description, "add button");
554 }
555
556 #[test]
557 fn configured_parser_fallback_match() {
558 let parser = configured_parser_with_deps();
559 let result = parser
560 .parse(&raw("Bump serde from 1.0.0 to 1.1.0"))
561 .unwrap();
562 assert_eq!(result.r#type, "deps");
563 assert_eq!(result.description, "serde from 1.0.0 to 1.1.0");
564 }
565
566 #[test]
567 fn configured_parser_fallback_no_named_groups() {
568 let mut types = default_commit_types();
569 types.push(CommitType {
570 name: "deps".into(),
571 bump: Some(BumpLevel::Patch),
572 section: Some("Dependencies".into()),
573 pattern: Some(r"^Bump .+ from .+ to .+".into()),
574 });
575 let parser = ConfiguredCommitParser::new(types, DEFAULT_COMMIT_PATTERN.into());
576 let result = parser
577 .parse(&raw("Bump serde from 1.0.0 to 1.1.0"))
578 .unwrap();
579 assert_eq!(result.r#type, "deps");
580 assert_eq!(result.description, "Bump serde from 1.0.0 to 1.1.0");
582 }
583
584 #[test]
585 fn configured_parser_no_match() {
586 let parser = configured_parser_with_deps();
587 let result = parser.parse(&raw("random garbage message"));
588 assert!(result.is_err());
589 }
590}