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        Ok(ConventionalCommit {
183            sha: commit.sha.clone(),
184            r#type,
185            scope,
186            description,
187            body,
188            breaking,
189        })
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn raw(message: &str) -> Commit {
198        Commit {
199            sha: "abc1234".into(),
200            message: message.into(),
201        }
202    }
203
204    #[test]
205    fn parse_simple_feat() {
206        let result = DefaultCommitParser.parse(&raw("feat: add button")).unwrap();
207        assert_eq!(result.r#type, "feat");
208        assert_eq!(result.description, "add button");
209        assert_eq!(result.scope, None);
210        assert!(!result.breaking);
211    }
212
213    #[test]
214    fn parse_scoped_fix() {
215        let result = DefaultCommitParser
216            .parse(&raw("fix(core): null check"))
217            .unwrap();
218        assert_eq!(result.r#type, "fix");
219        assert_eq!(result.scope.as_deref(), Some("core"));
220    }
221
222    #[test]
223    fn parse_breaking_bang() {
224        let result = DefaultCommitParser.parse(&raw("feat!: new API")).unwrap();
225        assert!(result.breaking);
226    }
227
228    #[test]
229    fn parse_with_body() {
230        let result = DefaultCommitParser
231            .parse(&raw("fix: x\n\ndetails"))
232            .unwrap();
233        assert_eq!(result.body.as_deref(), Some("details"));
234    }
235
236    #[test]
237    fn parse_invalid_message() {
238        let result = DefaultCommitParser.parse(&raw("not conventional"));
239        assert!(result.is_err());
240    }
241
242    // --- CommitClassifier tests ---
243
244    #[test]
245    fn classifier_bump_level_feat() {
246        let c = DefaultCommitClassifier::default();
247        assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
248    }
249
250    #[test]
251    fn classifier_bump_level_fix() {
252        let c = DefaultCommitClassifier::default();
253        assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
254    }
255
256    #[test]
257    fn classifier_bump_level_breaking_overrides() {
258        let c = DefaultCommitClassifier::default();
259        assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
260        assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
261    }
262
263    #[test]
264    fn classifier_bump_level_no_bump_type() {
265        let c = DefaultCommitClassifier::default();
266        assert_eq!(c.bump_level("chore", false), None);
267        assert_eq!(c.bump_level("docs", false), None);
268    }
269
270    #[test]
271    fn classifier_bump_level_unknown_type() {
272        let c = DefaultCommitClassifier::default();
273        assert_eq!(c.bump_level("unknown", false), None);
274    }
275
276    #[test]
277    fn classifier_changelog_section() {
278        let c = DefaultCommitClassifier::default();
279        assert_eq!(c.changelog_section("feat"), Some("Features"));
280        assert_eq!(c.changelog_section("fix"), Some("Bug Fixes"));
281        assert_eq!(c.changelog_section("perf"), Some("Performance"));
282        assert_eq!(c.changelog_section("docs"), Some("Documentation"));
283        assert_eq!(c.changelog_section("refactor"), Some("Refactoring"));
284        assert_eq!(c.changelog_section("revert"), Some("Reverts"));
285        assert_eq!(c.changelog_section("chore"), None);
286        assert_eq!(c.changelog_section("unknown"), None);
287    }
288
289    #[test]
290    fn classifier_is_allowed() {
291        let c = DefaultCommitClassifier::default();
292        assert!(c.is_allowed("feat"));
293        assert!(c.is_allowed("chore"));
294        assert!(!c.is_allowed("unknown"));
295    }
296
297    #[test]
298    fn classifier_pattern() {
299        let c = DefaultCommitClassifier::default();
300        assert_eq!(c.pattern(), DEFAULT_COMMIT_PATTERN);
301    }
302
303    #[test]
304    fn default_commit_types_count() {
305        let types = default_commit_types();
306        assert_eq!(types.len(), 11);
307    }
308
309    #[test]
310    fn commit_type_serialization_roundtrip() {
311        let ct = CommitType {
312            name: "feat".into(),
313            bump: Some(BumpLevel::Minor),
314            section: Some("Features".into()),
315        };
316        let yaml = serde_yaml_ng::to_string(&ct).unwrap();
317        let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
318        assert_eq!(parsed, ct);
319    }
320
321    #[test]
322    fn commit_type_no_bump_no_section_roundtrip() {
323        let ct = CommitType {
324            name: "chore".into(),
325            bump: None,
326            section: None,
327        };
328        let yaml = serde_yaml_ng::to_string(&ct).unwrap();
329        assert!(!yaml.contains("bump"));
330        assert!(!yaml.contains("section"));
331        let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
332        assert_eq!(parsed, ct);
333    }
334}