1use regex::Regex;
2use serde::{Deserialize, Serialize};
3
4use crate::error::ReleaseError;
5use crate::version::BumpLevel;
6
7#[derive(Debug, Clone)]
9pub struct Commit {
10 pub sha: String,
11 pub message: String,
12}
13
14#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub section: Option<String>,
34}
35
36pub trait CommitClassifier: Send + Sync {
38 fn types(&self) -> &[CommitType];
39
40 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
63pub 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
154pub trait CommitParser: Send + Sync {
156 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
157}
158
159pub 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 #[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}