Skip to main content

sr_core/
commit.rs

1use regex::Regex;
2use serde::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/// Internal commit type representation — name + bump level.
26#[derive(Debug, Clone, PartialEq)]
27pub struct CommitType {
28    pub name: String,
29    pub bump: Option<BumpLevel>,
30}
31
32/// Build the conventional commit regex from configured type names.
33///
34/// Produces: `^(?P<type>feat|fix|...)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)`
35pub fn build_commit_pattern(type_names: &[&str]) -> String {
36    let types_alternation = type_names.join("|");
37    format!(
38        r"^(?P<type>{types_alternation})(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)"
39    )
40}
41
42/// Single source of truth for commit type classification.
43pub trait CommitClassifier: Send + Sync {
44    fn types(&self) -> &[CommitType];
45    fn pattern(&self) -> &str;
46
47    fn bump_level(&self, type_name: &str, breaking: bool) -> Option<BumpLevel> {
48        if breaking {
49            return Some(BumpLevel::Major);
50        }
51        self.types().iter().find(|t| t.name == type_name)?.bump
52    }
53
54    fn is_allowed(&self, type_name: &str) -> bool {
55        self.types().iter().any(|t| t.name == type_name)
56    }
57}
58
59pub struct DefaultCommitClassifier {
60    types: Vec<CommitType>,
61    pattern: String,
62}
63
64impl DefaultCommitClassifier {
65    pub fn new(types: Vec<CommitType>) -> Self {
66        let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
67        let pattern = build_commit_pattern(&names);
68        Self { types, pattern }
69    }
70}
71
72impl Default for DefaultCommitClassifier {
73    fn default() -> Self {
74        Self::new(default_commit_types())
75    }
76}
77
78impl CommitClassifier for DefaultCommitClassifier {
79    fn types(&self) -> &[CommitType] {
80        &self.types
81    }
82    fn pattern(&self) -> &str {
83        &self.pattern
84    }
85}
86
87pub fn default_commit_types() -> Vec<CommitType> {
88    use crate::config::CommitTypesConfig;
89    CommitTypesConfig::default().into_commit_types()
90}
91
92/// Parses raw commits into conventional commits.
93pub trait CommitParser: Send + Sync {
94    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
95}
96
97/// Parser that builds its pattern from configured type names.
98pub struct TypedCommitParser {
99    pattern: String,
100}
101
102impl TypedCommitParser {
103    pub fn new(type_names: &[&str]) -> Self {
104        Self {
105            pattern: build_commit_pattern(type_names),
106        }
107    }
108
109    pub fn from_types(types: &[CommitType]) -> Self {
110        let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
111        Self::new(&names)
112    }
113}
114
115impl Default for TypedCommitParser {
116    fn default() -> Self {
117        Self::from_types(&default_commit_types())
118    }
119}
120
121impl CommitParser for TypedCommitParser {
122    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
123        let re = Regex::new(&self.pattern).map_err(|e| ReleaseError::Config(e.to_string()))?;
124
125        let first_line = commit.message.lines().next().unwrap_or("");
126
127        let caps = re.captures(first_line).ok_or_else(|| {
128            ReleaseError::Config(format!("not a conventional commit: {}", commit.message))
129        })?;
130
131        let r#type = caps.name("type").unwrap().as_str().to_string();
132        let scope = caps.name("scope").map(|m| m.as_str().to_string());
133        let breaking = caps.name("breaking").is_some();
134        let description = caps.name("description").unwrap().as_str().to_string();
135
136        let body = commit
137            .message
138            .split_once("\n\n")
139            .map(|x| x.1)
140            .map(|b| b.to_string());
141
142        // Detect BREAKING CHANGE: / BREAKING-CHANGE: footers in the body.
143        let breaking = breaking
144            || body.as_deref().is_some_and(|b| {
145                b.lines().any(|line| {
146                    line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:")
147                })
148            });
149
150        Ok(ConventionalCommit {
151            sha: commit.sha.clone(),
152            r#type,
153            scope,
154            description,
155            body,
156            breaking,
157        })
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn raw(message: &str) -> Commit {
166        Commit {
167            sha: "abc1234".into(),
168            message: message.into(),
169        }
170    }
171
172    fn parser() -> TypedCommitParser {
173        TypedCommitParser::default()
174    }
175
176    #[test]
177    fn build_pattern_from_types() {
178        let pattern = build_commit_pattern(&["feat", "fix", "chore"]);
179        assert!(pattern.contains("feat|fix|chore"));
180        let re = Regex::new(&pattern).unwrap();
181        assert!(re.is_match("feat: add button"));
182        assert!(re.is_match("fix(core): null check"));
183        assert!(!re.is_match("unknown: something"));
184    }
185
186    #[test]
187    fn parse_simple_feat() {
188        let result = parser().parse(&raw("feat: add button")).unwrap();
189        assert_eq!(result.r#type, "feat");
190        assert_eq!(result.description, "add button");
191        assert_eq!(result.scope, None);
192        assert!(!result.breaking);
193    }
194
195    #[test]
196    fn parse_scoped_fix() {
197        let result = parser().parse(&raw("fix(core): null check")).unwrap();
198        assert_eq!(result.r#type, "fix");
199        assert_eq!(result.scope.as_deref(), Some("core"));
200    }
201
202    #[test]
203    fn parse_breaking_bang() {
204        let result = parser().parse(&raw("feat!: new API")).unwrap();
205        assert!(result.breaking);
206    }
207
208    #[test]
209    fn parse_with_body() {
210        let result = parser().parse(&raw("fix: x\n\ndetails")).unwrap();
211        assert_eq!(result.body.as_deref(), Some("details"));
212    }
213
214    #[test]
215    fn parse_breaking_change_footer() {
216        let result = parser()
217            .parse(&raw(
218                "feat: new API\n\nBREAKING CHANGE: removed old endpoint",
219            ))
220            .unwrap();
221        assert!(result.breaking);
222    }
223
224    #[test]
225    fn parse_breaking_change_hyphenated_footer() {
226        let result = parser()
227            .parse(&raw("fix: update schema\n\nBREAKING-CHANGE: field renamed"))
228            .unwrap();
229        assert!(result.breaking);
230    }
231
232    #[test]
233    fn parse_no_breaking_change_in_body() {
234        let result = parser()
235            .parse(&raw("fix: tweak\n\nThis is not a BREAKING CHANGE footer"))
236            .unwrap();
237        assert!(!result.breaking);
238    }
239
240    #[test]
241    fn parse_no_breaking_change_indented_bullet() {
242        let result = parser()
243            .parse(&raw(
244                "feat(mcp): add breaking flag\n\n- add `breaking` field — sets \"!\" and adds\n  BREAKING CHANGE footer automatically",
245            ))
246            .unwrap();
247        assert!(!result.breaking);
248    }
249
250    #[test]
251    fn parse_invalid_message() {
252        let result = parser().parse(&raw("not conventional"));
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn parse_unknown_type_rejected() {
258        let result = parser().parse(&raw("unknown: something"));
259        assert!(result.is_err());
260    }
261
262    // --- CommitClassifier tests ---
263
264    #[test]
265    fn classifier_bump_level_feat() {
266        let c = DefaultCommitClassifier::default();
267        assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
268    }
269
270    #[test]
271    fn classifier_bump_level_fix() {
272        let c = DefaultCommitClassifier::default();
273        assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
274    }
275
276    #[test]
277    fn classifier_bump_level_breaking_overrides() {
278        let c = DefaultCommitClassifier::default();
279        assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
280        assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
281    }
282
283    #[test]
284    fn classifier_bump_level_no_bump_type() {
285        let c = DefaultCommitClassifier::default();
286        assert_eq!(c.bump_level("chore", false), None);
287        assert_eq!(c.bump_level("docs", false), None);
288    }
289
290    #[test]
291    fn classifier_is_allowed() {
292        let c = DefaultCommitClassifier::default();
293        assert!(c.is_allowed("feat"));
294        assert!(c.is_allowed("chore"));
295        assert!(!c.is_allowed("unknown"));
296    }
297
298    #[test]
299    fn classifier_pattern_built_from_types() {
300        let c = DefaultCommitClassifier::default();
301        assert!(c.pattern().contains("feat"));
302        assert!(c.pattern().contains("fix"));
303        assert!(c.pattern().contains("chore"));
304    }
305
306    #[test]
307    fn default_commit_types_count() {
308        let types = default_commit_types();
309        assert_eq!(types.len(), 11);
310    }
311}