Skip to main content

sr_core/
commit.rs

1use regex::Regex;
2use serde::{Deserialize, Serialize};
3
4use crate::error::ReleaseError;
5use crate::version::BumpLevel;
6
7/// A raw commit as read from git history.
8#[derive(Debug, Clone)]
9pub struct Commit {
10    pub sha: String,
11    pub message: String,
12}
13
14/// A commit parsed according to the Conventional Commits specification.
15#[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/// Describes a recognised commit type.
26#[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    /// Changelog section heading (e.g. "Features"). None = exclude from changelog.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub section: Option<String>,
34    /// Optional regex matched against the full raw commit message as a fallback
35    /// when the standard type-prefix pattern doesn't match. Useful for
36    /// non-conventional commit formats (e.g. Dependabot, automated tooling).
37    /// Named groups `type`, `scope`, `breaking`, `description` are used if present.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub pattern: Option<String>,
40}
41
42/// Single source of truth for commit type classification.
43pub trait CommitClassifier: Send + Sync {
44    fn types(&self) -> &[CommitType];
45
46    /// Commit message regex with named groups: type, scope, breaking, description.
47    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
69/// Default conventional commits pattern.
70/// Named groups: type, scope (optional), breaking (optional `!`), description.
71pub 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
171/// Parses raw commits into conventional commits.
172pub trait CommitParser: Send + Sync {
173    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
174}
175
176/// Default parser using the built-in `DEFAULT_COMMIT_PATTERN` regex.
177pub 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        // Detect BREAKING CHANGE: / BREAKING-CHANGE: footers in the body.
200        // Per the Conventional Commits spec, footers must start at column 0
201        // (no leading whitespace) and use a colon separator.
202        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
220/// Parser that tries the standard commit pattern first, then falls back to
221/// per-type `pattern` regexes for non-conventional commit formats.
222pub 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        // Try the standard type-prefix pattern first.
244        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        // Fallback: try per-type pattern regexes.
278        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        // Both bang and footer — should still be breaking
382        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        // Body text that mentions "BREAKING CHANGE" but not as a footer line
393        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        // Indented bullet mentioning BREAKING CHANGE should not trigger a major bump
402        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        // "BREAKING CHANGE " (space, no colon) is not a valid footer per spec
413        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    // --- CommitClassifier tests ---
426
427    #[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    // --- ConfiguredCommitParser tests ---
536
537    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        // Without named description group, uses full first line
581        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}