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    pub author: Option<String>,
13}
14
15/// A commit parsed according to the Conventional Commits specification.
16#[derive(Debug, Clone, Serialize)]
17pub struct ConventionalCommit {
18    pub sha: String,
19    pub r#type: String,
20    pub scope: Option<String>,
21    pub description: String,
22    pub body: Option<String>,
23    pub breaking: bool,
24    pub author: Option<String>,
25}
26
27/// Describes a recognised commit type.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct CommitType {
30    pub name: String,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub bump: Option<BumpLevel>,
33    /// Changelog section heading (e.g. "Features"). None = exclude from changelog.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub section: Option<String>,
36}
37
38/// Single source of truth for commit type classification.
39pub trait CommitClassifier: Send + Sync {
40    fn types(&self) -> &[CommitType];
41
42    /// Commit message regex with named groups: type, scope, breaking, description.
43    fn pattern(&self) -> &str;
44
45    fn bump_level(&self, type_name: &str, breaking: bool) -> Option<BumpLevel> {
46        if breaking {
47            return Some(BumpLevel::Major);
48        }
49        self.types().iter().find(|t| t.name == type_name)?.bump
50    }
51
52    fn changelog_section(&self, type_name: &str) -> Option<&str> {
53        self.types()
54            .iter()
55            .find(|t| t.name == type_name)?
56            .section
57            .as_deref()
58    }
59
60    fn is_allowed(&self, type_name: &str) -> bool {
61        self.types().iter().any(|t| t.name == type_name)
62    }
63}
64
65/// Default conventional commits pattern.
66/// Named groups: type, scope (optional), breaking (optional `!`), description.
67pub const DEFAULT_COMMIT_PATTERN: &str =
68    r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)";
69
70pub struct DefaultCommitClassifier {
71    types: Vec<CommitType>,
72    pattern: String,
73}
74
75impl DefaultCommitClassifier {
76    pub fn new(types: Vec<CommitType>, pattern: String) -> Self {
77        Self { types, pattern }
78    }
79}
80
81impl Default for DefaultCommitClassifier {
82    fn default() -> Self {
83        Self::new(default_commit_types(), DEFAULT_COMMIT_PATTERN.into())
84    }
85}
86
87impl CommitClassifier for DefaultCommitClassifier {
88    fn types(&self) -> &[CommitType] {
89        &self.types
90    }
91    fn pattern(&self) -> &str {
92        &self.pattern
93    }
94}
95
96pub fn default_commit_types() -> Vec<CommitType> {
97    vec![
98        CommitType {
99            name: "feat".into(),
100            bump: Some(BumpLevel::Minor),
101            section: Some("Features".into()),
102        },
103        CommitType {
104            name: "fix".into(),
105            bump: Some(BumpLevel::Patch),
106            section: Some("Bug Fixes".into()),
107        },
108        CommitType {
109            name: "perf".into(),
110            bump: Some(BumpLevel::Patch),
111            section: Some("Performance".into()),
112        },
113        CommitType {
114            name: "docs".into(),
115            bump: None,
116            section: Some("Documentation".into()),
117        },
118        CommitType {
119            name: "refactor".into(),
120            bump: None,
121            section: Some("Refactoring".into()),
122        },
123        CommitType {
124            name: "revert".into(),
125            bump: None,
126            section: Some("Reverts".into()),
127        },
128        CommitType {
129            name: "chore".into(),
130            bump: None,
131            section: None,
132        },
133        CommitType {
134            name: "ci".into(),
135            bump: None,
136            section: None,
137        },
138        CommitType {
139            name: "test".into(),
140            bump: None,
141            section: None,
142        },
143        CommitType {
144            name: "build".into(),
145            bump: None,
146            section: None,
147        },
148        CommitType {
149            name: "style".into(),
150            bump: None,
151            section: None,
152        },
153    ]
154}
155
156/// Parses raw commits into conventional commits.
157pub trait CommitParser: Send + Sync {
158    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
159}
160
161/// Default parser using the built-in `DEFAULT_COMMIT_PATTERN` regex.
162pub struct DefaultCommitParser;
163
164impl CommitParser for DefaultCommitParser {
165    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
166        let re =
167            Regex::new(DEFAULT_COMMIT_PATTERN).map_err(|e| ReleaseError::Config(e.to_string()))?;
168
169        let caps = re.captures(&commit.message).ok_or_else(|| {
170            ReleaseError::Config(format!("not a conventional commit: {}", commit.message))
171        })?;
172
173        let r#type = caps.name("type").unwrap().as_str().to_string();
174        let scope = caps.name("scope").map(|m| m.as_str().to_string());
175        let breaking = caps.name("breaking").is_some();
176        let description = caps.name("description").unwrap().as_str().to_string();
177
178        let body = commit
179            .message
180            .split_once("\n\n")
181            .map(|x| x.1)
182            .map(|b| b.to_string());
183
184        Ok(ConventionalCommit {
185            sha: commit.sha.clone(),
186            r#type,
187            scope,
188            description,
189            body,
190            breaking,
191            author: commit.author.clone(),
192        })
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn raw(message: &str) -> Commit {
201        Commit {
202            sha: "abc1234".into(),
203            message: message.into(),
204            author: None,
205        }
206    }
207
208    #[test]
209    fn parse_simple_feat() {
210        let result = DefaultCommitParser.parse(&raw("feat: add button")).unwrap();
211        assert_eq!(result.r#type, "feat");
212        assert_eq!(result.description, "add button");
213        assert_eq!(result.scope, None);
214        assert!(!result.breaking);
215    }
216
217    #[test]
218    fn parse_scoped_fix() {
219        let result = DefaultCommitParser
220            .parse(&raw("fix(core): null check"))
221            .unwrap();
222        assert_eq!(result.r#type, "fix");
223        assert_eq!(result.scope.as_deref(), Some("core"));
224    }
225
226    #[test]
227    fn parse_breaking_bang() {
228        let result = DefaultCommitParser.parse(&raw("feat!: new API")).unwrap();
229        assert!(result.breaking);
230    }
231
232    #[test]
233    fn parse_with_body() {
234        let result = DefaultCommitParser
235            .parse(&raw("fix: x\n\ndetails"))
236            .unwrap();
237        assert_eq!(result.body.as_deref(), Some("details"));
238    }
239
240    #[test]
241    fn parse_invalid_message() {
242        let result = DefaultCommitParser.parse(&raw("not conventional"));
243        assert!(result.is_err());
244    }
245
246    // --- CommitClassifier tests ---
247
248    #[test]
249    fn classifier_bump_level_feat() {
250        let c = DefaultCommitClassifier::default();
251        assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
252    }
253
254    #[test]
255    fn classifier_bump_level_fix() {
256        let c = DefaultCommitClassifier::default();
257        assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
258    }
259
260    #[test]
261    fn classifier_bump_level_breaking_overrides() {
262        let c = DefaultCommitClassifier::default();
263        assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
264        assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
265    }
266
267    #[test]
268    fn classifier_bump_level_no_bump_type() {
269        let c = DefaultCommitClassifier::default();
270        assert_eq!(c.bump_level("chore", false), None);
271        assert_eq!(c.bump_level("docs", false), None);
272    }
273
274    #[test]
275    fn classifier_bump_level_unknown_type() {
276        let c = DefaultCommitClassifier::default();
277        assert_eq!(c.bump_level("unknown", false), None);
278    }
279
280    #[test]
281    fn classifier_changelog_section() {
282        let c = DefaultCommitClassifier::default();
283        assert_eq!(c.changelog_section("feat"), Some("Features"));
284        assert_eq!(c.changelog_section("fix"), Some("Bug Fixes"));
285        assert_eq!(c.changelog_section("perf"), Some("Performance"));
286        assert_eq!(c.changelog_section("docs"), Some("Documentation"));
287        assert_eq!(c.changelog_section("refactor"), Some("Refactoring"));
288        assert_eq!(c.changelog_section("revert"), Some("Reverts"));
289        assert_eq!(c.changelog_section("chore"), None);
290        assert_eq!(c.changelog_section("unknown"), None);
291    }
292
293    #[test]
294    fn classifier_is_allowed() {
295        let c = DefaultCommitClassifier::default();
296        assert!(c.is_allowed("feat"));
297        assert!(c.is_allowed("chore"));
298        assert!(!c.is_allowed("unknown"));
299    }
300
301    #[test]
302    fn classifier_pattern() {
303        let c = DefaultCommitClassifier::default();
304        assert_eq!(c.pattern(), DEFAULT_COMMIT_PATTERN);
305    }
306
307    #[test]
308    fn default_commit_types_count() {
309        let types = default_commit_types();
310        assert_eq!(types.len(), 11);
311    }
312
313    #[test]
314    fn commit_type_serialization_roundtrip() {
315        let ct = CommitType {
316            name: "feat".into(),
317            bump: Some(BumpLevel::Minor),
318            section: Some("Features".into()),
319        };
320        let yaml = serde_yaml_ng::to_string(&ct).unwrap();
321        let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
322        assert_eq!(parsed, ct);
323    }
324
325    #[test]
326    fn commit_type_no_bump_no_section_roundtrip() {
327        let ct = CommitType {
328            name: "chore".into(),
329            bump: None,
330            section: None,
331        };
332        let yaml = serde_yaml_ng::to_string(&ct).unwrap();
333        assert!(!yaml.contains("bump"));
334        assert!(!yaml.contains("section"));
335        let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
336        assert_eq!(parsed, ct);
337    }
338}