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 pub author: Option<String>,
13}
14
15#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub section: Option<String>,
36}
37
38pub trait CommitClassifier: Send + Sync {
40 fn types(&self) -> &[CommitType];
41
42 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
65pub 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
156pub trait CommitParser: Send + Sync {
158 fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
159}
160
161pub 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 #[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}