conventional_commit_parser/
commit.rs1use std::fmt;
2use std::fmt::Formatter;
3
4use pest::iterators::Pair;
5
6use crate::commit::CommitType::*;
7use crate::Rule;
8
9#[derive(Hash, Eq, PartialEq, Ord, PartialOrd, Debug, Clone)]
14pub enum CommitType {
15 Feature,
17 BugFix,
19 Chore,
21 Revert,
23 Performances,
25 Documentation,
27 Style,
29 Refactor,
31 Test,
33 Build,
35 Ci,
37 Custom(String),
39}
40
41#[derive(Debug, Eq, PartialEq, Default, Clone)]
44pub struct Footer {
45 pub token: String,
47 pub content: String,
49 pub token_separator: Separator,
51}
52
53#[derive(Debug, Eq, PartialEq, Clone)]
56pub enum Separator {
57 Colon,
58 ColonWithNewLine,
59 Hash,
60}
61
62impl From<&str> for Separator {
63 fn from(separator: &str) -> Self {
64 match separator {
65 ": " => Separator::Colon,
66 " #" => Separator::Hash,
67 ":\n" | ":\r" | ":\r\n" => Separator::ColonWithNewLine,
68 other => unreachable!("Unexpected footer token separator : `{}`", other),
69 }
70 }
71}
72
73impl Default for Separator {
74 fn default() -> Self {
75 Separator::Colon
76 }
77}
78
79impl Footer {
80 pub fn is_breaking_change(&self) -> bool {
102 self.token == "BREAKING CHANGE" || self.token == "BREAKING-CHANGE"
103 }
104}
105
106#[derive(Debug, Eq, PartialEq, Clone)]
110pub struct ConventionalCommit {
111 pub commit_type: CommitType,
113 pub scope: Option<String>,
115 pub summary: String,
117 pub body: Option<String>,
119 pub footers: Vec<Footer>,
121 pub is_breaking_change: bool,
123}
124
125impl From<Pair<'_, Rule>> for Footer {
126 fn from(pairs: Pair<'_, Rule>) -> Self {
127 let mut pair = pairs.into_inner();
128 let token = pair.next().unwrap().as_str().to_string();
129 let separator = pair.next().unwrap().as_str();
130 let token_separator = Separator::from(separator);
131 let content = pair.next().unwrap().as_str().to_string().trim().to_string();
132
133 Footer {
134 token,
135 content,
136 token_separator,
137 }
138 }
139}
140
141impl Default for ConventionalCommit {
142 fn default() -> Self {
143 ConventionalCommit {
144 commit_type: Feature,
145 scope: None,
146 body: None,
147 footers: vec![],
148 summary: "".to_string(),
149 is_breaking_change: false,
150 }
151 }
152}
153
154impl ConventionalCommit {
155 pub(crate) fn set_summary(&mut self, pair: Pair<Rule>) {
156 for pair in pair.into_inner() {
157 match pair.as_rule() {
158 Rule::commit_type => self.set_commit_type(&pair),
159 Rule::scope => self.set_scope(pair),
160 Rule::summary_content => self.set_summary_content(pair),
161 Rule::breaking_change_mark => self.set_breaking_change(pair),
162 _other => (),
163 }
164 }
165 }
166
167 fn set_breaking_change(&mut self, pair: Pair<Rule>) {
168 if !pair.as_str().is_empty() {
169 self.is_breaking_change = true
170 }
171 }
172
173 fn set_summary_content(&mut self, pair: Pair<Rule>) {
174 let summary = pair.as_str();
175 self.summary = summary.to_string();
176 }
177
178 fn set_scope(&mut self, pair: Pair<Rule>) {
179 if let Some(scope) = pair.into_inner().next() {
180 let scope = scope.as_str();
181 if !scope.is_empty() {
182 self.scope = Some(scope.to_string())
183 }
184 };
185 }
186
187 pub fn set_commit_type(&mut self, pair: &Pair<Rule>) {
188 let commit_type = pair.as_str();
189 let commit_type = CommitType::from(commit_type);
190 self.commit_type = commit_type;
191 }
192
193 pub(crate) fn set_commit_body(&mut self, pair: Pair<Rule>) {
194 let body = pair.as_str().trim();
195 if !body.is_empty() {
196 self.body = Some(body.to_string())
197 }
198 }
199
200 pub(crate) fn set_footers(&mut self, pair: Pair<Rule>) {
201 for footer in pair.into_inner() {
202 self.set_footer(footer);
203 }
204 }
205
206 fn set_footer(&mut self, footer: Pair<Rule>) {
207 let footer = Footer::from(footer);
208
209 if footer.is_breaking_change() {
210 self.is_breaking_change = true;
211 }
212
213 self.footers.push(footer);
214 }
215}
216
217impl From<&str> for CommitType {
218 fn from(commit_type: &str) -> Self {
219 match commit_type.to_ascii_lowercase().as_str() {
220 "feat" => Feature,
221 "fix" => BugFix,
222 "chore" => Chore,
223 "revert" => Revert,
224 "perf" => Performances,
225 "docs" => Documentation,
226 "style" => Style,
227 "refactor" => Refactor,
228 "test" => Test,
229 "build" => Build,
230 "ci" => Ci,
231 other => Custom(other.to_string()),
232 }
233 }
234}
235
236impl Default for CommitType {
237 fn default() -> Self {
238 CommitType::Chore
239 }
240}
241
242impl AsRef<str> for CommitType {
243 fn as_ref(&self) -> &str {
244 match self {
245 Feature => "feat",
246 BugFix => "fix",
247 Chore => "chore",
248 Revert => "revert",
249 Performances => "perf",
250 Documentation => "docs",
251 Style => "style",
252 Refactor => "refactor",
253 Test => "test",
254 Build => "build",
255 Ci => "ci",
256 Custom(key) => key,
257 }
258 }
259}
260
261impl ToString for ConventionalCommit {
262 fn to_string(&self) -> String {
263 let mut message = String::new();
264 message.push_str(self.commit_type.as_ref());
265
266 if let Some(scope) = &self.scope {
267 message.push_str(&format!("({})", scope));
268 }
269
270 let has_breaking_change_footer = self.footers.iter().any(|f| f.is_breaking_change());
271
272 if self.is_breaking_change && !has_breaking_change_footer {
273 message.push('!');
274 }
275
276 message.push_str(&format!(": {}", &self.summary));
277
278 if let Some(body) = &self.body {
279 message.push_str(&format!("\n\n{}", body));
280 }
281
282 if !self.footers.is_empty() {
283 message.push('\n');
284 }
285
286 self.footers
287 .iter()
288 .for_each(|footer| match footer.token_separator {
289 Separator::Colon => {
290 message.push_str(&format!("\n{}: {}", footer.token, footer.content))
291 }
292 Separator::Hash => {
293 message.push_str(&format!("\n{} #{}", footer.token, footer.content))
294 }
295 Separator::ColonWithNewLine => {
296 message.push_str(&format!("\n{}:\n{}", footer.token, footer.content))
297 }
298 });
299
300 message
301 }
302}
303
304impl fmt::Display for CommitType {
305 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
306 write!(f, "{}", self.as_ref())
307 }
308}
309
310#[cfg(test)]
311mod test {
312 use indoc::indoc;
313 use speculoos::assert_that;
314 use speculoos::prelude::ResultAssertions;
315
316 use crate::commit::{CommitType, ConventionalCommit, Footer, Separator};
317 use crate::parse;
318
319 #[test]
320 fn commit_to_string_ok() {
321 let commit = ConventionalCommit {
322 commit_type: CommitType::Feature,
323 scope: None,
324 summary: "a feature".to_string(),
325 body: None,
326 footers: Vec::with_capacity(0),
327 is_breaking_change: false,
328 };
329
330 let expected = "feat: a feature".to_string();
331
332 assert_that(&commit.to_string()).is_equal_to(expected);
333 let parsed = parse(&commit.to_string());
334 assert_that(&parsed).is_ok().is_equal_to(commit);
335 }
336
337 #[test]
338 fn commit_to_with_footer_only_string_ok() {
339 let commit = ConventionalCommit {
340 commit_type: CommitType::Chore,
341 scope: None,
342 summary: "a commit".to_string(),
343 body: None,
344 footers: vec![Footer {
345 token: "BREAKING CHANGE".to_string(),
346 content: "message".to_string(),
347 ..Default::default()
348 }],
349 is_breaking_change: true,
350 };
351
352 let expected = indoc!(
353 "chore: a commit
354
355 BREAKING CHANGE: message"
356 )
357 .to_string();
358
359 assert_that(&commit.to_string()).is_equal_to(expected);
360 let parsed = parse(&commit.to_string());
361 assert_that(&parsed).is_ok().is_equal_to(commit);
362 }
363
364 #[test]
365 fn commit_with_body_only_and_breaking_change() {
366 let commit = ConventionalCommit {
367 commit_type: CommitType::Chore,
368 scope: None,
369 summary: "a commit".to_string(),
370 body: Some("A breaking change body on\nmultiple lines".to_string()),
371 footers: Vec::with_capacity(0),
372 is_breaking_change: true,
373 };
374
375 let expected = indoc!(
376 "chore!: a commit
377
378 A breaking change body on
379 multiple lines"
380 )
381 .to_string();
382
383 assert_that(&commit.to_string()).is_equal_to(expected);
384 let parsed = parse(&commit.to_string());
385 assert_that(&parsed).is_ok().is_equal_to(commit);
386 }
387
388 #[test]
389 fn full_commit_to_string() {
390 let commit = ConventionalCommit {
391 commit_type: CommitType::BugFix,
392 scope: Some("code".to_string()),
393 summary: "correct minor typos in code".to_string(),
394 body: Some(
395 indoc!(
396 "see the issue for details
397
398 on typos fixed."
399 )
400 .to_string(),
401 ),
402 footers: vec![
403 Footer {
404 token: "Reviewed-by".to_string(),
405 content: "Z".to_string(),
406 ..Default::default()
407 },
408 Footer {
409 token: "Refs".to_string(),
410 content: "133".to_string(),
411 token_separator: Separator::Hash,
412 },
413 ],
414 is_breaking_change: false,
415 };
416
417 let expected = indoc!(
418 "fix(code): correct minor typos in code
419
420 see the issue for details
421
422 on typos fixed.
423
424 Reviewed-by: Z
425 Refs #133"
426 )
427 .to_string();
428
429 assert_that(&commit.to_string()).is_equal_to(expected);
430 let parsed = parse(&commit.to_string());
431
432 assert_that(&parsed).is_ok().is_equal_to(commit);
433 }
434}