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}
35
36/// Single source of truth for commit type classification.
37pub trait CommitClassifier: Send + Sync {
38    fn types(&self) -> &[CommitType];
39
40    /// Commit message regex with named groups: type, scope, breaking, description.
41    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
63/// Default conventional commits pattern.
64/// Named groups: type, scope (optional), breaking (optional `!`), description.
65pub 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
154/// Parses raw commits into conventional commits.
155pub trait CommitParser: Send + Sync {
156    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
157}
158
159/// Default parser using the built-in `DEFAULT_COMMIT_PATTERN` regex.
160pub 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        // Detect BREAKING CHANGE / BREAKING-CHANGE footers in the body
183        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        // Both bang and footer — should still be breaking
270        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        // Body text that mentions "BREAKING CHANGE" but not as a footer line
281        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    // --- CommitClassifier tests ---
294
295    #[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}