1use std::sync::LazyLock;
2
3use git_conventional::{Commit as ConventionalCommit, Footer as ConventionalFooter};
4#[cfg(feature = "repo")]
5use git2::{Commit as GitCommit, Signature as CommitSignature};
6use regex::Regex;
7use serde::ser::{SerializeStruct, Serializer};
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::value::Value;
10
11use crate::config::{CommitParser, GitConfig, LinkParser, TextProcessor};
12use crate::error::{Error as AppError, Result};
13
14static SHA1_REGEX: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"^\b([a-f0-9]{40})\b (.*)$").expect("valid SHA1 regex"));
19
20#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
22#[serde(rename_all(serialize = "camelCase"))]
23pub struct Link {
24 pub text: String,
26 pub href: String,
28}
29
30#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
32struct Footer<'a> {
33 token: &'a str,
38 separator: &'a str,
42 value: &'a str,
44 breaking: bool,
46}
47
48impl<'a> From<&'a ConventionalFooter<'a>> for Footer<'a> {
49 fn from(footer: &'a ConventionalFooter<'a>) -> Self {
50 Self {
51 token: footer.token().as_str(),
52 separator: footer.separator().as_str(),
53 value: footer.value(),
54 breaking: footer.breaking(),
55 }
56 }
57}
58
59#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
61pub struct Signature {
62 pub name: Option<String>,
64 pub email: Option<String>,
66 pub timestamp: i64,
68}
69
70#[cfg(feature = "repo")]
71impl<'a> From<CommitSignature<'a>> for Signature {
72 fn from(signature: CommitSignature<'a>) -> Self {
73 Self {
74 name: signature.name().ok().map(String::from),
75 email: signature.email().ok().map(String::from),
76 timestamp: signature.when().seconds(),
77 }
78 }
79}
80
81#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
83pub struct CommitStatistics {
84 pub files_changed: usize,
86 pub additions: usize,
88 pub deletions: usize,
90}
91
92#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Range {
95 from: String,
97 to: String,
99}
100
101impl Range {
102 #[must_use]
104 pub fn new(from: &Commit, to: &Commit) -> Self {
105 Self {
106 from: from.id.clone(),
107 to: to.id.clone(),
108 }
109 }
110}
111
112#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
114#[serde(rename_all(serialize = "camelCase"))]
115pub struct Commit<'a> {
116 pub id: String,
118 pub message: String,
120 #[serde(skip_deserializing)]
122 pub conv: Option<ConventionalCommit<'a>>,
123 pub group: Option<String>,
125 pub default_scope: Option<String>,
128 pub scope: Option<String>,
130 pub links: Vec<Link>,
132 pub author: Signature,
134 pub committer: Signature,
136 pub merge_commit: bool,
138 #[serde(default)]
140 pub statistics: CommitStatistics,
141 pub extra: Option<Value>,
143 pub remote: Option<crate::contributor::RemoteContributor>,
145 #[cfg(feature = "github")]
147 #[deprecated(note = "Use `remote` field instead")]
148 pub github: crate::contributor::RemoteContributor,
149 #[cfg(feature = "gitlab")]
151 #[deprecated(note = "Use `remote` field instead")]
152 pub gitlab: crate::contributor::RemoteContributor,
153 #[cfg(feature = "gitea")]
155 #[deprecated(note = "Use `remote` field instead")]
156 pub gitea: crate::contributor::RemoteContributor,
157 #[cfg(feature = "bitbucket")]
159 #[deprecated(note = "Use `remote` field instead")]
160 pub bitbucket: crate::contributor::RemoteContributor,
161 #[cfg(feature = "azure_devops")]
163 #[deprecated(note = "Use `remote` field instead")]
164 pub azure_devops: crate::contributor::RemoteContributor,
165
166 pub raw_message: Option<String>,
173}
174
175impl From<String> for Commit<'_> {
176 fn from(message: String) -> Self {
177 if let Some(captures) = SHA1_REGEX.captures(&message) &&
178 let (Some(id), Some(message)) = (
179 captures.get(1).map(|v| v.as_str()),
180 captures.get(2).map(|v| v.as_str()),
181 )
182 {
183 return Commit {
184 id: id.to_string(),
185 message: message.to_string(),
186 ..Default::default()
187 };
188 }
189 Commit {
190 id: String::new(),
191 message,
192 ..Default::default()
193 }
194 }
195}
196
197#[cfg(feature = "repo")]
198impl From<&GitCommit<'_>> for Commit<'_> {
199 fn from(commit: &GitCommit<'_>) -> Self {
200 Commit {
201 id: commit.id().to_string(),
202 message: commit.message().unwrap_or_default().trim_end().to_string(),
203 author: commit.author().into(),
204 committer: commit.committer().into(),
205 merge_commit: commit.parent_count() > 1,
206 ..Default::default()
207 }
208 }
209}
210
211impl Commit<'_> {
212 #[must_use]
214 pub fn new(id: String, message: String) -> Self {
215 Self {
216 id,
217 message,
218 ..Default::default()
219 }
220 }
221
222 #[must_use]
224 pub fn raw_message(&self) -> &str {
225 self.raw_message.as_deref().unwrap_or(&self.message)
226 }
227
228 #[cfg_attr(
234 feature = "tracing",
235 tracing::instrument(
236 skip_all,
237 fields(id = self.id)
238 )
239 )]
240 pub fn process(&self, config: &GitConfig) -> Result<Self> {
241 crate::set_progress_message!(
242 "Converting the commit to conventional format, setting its group, and extracting links"
243 );
244 let mut commit = self.clone();
245 commit = commit.preprocess(&config.commit_preprocessors)?;
246 if config.conventional_commits {
247 if !config.require_conventional && config.filter_unconventional && !config.split_commits
248 {
249 commit = commit.into_conventional()?;
250 } else if let Ok(conv_commit) = commit.clone().into_conventional() {
251 commit = conv_commit;
252 }
253 }
254
255 commit = commit.parse(
256 &config.commit_parsers,
257 config.protect_breaking_commits,
258 config.filter_commits,
259 )?;
260
261 commit = commit.parse_links(&config.link_parsers);
262
263 Ok(commit)
264 }
265
266 pub fn into_conventional(mut self) -> Result<Self> {
268 match ConventionalCommit::parse(Box::leak(self.raw_message().to_string().into_boxed_str()))
269 {
270 Ok(conv) => {
271 self.conv = Some(conv);
272 Ok(self)
273 }
274 Err(e) => Err(AppError::ParseError(e)),
275 }
276 }
277
278 #[cfg_attr(
284 feature = "tracing",
285 tracing::instrument(
286 skip_all,
287 fields(id = self.id)
288 )
289 )]
290 pub fn preprocess(mut self, preprocessors: &[TextProcessor]) -> Result<Self> {
291 crate::set_progress_message!("Preprocessing the commit message using text processors");
292 preprocessors.iter().try_for_each(|preprocessor| {
293 preprocessor.replace(&mut self.message, vec![("COMMIT_SHA", &self.id)])?;
294 Ok::<(), AppError>(())
295 })?;
296 Ok(self)
297 }
298
299 fn skip_commit(&self, parser: &CommitParser, protect_breaking: bool) -> bool {
305 parser.skip.unwrap_or(false) &&
306 !(self.conv.as_ref().is_some_and(ConventionalCommit::breaking) && protect_breaking)
307 }
308
309 #[cfg_attr(
316 feature = "tracing",
317 tracing::instrument(
318 skip_all,
319 fields(id = self.id)
320 )
321 )]
322 pub fn parse(
323 mut self,
324 parsers: &[CommitParser],
325 protect_breaking: bool,
326 filter: bool,
327 ) -> Result<Self> {
328 crate::set_progress_message!("Parsing the commit and setting its group and scope");
329 let lookup_context = serde_json::to_value(&self).map_err(|e| {
330 AppError::FieldError(format!("failed to convert context into value: {e}",))
331 })?;
332 let mut matched = false;
335 'parsers: for parser in parsers {
336 if let Some(sha) = parser.sha.as_ref() &&
337 sha.to_lowercase() != self.id
338 {
339 continue 'parsers;
340 }
341 let mut regex_checks = Vec::new();
342 if let Some(message_regex) = parser.message.as_ref() {
343 if !message_regex.is_match(self.message.trim()) {
344 continue 'parsers;
345 }
346 regex_checks.push((message_regex, self.message.clone()));
347 }
348 let body = self
349 .conv
350 .as_ref()
351 .and_then(ConventionalCommit::body)
352 .map(ToString::to_string);
353 if let Some(body_regex) = parser.body.as_ref() {
354 let body_text = body.clone().unwrap_or_default();
355 if !body_regex.is_match(body_text.trim()) {
356 continue 'parsers;
357 }
358 regex_checks.push((body_regex, body_text));
359 }
360 if let Some(footer_regex) = parser.footer.as_ref() {
361 let Some(footers) = self.conv.as_ref().map(ConventionalCommit::footers) else {
362 continue 'parsers;
363 };
364 let Some(matched_footer) = footers
365 .iter()
366 .map(ToString::to_string)
367 .find(|f| footer_regex.is_match(f.trim()))
368 else {
369 continue 'parsers;
370 };
371 regex_checks.push((footer_regex, matched_footer));
372 }
373 if let (Some(field_name), Some(pattern_regex)) =
374 (parser.field.as_ref(), parser.pattern.as_ref())
375 {
376 let values = if field_name == "body" {
377 vec![body.clone()].into_iter().collect()
378 } else {
379 let Some(field_value) = tera::dotted_pointer(&lookup_context, field_name)
380 else {
381 tracing::trace!("Field '{field_name}' is absent; trying the next parser");
382 continue 'parsers;
383 };
384 match field_value {
385 Value::String(s) => Some(vec![s.clone()]),
386 Value::Number(_) | Value::Bool(_) | Value::Null => {
387 Some(vec![field_value.to_string()])
388 }
389 Value::Array(arr) => {
390 let mut values = Vec::new();
391 for item in arr {
392 match item {
393 Value::String(s) => values.push(s.clone()),
394 Value::Number(_) | Value::Bool(_) | Value::Null => {
395 values.push(item.to_string());
396 }
397 _ => {}
398 }
399 }
400 Some(values)
401 }
402 Value::Object(_) => None,
403 }
404 };
405 match values {
406 Some(values) => {
407 if values.is_empty() {
408 tracing::trace!("Field '{field_name}' is present but empty");
409 }
410 let Some(matched_value) = values
411 .into_iter()
412 .find(|v| pattern_regex.is_match(v.trim()))
413 else {
414 continue 'parsers;
415 };
416 regex_checks.push((pattern_regex, matched_value));
417 }
418 None => {
419 return Err(AppError::FieldError(format!(
420 "field '{field_name}' is missing or has unsupported type (expected a \
421 String, Number, Bool, or Null — or an Array of these scalar values)",
422 )));
423 }
424 }
425 }
426 if regex_checks.is_empty() {
427 if parser.sha.is_none() {
428 continue 'parsers;
429 }
430 if self.skip_commit(parser, protect_breaking) {
431 return Err(AppError::GroupError(String::from("Skipping commit")));
432 } else {
433 self.group = parser.group.clone().or(self.group);
434 self.scope = parser.scope.clone().or(self.scope);
435 self.default_scope = parser.default_scope.clone().or(self.default_scope);
436 if parser.r#continue.unwrap_or(false) {
437 matched = true;
438 continue;
439 }
440 return Ok(self);
441 }
442 } else if self.skip_commit(parser, protect_breaking) {
443 return Err(AppError::GroupError(String::from("Skipping commit")));
444 } else {
445 let regex_replace = |mut value: String| {
446 for (regex, text) in ®ex_checks {
447 for mat in regex.find_iter(text) {
448 value = regex.replace(mat.as_str(), value).to_string();
449 }
450 }
451 value
452 };
453 if parser.r#continue.unwrap_or(false) {
454 if let Some(group) = parser.group.clone() {
457 self.group = Some(regex_replace(group));
458 }
459 if let Some(scope) = parser.scope.clone() {
460 self.scope = Some(regex_replace(scope));
461 }
462 if parser.default_scope.is_some() {
463 self.default_scope.clone_from(&parser.default_scope);
464 }
465 matched = true;
466 continue 'parsers;
467 }
468 if matched {
469 self.group = parser.group.clone().map(regex_replace).or(self.group);
471 self.scope = parser.scope.clone().map(regex_replace).or(self.scope);
472 if parser.default_scope.is_some() {
473 self.default_scope.clone_from(&parser.default_scope);
474 }
475 } else {
476 self.group = parser.group.clone().map(regex_replace);
479 self.scope = parser.scope.clone().map(regex_replace);
480 self.default_scope.clone_from(&parser.default_scope);
481 }
482 return Ok(self);
483 }
484 }
485 if filter && !matched {
486 Err(AppError::GroupError(String::from(
487 "Commit does not belong to any group",
488 )))
489 } else {
490 Ok(self)
491 }
492 }
493
494 #[must_use]
500 #[cfg_attr(
501 feature = "tracing",
502 tracing::instrument(
503 skip_all,
504 fields(id = self.id)
505 )
506 )]
507 pub fn parse_links(mut self, parsers: &[LinkParser]) -> Self {
508 crate::set_progress_message!("Parsing links for the commit using link parsers");
509 for parser in parsers {
510 let regex = &parser.pattern;
511 let replace = &parser.href;
512 for mat in regex.find_iter(&self.message) {
513 let m = mat.as_str();
514 let text = if let Some(text_replace) = &parser.text {
515 regex.replace(m, text_replace).to_string()
516 } else {
517 m.to_string()
518 };
519 let href = regex.replace(m, replace);
520 self.links.push(Link {
521 text,
522 href: href.to_string(),
523 });
524 }
525 }
526 self
527 }
528
529 fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
534 self.conv
535 .iter()
536 .flat_map(|conv| conv.footers().iter().map(Footer::from))
537 }
538}
539
540impl Serialize for Commit<'_> {
541 #[allow(deprecated)]
542 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
543 where
544 S: Serializer,
545 {
546 struct SerializeFooters<'a>(&'a Commit<'a>);
550 impl Serialize for SerializeFooters<'_> {
551 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
552 where
553 S: Serializer,
554 {
555 serializer.collect_seq(self.0.footers())
556 }
557 }
558
559 let mut commit = serializer.serialize_struct("Commit", 21)?;
560 commit.serialize_field("id", &self.id)?;
561 if let Some(conv) = &self.conv {
562 commit.serialize_field("message", conv.description())?;
563 commit.serialize_field("body", &conv.body())?;
564 commit.serialize_field("footers", &SerializeFooters(self))?;
565 commit.serialize_field(
566 "group",
567 self.group.as_ref().unwrap_or(&conv.type_().to_string()),
568 )?;
569 commit.serialize_field("breaking_description", &conv.breaking_description())?;
570 commit.serialize_field("breaking", &conv.breaking())?;
571 commit.serialize_field(
572 "scope",
573 &self
574 .scope
575 .as_deref()
576 .or_else(|| conv.scope().map(|v| v.as_str()))
577 .or(self.default_scope.as_deref()),
578 )?;
579 } else {
580 commit.serialize_field("message", &self.message)?;
581 commit.serialize_field("group", &self.group)?;
582 commit.serialize_field(
583 "scope",
584 &self.scope.as_deref().or(self.default_scope.as_deref()),
585 )?;
586 }
587
588 commit.serialize_field("links", &self.links)?;
589 commit.serialize_field("author", &self.author)?;
590 commit.serialize_field("committer", &self.committer)?;
591 commit.serialize_field("conventional", &self.conv.is_some())?;
592 commit.serialize_field("merge_commit", &self.merge_commit)?;
593 commit.serialize_field("statistics", &self.statistics)?;
594 commit.serialize_field("extra", &self.extra)?;
595 #[cfg(feature = "github")]
596 commit.serialize_field("github", &self.github)?;
597 #[cfg(feature = "gitlab")]
598 commit.serialize_field("gitlab", &self.gitlab)?;
599 #[cfg(feature = "gitea")]
600 commit.serialize_field("gitea", &self.gitea)?;
601 #[cfg(feature = "bitbucket")]
602 commit.serialize_field("bitbucket", &self.bitbucket)?;
603 #[cfg(feature = "azure_devops")]
604 commit.serialize_field("azure_devops", &self.azure_devops)?;
605 if let Some(remote) = &self.remote {
606 commit.serialize_field("remote", remote)?;
607 }
608 commit.serialize_field("raw_message", &self.raw_message())?;
609 commit.end()
610 }
611}
612
613#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
621pub(crate) fn commits_to_conventional_commits<'de, 'a, D: Deserializer<'de>>(
622 deserializer: D,
623) -> std::result::Result<Vec<Commit<'a>>, D::Error> {
624 crate::set_progress_message!("Converting commits to conventional commits");
625 let commits = Vec::<Commit<'a>>::deserialize(deserializer)?;
626 let commits = commits
627 .into_iter()
628 .map(|commit| commit.clone().into_conventional().unwrap_or(commit))
629 .collect();
630 Ok(commits)
631}
632
633#[cfg(test)]
634mod test {
635 use super::*;
636
637 #[test]
638 fn conventional_commit() -> Result<()> {
639 let test_cases = vec![
640 (
641 Commit::new(
642 String::from("123123"),
643 String::from("test(commit): add test"),
644 ),
645 true,
646 ),
647 (
648 Commit::new(String::from("124124"), String::from("xyz")),
649 false,
650 ),
651 ];
652
653 for (commit, is_conventional) in &test_cases {
654 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
655 }
656
657 let commit = test_cases[0].0.clone().parse(
658 &[CommitParser {
659 sha: None,
660 message: Regex::new("test*").ok(),
661 body: None,
662 footer: None,
663 group: Some(String::from("test_group")),
664 default_scope: Some(String::from("test_scope")),
665 scope: None,
666 skip: None,
667 r#continue: None,
668 field: None,
669 pattern: None,
670 }],
671 false,
672 false,
673 )?;
674 assert_eq!(Some(String::from("test_group")), commit.group);
675 assert_eq!(Some(String::from("test_scope")), commit.default_scope);
676
677 Ok(())
678 }
679
680 #[test]
681 fn conventional_footers() {
682 let cfg = crate::config::GitConfig {
683 conventional_commits: true,
684 ..Default::default()
685 };
686 let test_cases = vec![
687 (
688 Commit::new(
689 String::from("123123"),
690 String::from(
691 "test(commit): add test\n\nSigned-off-by: Test User <test@example.com>",
692 ),
693 ),
694 vec![Footer {
695 token: "Signed-off-by",
696 separator: ":",
697 value: "Test User <test@example.com>",
698 breaking: false,
699 }],
700 ),
701 (
702 Commit::new(
703 String::from("123124"),
704 String::from(
705 "fix(commit): break stuff\n\nBREAKING CHANGE: This commit breaks \
706 stuff\nSigned-off-by: Test User <test@example.com>",
707 ),
708 ),
709 vec![
710 Footer {
711 token: "BREAKING CHANGE",
712 separator: ":",
713 value: "This commit breaks stuff",
714 breaking: true,
715 },
716 Footer {
717 token: "Signed-off-by",
718 separator: ":",
719 value: "Test User <test@example.com>",
720 breaking: false,
721 },
722 ],
723 ),
724 ];
725
726 for (commit, footers) in &test_cases {
727 let commit = commit.process(&cfg).expect("commit should process");
728 assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
729 }
730 }
731
732 #[test]
733 fn parse_link() -> Result<()> {
734 let test_cases = vec![
735 (
736 Commit::new(
737 String::from("123123"),
738 String::from("test(commit): add test\n\nBody with issue #123"),
739 ),
740 true,
741 ),
742 (
743 Commit::new(
744 String::from("123123"),
745 String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #456"),
746 ),
747 true,
748 ),
749 ];
750
751 for (commit, is_conventional) in &test_cases {
752 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
753 }
754
755 let commit = Commit::new(
756 String::from("123123"),
757 String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"),
758 );
759
760 let commit = commit.parse_links(&[
761 LinkParser {
762 pattern: Regex::new("RFC(\\d+)")?,
763 href: String::from("rfc://$1"),
764 text: None,
765 },
766 LinkParser {
767 pattern: Regex::new("#(\\d+)")?,
768 href: String::from("https://github.com/$1"),
769 text: None,
770 },
771 ]);
772 assert_eq!(
773 vec![
774 Link {
775 text: String::from("RFC456"),
776 href: String::from("rfc://456"),
777 },
778 Link {
779 text: String::from("#455"),
780 href: String::from("https://github.com/455"),
781 }
782 ],
783 commit.links
784 );
785
786 Ok(())
787 }
788
789 #[test]
790 fn parse_commit() {
791 assert_eq!(
792 Commit::new(String::new(), String::from("test: no sha1 given")),
793 Commit::from(String::from("test: no sha1 given"))
794 );
795
796 assert_eq!(
797 Commit::new(
798 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
799 String::from("feat: do something")
800 ),
801 Commit::from(String::from(
802 "8f55e69eba6e6ce811ace32bd84cc82215673cb6 feat: do something"
803 ))
804 );
805
806 assert_eq!(
807 Commit::new(
808 String::from("3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853"),
809 String::from("chore: do something")
810 ),
811 Commit::from(String::from(
812 "3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853 chore: do something"
813 ))
814 );
815
816 assert_eq!(
817 Commit::new(
818 String::new(),
819 String::from("thisisinvalidsha1 style: add formatting")
820 ),
821 Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
822 );
823 }
824
825 #[test]
826 fn parse_body() -> Result<()> {
827 let mut commit = Commit::new(
828 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
829 String::from(
830 "fix: do something
831
832Introduce something great
833
834BREAKING CHANGE: drop support for something else
835Refs: #123
836",
837 ),
838 );
839 commit.author = Signature {
840 name: Some("John Doe".to_string()),
841 email: None,
842 timestamp: 0x0,
843 };
844 commit.remote = Some(crate::contributor::RemoteContributor {
845 username: None,
846 pr_author: None,
847 pr_title: Some("feat: do something".to_string()),
848 pr_number: None,
849 pr_numbers: vec![],
850 pr_labels: vec![String::from("feature"), String::from("deprecation")],
851 is_first_time: true,
852 });
853 let commit = commit.into_conventional()?;
854 let commit = commit.parse_links(&[
855 LinkParser {
856 pattern: Regex::new("RFC(\\d+)")?,
857 href: String::from("rfc://$1"),
858 text: None,
859 },
860 LinkParser {
861 pattern: Regex::new("#(\\d+)")?,
862 href: String::from("https://github.com/$1"),
863 text: None,
864 },
865 ]);
866
867 let parsed_commit = commit.clone().parse(
868 &[CommitParser {
869 sha: None,
870 message: None,
871 body: Regex::new("something great").ok(),
872 footer: None,
873 group: Some(String::from("Test group")),
874 default_scope: None,
875 scope: None,
876 skip: None,
877 r#continue: None,
878 field: None,
879 pattern: None,
880 }],
881 false,
882 false,
883 )?;
884 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
885
886 Ok(())
887 }
888
889 #[test]
890 fn parse_commit_field() -> Result<()> {
891 let mut commit = Commit::new(
892 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
893 String::from(
894 "fix: do something
895
896Introduce something great
897
898BREAKING CHANGE: drop support for something else
899Refs: #123
900",
901 ),
902 );
903 commit.author = Signature {
904 name: Some("John Doe".to_string()),
905 email: None,
906 timestamp: 0x0,
907 };
908 commit.remote = Some(crate::contributor::RemoteContributor {
909 username: None,
910 pr_author: None,
911 pr_title: Some("feat: do something".to_string()),
912 pr_number: None,
913 pr_numbers: vec![],
914 pr_labels: vec![String::from("feature"), String::from("deprecation")],
915 is_first_time: true,
916 });
917 let commit = commit.into_conventional()?;
918 let commit = commit.parse_links(&[
919 LinkParser {
920 pattern: Regex::new("RFC(\\d+)")?,
921 href: String::from("rfc://$1"),
922 text: None,
923 },
924 LinkParser {
925 pattern: Regex::new("#(\\d+)")?,
926 href: String::from("https://github.com/$1"),
927 text: None,
928 },
929 ]);
930
931 let parsed_commit = commit.clone().parse(
932 &[CommitParser {
933 sha: None,
934 message: None,
935 body: None,
936 footer: None,
937 group: Some(String::from("Test group")),
938 default_scope: None,
939 scope: None,
940 skip: None,
941 r#continue: None,
942 field: Some(String::from("author.name")),
943 pattern: Regex::new("John Doe").ok(),
944 }],
945 false,
946 false,
947 )?;
948 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
949
950 let parsed_commit = commit.clone().parse(
951 &[CommitParser {
952 sha: None,
953 message: None,
954 body: None,
955 footer: None,
956 group: Some(String::from("Test group")),
957 default_scope: None,
958 scope: None,
959 skip: None,
960 r#continue: None,
961 field: Some(String::from("remote.pr_title")),
962 pattern: Regex::new("feat: do something").ok(),
963 }],
964 false,
965 false,
966 )?;
967 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
968
969 let parsed_commit = commit.clone().parse(
970 &[CommitParser {
971 sha: None,
972 message: None,
973 body: None,
974 footer: None,
975 group: Some(String::from("Test group")),
976 default_scope: None,
977 scope: None,
978 skip: None,
979 r#continue: None,
980 field: Some(String::from("body")),
981 pattern: Regex::new("something great").ok(),
982 }],
983 false,
984 false,
985 )?;
986 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
987
988 let parsed_commit = commit.clone().parse(
989 &[CommitParser {
990 sha: None,
991 message: None,
992 body: None,
993 footer: None,
994 group: Some(String::from("Test group")),
995 default_scope: None,
996 scope: None,
997 skip: None,
998 r#continue: None,
999 field: Some(String::from("remote.pr_labels")),
1000 pattern: Regex::new("feature|deprecation").ok(),
1001 }],
1002 false,
1003 false,
1004 )?;
1005 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1006
1007 let parsed_commit = commit.clone().parse(
1008 &[CommitParser {
1009 sha: None,
1010 message: None,
1011 body: None,
1012 footer: None,
1013 group: Some(String::from("Test group")),
1014 default_scope: None,
1015 scope: None,
1016 skip: None,
1017 r#continue: None,
1018 field: Some(String::from("links")),
1019 pattern: Regex::new(".*").ok(),
1020 }],
1021 false,
1022 false,
1023 )?;
1024 assert_eq!(None, parsed_commit.group);
1025
1026 let parse_result = commit.clone().parse(
1027 &[CommitParser {
1028 sha: None,
1029 message: None,
1030 body: None,
1031 footer: None,
1032 group: Some(String::from("Test group")),
1033 default_scope: None,
1034 scope: None,
1035 skip: None,
1036 r#continue: None,
1037 field: Some(String::from("remote")),
1038 pattern: Regex::new(".*").ok(),
1039 }],
1040 false,
1041 false,
1042 );
1043 assert!(
1044 parse_result.is_err(),
1045 "Expected error when using unsupported field `remote`, but got Ok"
1046 );
1047
1048 Ok(())
1049 }
1050
1051 #[test]
1052 fn parse_commit_multiple_parsers() -> Result<()> {
1053 let commit = Commit::new(
1054 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1055 String::from("feat(deep): support multiple parsers"),
1056 );
1057 let commit = commit.into_conventional()?;
1058
1059 let parsers = vec![
1063 CommitParser {
1064 sha: None,
1065 message: Regex::new("\\(deep\\)").ok(),
1066 body: None,
1067 footer: None,
1068 group: None,
1069 default_scope: None,
1070 scope: Some(String::from("Deep Scope")),
1071 skip: None,
1072 r#continue: None,
1073 field: None,
1074 pattern: None,
1075 },
1076 CommitParser {
1077 sha: None,
1078 message: Regex::new("^feat").ok(),
1079 body: None,
1080 footer: None,
1081 group: Some(String::from("Features")),
1082 default_scope: None,
1083 scope: None,
1084 skip: None,
1085 r#continue: None,
1086 field: None,
1087 pattern: None,
1088 },
1089 ];
1090 let parsed = commit.clone().parse(&parsers, false, false)?;
1091 assert_eq!(Some(String::from("Deep Scope")), parsed.scope);
1092 assert_eq!(None, parsed.group);
1093
1094 let parsers = vec![
1097 CommitParser {
1098 sha: None,
1099 message: Regex::new("\\(deep\\)").ok(),
1100 body: None,
1101 footer: None,
1102 group: None,
1103 default_scope: None,
1104 scope: Some(String::from("Deep Scope")),
1105 skip: None,
1106 r#continue: Some(true),
1107 field: None,
1108 pattern: None,
1109 },
1110 CommitParser {
1111 sha: None,
1112 message: Regex::new("^feat").ok(),
1113 body: None,
1114 footer: None,
1115 group: Some(String::from("Features")),
1116 default_scope: None,
1117 scope: None,
1118 skip: None,
1119 r#continue: Some(true),
1120 field: None,
1121 pattern: None,
1122 },
1123 ];
1124 let parsed = commit.clone().parse(&parsers, false, true)?;
1125 assert_eq!(Some(String::from("Deep Scope")), parsed.scope);
1126 assert_eq!(Some(String::from("Features")), parsed.group);
1127
1128 let scope_only = vec![CommitParser {
1131 sha: None,
1132 message: Regex::new("^feat").ok(),
1133 body: None,
1134 footer: None,
1135 group: None,
1136 default_scope: None,
1137 scope: Some(String::from("Deep Scope")),
1138 skip: None,
1139 r#continue: Some(true),
1140 field: None,
1141 pattern: None,
1142 }];
1143 let parsed = commit.clone().parse(&scope_only, false, true)?;
1144 assert_eq!(Some(String::from("Deep Scope")), parsed.scope);
1145 assert_eq!(None, parsed.group);
1146
1147 let parsers = vec![
1152 CommitParser {
1153 sha: None,
1154 message: Regex::new("\\(deep\\)").ok(),
1155 body: None,
1156 footer: None,
1157 group: None,
1158 default_scope: None,
1159 scope: Some(String::from("Deep Scope")),
1160 skip: None,
1161 r#continue: Some(true),
1162 field: None,
1163 pattern: None,
1164 },
1165 CommitParser {
1166 sha: None,
1167 message: Regex::new("^feat").ok(),
1168 body: None,
1169 footer: None,
1170 group: Some(String::from("Features")),
1171 default_scope: None,
1172 scope: None,
1173 skip: None,
1174 r#continue: None,
1175 field: None,
1176 pattern: None,
1177 },
1178 ];
1179 let parsed = commit.clone().parse(&parsers, false, false)?;
1180 assert_eq!(Some(String::from("Deep Scope")), parsed.scope);
1181 assert_eq!(Some(String::from("Features")), parsed.group);
1182
1183 let mut populated_commit = commit;
1186 populated_commit.group = Some(String::from("Old Group"));
1187 populated_commit.scope = Some(String::from("Old Scope"));
1188 populated_commit.default_scope = Some(String::from("Old Default Scope"));
1189 let terminal = vec![CommitParser {
1190 message: Regex::new("^feat").ok(),
1191 group: Some(String::from("Features")),
1192 ..Default::default()
1193 }];
1194 let parsed = populated_commit.parse(&terminal, false, false)?;
1195 assert_eq!(Some(String::from("Features")), parsed.group);
1196 assert_eq!(None, parsed.scope);
1197 assert_eq!(None, parsed.default_scope);
1198
1199 Ok(())
1200 }
1201
1202 #[test]
1203 fn parse_commit_missing_field_falls_through_to_next_parser() -> Result<()> {
1204 let commit = Commit::new(
1205 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1206 String::from("feat: first feature"),
1207 );
1208 let parsers = [
1213 CommitParser {
1214 sha: None,
1215 message: Some(Regex::new("^feat")?),
1216 body: None,
1217 footer: None,
1218 group: Some(String::from("Bug fixes")),
1219 default_scope: None,
1220 scope: None,
1221 skip: None,
1222 r#continue: None,
1223 field: Some(String::from("remote.pr_labels")),
1224 pattern: Regex::new("bug").ok(),
1225 },
1226 CommitParser {
1227 sha: None,
1228 message: Some(Regex::new(".*")?),
1229 body: None,
1230 footer: None,
1231 group: Some(String::from("Miscellaneous")),
1232 default_scope: None,
1233 scope: None,
1234 skip: None,
1235 r#continue: None,
1236 field: None,
1237 pattern: None,
1238 },
1239 ];
1240 let parsed = commit.parse(&parsers, false, false)?;
1241 assert_eq!(
1242 Some(String::from("Miscellaneous")),
1243 parsed.group,
1244 "a missing field on parser #1 must fall through to the catch-all parser #2"
1245 );
1246 Ok(())
1247 }
1248
1249 #[test]
1250 fn parse_commit_footer_requires_conventional_data() -> Result<()> {
1251 let parsers = [
1252 CommitParser {
1253 sha: None,
1254 message: Regex::new("^feat:.*?remove").ok(),
1255 body: None,
1256 footer: Regex::new("^BREAKING CHANGE:").ok(),
1257 group: Some(String::from("Removed")),
1258 default_scope: None,
1259 scope: None,
1260 skip: None,
1261 r#continue: None,
1262 field: None,
1263 pattern: None,
1264 },
1265 CommitParser {
1266 sha: None,
1267 message: Some(Regex::new(".*")?),
1268 body: None,
1269 footer: None,
1270 group: Some(String::from("Miscellaneous")),
1271 default_scope: None,
1272 scope: None,
1273 skip: None,
1274 r#continue: None,
1275 field: None,
1276 pattern: None,
1277 },
1278 ];
1279
1280 let commit = Commit::new(
1281 String::new(),
1282 String::from("feat: remove old api\n\nBREAKING CHANGE: drop legacy support"),
1283 )
1284 .parse(&parsers, false, false)?;
1285 assert_eq!(
1286 Some(String::from("Miscellaneous")),
1287 commit.group,
1288 "footer requires conventional data; must not match the combined parser"
1289 );
1290
1291 Ok(())
1292 }
1293
1294 #[test]
1295 fn parse_commit_and_semantics_message_footer() -> Result<()> {
1296 let parsers = [
1297 CommitParser {
1298 sha: None,
1299 message: Regex::new("^feat:.*?remove").ok(),
1300 body: None,
1301 footer: Regex::new("^BREAKING CHANGE:").ok(),
1302 group: Some(String::from("Removed")),
1303 default_scope: None,
1304 scope: None,
1305 skip: None,
1306 r#continue: None,
1307 field: None,
1308 pattern: None,
1309 },
1310 CommitParser {
1311 sha: None,
1312 message: Some(Regex::new(".*")?),
1313 body: None,
1314 footer: None,
1315 group: Some(String::from("Miscellaneous")),
1316 default_scope: None,
1317 scope: None,
1318 skip: None,
1319 r#continue: None,
1320 field: None,
1321 pattern: None,
1322 },
1323 ];
1324
1325 let commit = Commit::new(String::new(), String::from("feat: remove old api"))
1326 .into_conventional()?
1327 .parse(&parsers, false, false)?;
1328 assert_eq!(
1329 Some(String::from("Miscellaneous")),
1330 commit.group,
1331 "message matches but footer doesn't; must not match the combined parser"
1332 );
1333
1334 let commit = Commit::new(
1335 String::new(),
1336 String::from("feat: remove old api\n\nBREAKING CHANGE: drop legacy support"),
1337 )
1338 .into_conventional()?
1339 .parse(&parsers, false, false)?;
1340 assert_eq!(Some(String::from("Removed")), commit.group);
1341
1342 Ok(())
1343 }
1344
1345 #[test]
1346 fn parse_commit_and_semantics_message_body() -> Result<()> {
1347 let parsers = [
1348 CommitParser {
1349 sha: None,
1350 message: Regex::new("^fix:").ok(),
1351 body: Regex::new("security").ok(),
1352 footer: None,
1353 group: Some(String::from("Security")),
1354 default_scope: None,
1355 scope: None,
1356 skip: None,
1357 r#continue: None,
1358 field: None,
1359 pattern: None,
1360 },
1361 CommitParser {
1362 sha: None,
1363 message: Some(Regex::new(".*")?),
1364 body: None,
1365 footer: None,
1366 group: Some(String::from("Miscellaneous")),
1367 default_scope: None,
1368 scope: None,
1369 skip: None,
1370 r#continue: None,
1371 field: None,
1372 pattern: None,
1373 },
1374 ];
1375
1376 let commit = Commit::new(
1377 String::new(),
1378 String::from("fix: patch bug\n\nregular body"),
1379 )
1380 .into_conventional()?
1381 .parse(&parsers, false, false)?;
1382 assert_eq!(
1383 Some(String::from("Miscellaneous")),
1384 commit.group,
1385 "message matches but body doesn't; must not match the combined parser"
1386 );
1387
1388 let commit = Commit::new(
1389 String::new(),
1390 String::from("fix: patch bug\n\nfix a security bug"),
1391 )
1392 .into_conventional()?
1393 .parse(&parsers, false, false)?;
1394 assert_eq!(Some(String::from("Security")), commit.group);
1395
1396 Ok(())
1397 }
1398
1399 #[test]
1400 fn parse_commit_and_semantics_footer_field() -> Result<()> {
1401 let parsers = [
1402 CommitParser {
1403 sha: None,
1404 message: None,
1405 body: None,
1406 footer: Regex::new("^BREAKING CHANGE:").ok(),
1407 group: Some(String::from("Removed")),
1408 default_scope: None,
1409 scope: None,
1410 skip: None,
1411 r#continue: None,
1412 field: Some(String::from("message")),
1413 pattern: Regex::new("remove").ok(),
1414 },
1415 CommitParser {
1416 sha: None,
1417 message: Some(Regex::new(".*")?),
1418 body: None,
1419 footer: None,
1420 group: Some(String::from("Miscellaneous")),
1421 default_scope: None,
1422 scope: None,
1423 skip: None,
1424 r#continue: None,
1425 field: None,
1426 pattern: None,
1427 },
1428 ];
1429
1430 let commit = Commit::new(String::new(), String::from("feat: remove old api"))
1431 .into_conventional()?
1432 .parse(&parsers, false, false)?;
1433 assert_eq!(
1434 Some(String::from("Miscellaneous")),
1435 commit.group,
1436 "field pattern matches but footer doesn't; must not match the combined parser"
1437 );
1438
1439 let commit = Commit::new(
1440 String::new(),
1441 String::from("feat: remove old api\n\nBREAKING CHANGE: drop legacy support"),
1442 )
1443 .into_conventional()?
1444 .parse(&parsers, false, false)?;
1445 assert_eq!(Some(String::from("Removed")), commit.group);
1446
1447 Ok(())
1448 }
1449
1450 #[test]
1451 fn parse_commit_and_semantics_sha_message() -> Result<()> {
1452 let sha = String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6");
1453 let parsers = [
1454 CommitParser {
1455 sha: Some(sha.clone()),
1456 message: Regex::new("^feat:").ok(),
1457 body: None,
1458 footer: None,
1459 group: Some(String::from("Added")),
1460 default_scope: None,
1461 scope: None,
1462 skip: None,
1463 r#continue: None,
1464 field: None,
1465 pattern: None,
1466 },
1467 CommitParser {
1468 sha: None,
1469 message: Some(Regex::new(".*")?),
1470 body: None,
1471 footer: None,
1472 group: Some(String::from("Miscellaneous")),
1473 default_scope: None,
1474 scope: None,
1475 skip: None,
1476 r#continue: None,
1477 field: None,
1478 pattern: None,
1479 },
1480 ];
1481
1482 let commit = Commit::new(sha.clone(), String::from("fix: patch bug"))
1483 .parse(&parsers, false, false)?;
1484 assert_eq!(
1485 Some(String::from("Miscellaneous")),
1486 commit.group,
1487 "sha matches but message doesn't; must not match the combined parser"
1488 );
1489
1490 let commit = Commit::new(
1491 String::from("0000000000000000000000000000000000000000"),
1492 String::from("feat: add feature"),
1493 )
1494 .parse(&parsers, false, false)?;
1495 assert_eq!(
1496 Some(String::from("Miscellaneous")),
1497 commit.group,
1498 "message matches but sha doesn't; must not match the combined parser"
1499 );
1500
1501 let commit =
1502 Commit::new(sha, String::from("feat: add feature")).parse(&parsers, false, false)?;
1503 assert_eq!(Some(String::from("Added")), commit.group);
1504
1505 Ok(())
1506 }
1507
1508 #[test]
1509 fn commit_sha() {
1510 let commit = Commit::new(
1511 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1512 String::from("feat: do something"),
1513 );
1514
1515 let parsed_commit = commit.clone().parse(
1516 &[CommitParser {
1517 sha: Some(String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6")),
1518 message: None,
1519 body: None,
1520 footer: None,
1521 group: None,
1522 default_scope: None,
1523 scope: None,
1524 skip: Some(true),
1525 r#continue: None,
1526 field: None,
1527 pattern: None,
1528 }],
1529 false,
1530 false,
1531 );
1532 assert!(
1533 parsed_commit.is_err(),
1534 "Expected error when parsing with `skip: Some(true)`, but got Ok"
1535 );
1536 }
1537
1538 #[test]
1539 fn field_name_regex() -> Result<()> {
1540 let mut commit = Commit::new(
1541 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1542 String::from("feat: do something"),
1543 );
1544 commit.author = Signature {
1545 name: Some("John Doe".to_string()),
1546 email: None,
1547 timestamp: 0x0,
1548 };
1549 commit.remote = Some(crate::contributor::RemoteContributor {
1550 username: None,
1551 pr_author: None,
1552 pr_title: Some("feat: do something".to_string()),
1553 pr_number: None,
1554 pr_numbers: vec![],
1555 pr_labels: Vec::new(),
1556 is_first_time: true,
1557 });
1558
1559 let parsed_commit = commit.clone().parse(
1560 &[CommitParser {
1561 sha: None,
1562 message: None,
1563 body: None,
1564 footer: None,
1565 group: Some(String::from("Test group")),
1566 default_scope: None,
1567 scope: None,
1568 skip: None,
1569 r#continue: None,
1570 field: Some(String::from("author.name")),
1571 pattern: Regex::new("^John Doe$").ok(),
1572 }],
1573 false,
1574 false,
1575 )?;
1576 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1577
1578 let parsed_commit = commit.clone().parse(
1579 &[CommitParser {
1580 sha: None,
1581 message: None,
1582 body: None,
1583 footer: None,
1584 group: Some(String::from("Test group")),
1585 default_scope: None,
1586 scope: None,
1587 skip: None,
1588 r#continue: None,
1589 field: Some(String::from("remote.pr_title")),
1590 pattern: Regex::new("^feat(\\([^)]+\\))?").ok(),
1591 }],
1592 false,
1593 false,
1594 )?;
1595 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1596
1597 let parse_result = commit.parse(
1598 &[CommitParser {
1599 sha: None,
1600 message: None,
1601 body: None,
1602 footer: None,
1603 group: Some(String::from("Test group")),
1604 default_scope: None,
1605 scope: None,
1606 skip: None,
1607 r#continue: None,
1608 field: Some(String::from("author.name")),
1609 pattern: Regex::new("Something else").ok(),
1610 }],
1611 false,
1612 true,
1613 );
1614 assert!(
1615 parse_result.is_err(),
1616 "Expected error because `author.name` did not match the given pattern, but got Ok"
1617 );
1618
1619 Ok(())
1620 }
1621}