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        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
222/// Parser that tries the standard commit pattern first, then falls back to
223/// per-type `pattern` regexes for non-conventional commit formats.
224pub 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        // Try the standard type-prefix pattern first.
246        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        // Fallback: try per-type pattern regexes.
280        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        // Both bang and footer — should still be breaking
384        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        // Body text that mentions "BREAKING CHANGE" but not as a footer line
395        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    // --- CommitClassifier tests ---
408
409    #[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    // --- ConfiguredCommitParser tests ---
518
519    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        // Without named description group, uses full first line
563        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}