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 if 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 return Commit {
183 id: id.to_string(),
184 message: message.to_string(),
185 ..Default::default()
186 };
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 'parsers: for parser in parsers {
333 let mut regex_checks = Vec::new();
334 if let Some(message_regex) = parser.message.as_ref() {
335 regex_checks.push((message_regex, self.message.clone()));
336 }
337 let body = self
338 .conv
339 .as_ref()
340 .and_then(ConventionalCommit::body)
341 .map(ToString::to_string);
342 if let Some(body_regex) = parser.body.as_ref() {
343 regex_checks.push((body_regex, body.clone().unwrap_or_default()));
344 }
345 if let (Some(footer_regex), Some(footers)) = (
346 parser.footer.as_ref(),
347 self.conv.as_ref().map(ConventionalCommit::footers),
348 ) {
349 regex_checks.extend(footers.iter().map(|f| (footer_regex, f.to_string())));
350 }
351 if let (Some(field_name), Some(pattern_regex)) =
352 (parser.field.as_ref(), parser.pattern.as_ref())
353 {
354 let values = if field_name == "body" {
355 vec![body.clone()].into_iter().collect()
356 } else {
357 let Some(field_value) = tera::dotted_pointer(&lookup_context, field_name)
358 else {
359 tracing::trace!("Field '{field_name}' is absent; trying the next parser");
360 continue 'parsers;
361 };
362 match field_value {
363 Value::String(s) => Some(vec![s.clone()]),
364 Value::Number(_) | Value::Bool(_) | Value::Null => {
365 Some(vec![field_value.to_string()])
366 }
367 Value::Array(arr) => {
368 let mut values = Vec::new();
369 for item in arr {
370 match item {
371 Value::String(s) => values.push(s.clone()),
372 Value::Number(_) | Value::Bool(_) | Value::Null => {
373 values.push(item.to_string());
374 }
375 _ => {}
376 }
377 }
378 Some(values)
379 }
380 Value::Object(_) => None,
381 }
382 };
383 match values {
384 Some(values) => {
385 if values.is_empty() {
386 tracing::trace!("Field '{field_name}' is present but empty");
387 } else {
388 for value in values {
389 regex_checks.push((pattern_regex, value));
390 }
391 }
392 }
393 None => {
394 return Err(AppError::FieldError(format!(
395 "field '{field_name}' is missing or has unsupported type (expected a \
396 String, Number, Bool, or Null — or an Array of these scalar values)",
397 )));
398 }
399 }
400 }
401 if parser.sha.clone().map(|v| v.to_lowercase()).as_deref() == Some(&self.id) {
402 if self.skip_commit(parser, protect_breaking) {
403 return Err(AppError::GroupError(String::from("Skipping commit")));
404 } else {
405 self.group = parser.group.clone().or(self.group);
406 self.scope = parser.scope.clone().or(self.scope);
407 self.default_scope = parser.default_scope.clone().or(self.default_scope);
408 return Ok(self);
409 }
410 }
411 for (regex, text) in regex_checks {
412 if regex.is_match(text.trim()) {
413 if self.skip_commit(parser, protect_breaking) {
414 return Err(AppError::GroupError(String::from("Skipping commit")));
415 } else {
416 let regex_replace = |mut value: String| {
417 for mat in regex.find_iter(&text) {
418 value = regex.replace(mat.as_str(), value).to_string();
419 }
420 value
421 };
422 self.group = parser.group.clone().map(regex_replace);
423 self.scope = parser.scope.clone().map(regex_replace);
424 self.default_scope.clone_from(&parser.default_scope);
425 return Ok(self);
426 }
427 }
428 }
429 }
430 if filter {
431 Err(AppError::GroupError(String::from(
432 "Commit does not belong to any group",
433 )))
434 } else {
435 Ok(self)
436 }
437 }
438
439 #[must_use]
445 #[cfg_attr(
446 feature = "tracing",
447 tracing::instrument(
448 skip_all,
449 fields(id = self.id)
450 )
451 )]
452 pub fn parse_links(mut self, parsers: &[LinkParser]) -> Self {
453 crate::set_progress_message!("Parsing links for the commit using link parsers");
454 for parser in parsers {
455 let regex = &parser.pattern;
456 let replace = &parser.href;
457 for mat in regex.find_iter(&self.message) {
458 let m = mat.as_str();
459 let text = if let Some(text_replace) = &parser.text {
460 regex.replace(m, text_replace).to_string()
461 } else {
462 m.to_string()
463 };
464 let href = regex.replace(m, replace);
465 self.links.push(Link {
466 text,
467 href: href.to_string(),
468 });
469 }
470 }
471 self
472 }
473
474 fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
479 self.conv
480 .iter()
481 .flat_map(|conv| conv.footers().iter().map(Footer::from))
482 }
483}
484
485impl Serialize for Commit<'_> {
486 #[allow(deprecated)]
487 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
488 where
489 S: Serializer,
490 {
491 struct SerializeFooters<'a>(&'a Commit<'a>);
495 impl Serialize for SerializeFooters<'_> {
496 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
497 where
498 S: Serializer,
499 {
500 serializer.collect_seq(self.0.footers())
501 }
502 }
503
504 let mut commit = serializer.serialize_struct("Commit", 21)?;
505 commit.serialize_field("id", &self.id)?;
506 if let Some(conv) = &self.conv {
507 commit.serialize_field("message", conv.description())?;
508 commit.serialize_field("body", &conv.body())?;
509 commit.serialize_field("footers", &SerializeFooters(self))?;
510 commit.serialize_field(
511 "group",
512 self.group.as_ref().unwrap_or(&conv.type_().to_string()),
513 )?;
514 commit.serialize_field("breaking_description", &conv.breaking_description())?;
515 commit.serialize_field("breaking", &conv.breaking())?;
516 commit.serialize_field(
517 "scope",
518 &self
519 .scope
520 .as_deref()
521 .or_else(|| conv.scope().map(|v| v.as_str()))
522 .or(self.default_scope.as_deref()),
523 )?;
524 } else {
525 commit.serialize_field("message", &self.message)?;
526 commit.serialize_field("group", &self.group)?;
527 commit.serialize_field(
528 "scope",
529 &self.scope.as_deref().or(self.default_scope.as_deref()),
530 )?;
531 }
532
533 commit.serialize_field("links", &self.links)?;
534 commit.serialize_field("author", &self.author)?;
535 commit.serialize_field("committer", &self.committer)?;
536 commit.serialize_field("conventional", &self.conv.is_some())?;
537 commit.serialize_field("merge_commit", &self.merge_commit)?;
538 commit.serialize_field("statistics", &self.statistics)?;
539 commit.serialize_field("extra", &self.extra)?;
540 #[cfg(feature = "github")]
541 commit.serialize_field("github", &self.github)?;
542 #[cfg(feature = "gitlab")]
543 commit.serialize_field("gitlab", &self.gitlab)?;
544 #[cfg(feature = "gitea")]
545 commit.serialize_field("gitea", &self.gitea)?;
546 #[cfg(feature = "bitbucket")]
547 commit.serialize_field("bitbucket", &self.bitbucket)?;
548 #[cfg(feature = "azure_devops")]
549 commit.serialize_field("azure_devops", &self.azure_devops)?;
550 if let Some(remote) = &self.remote {
551 commit.serialize_field("remote", remote)?;
552 }
553 commit.serialize_field("raw_message", &self.raw_message())?;
554 commit.end()
555 }
556}
557
558#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
566pub(crate) fn commits_to_conventional_commits<'de, 'a, D: Deserializer<'de>>(
567 deserializer: D,
568) -> std::result::Result<Vec<Commit<'a>>, D::Error> {
569 crate::set_progress_message!("Converting commits to conventional commits");
570 let commits = Vec::<Commit<'a>>::deserialize(deserializer)?;
571 let commits = commits
572 .into_iter()
573 .map(|commit| commit.clone().into_conventional().unwrap_or(commit))
574 .collect();
575 Ok(commits)
576}
577
578#[cfg(test)]
579mod test {
580 use super::*;
581
582 #[test]
583 fn conventional_commit() -> Result<()> {
584 let test_cases = vec![
585 (
586 Commit::new(
587 String::from("123123"),
588 String::from("test(commit): add test"),
589 ),
590 true,
591 ),
592 (
593 Commit::new(String::from("124124"), String::from("xyz")),
594 false,
595 ),
596 ];
597
598 for (commit, is_conventional) in &test_cases {
599 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
600 }
601
602 let commit = test_cases[0].0.clone().parse(
603 &[CommitParser {
604 sha: None,
605 message: Regex::new("test*").ok(),
606 body: None,
607 footer: None,
608 group: Some(String::from("test_group")),
609 default_scope: Some(String::from("test_scope")),
610 scope: None,
611 skip: None,
612 field: None,
613 pattern: None,
614 }],
615 false,
616 false,
617 )?;
618 assert_eq!(Some(String::from("test_group")), commit.group);
619 assert_eq!(Some(String::from("test_scope")), commit.default_scope);
620
621 Ok(())
622 }
623
624 #[test]
625 fn conventional_footers() {
626 let cfg = crate::config::GitConfig {
627 conventional_commits: true,
628 ..Default::default()
629 };
630 let test_cases = vec![
631 (
632 Commit::new(
633 String::from("123123"),
634 String::from(
635 "test(commit): add test\n\nSigned-off-by: Test User <test@example.com>",
636 ),
637 ),
638 vec![Footer {
639 token: "Signed-off-by",
640 separator: ":",
641 value: "Test User <test@example.com>",
642 breaking: false,
643 }],
644 ),
645 (
646 Commit::new(
647 String::from("123124"),
648 String::from(
649 "fix(commit): break stuff\n\nBREAKING CHANGE: This commit breaks \
650 stuff\nSigned-off-by: Test User <test@example.com>",
651 ),
652 ),
653 vec![
654 Footer {
655 token: "BREAKING CHANGE",
656 separator: ":",
657 value: "This commit breaks stuff",
658 breaking: true,
659 },
660 Footer {
661 token: "Signed-off-by",
662 separator: ":",
663 value: "Test User <test@example.com>",
664 breaking: false,
665 },
666 ],
667 ),
668 ];
669
670 for (commit, footers) in &test_cases {
671 let commit = commit.process(&cfg).expect("commit should process");
672 assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
673 }
674 }
675
676 #[test]
677 fn parse_link() -> Result<()> {
678 let test_cases = vec![
679 (
680 Commit::new(
681 String::from("123123"),
682 String::from("test(commit): add test\n\nBody with issue #123"),
683 ),
684 true,
685 ),
686 (
687 Commit::new(
688 String::from("123123"),
689 String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #456"),
690 ),
691 true,
692 ),
693 ];
694
695 for (commit, is_conventional) in &test_cases {
696 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
697 }
698
699 let commit = Commit::new(
700 String::from("123123"),
701 String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"),
702 );
703
704 let commit = commit.parse_links(&[
705 LinkParser {
706 pattern: Regex::new("RFC(\\d+)")?,
707 href: String::from("rfc://$1"),
708 text: None,
709 },
710 LinkParser {
711 pattern: Regex::new("#(\\d+)")?,
712 href: String::from("https://github.com/$1"),
713 text: None,
714 },
715 ]);
716 assert_eq!(
717 vec![
718 Link {
719 text: String::from("RFC456"),
720 href: String::from("rfc://456"),
721 },
722 Link {
723 text: String::from("#455"),
724 href: String::from("https://github.com/455"),
725 }
726 ],
727 commit.links
728 );
729
730 Ok(())
731 }
732
733 #[test]
734 fn parse_commit() {
735 assert_eq!(
736 Commit::new(String::new(), String::from("test: no sha1 given")),
737 Commit::from(String::from("test: no sha1 given"))
738 );
739
740 assert_eq!(
741 Commit::new(
742 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
743 String::from("feat: do something")
744 ),
745 Commit::from(String::from(
746 "8f55e69eba6e6ce811ace32bd84cc82215673cb6 feat: do something"
747 ))
748 );
749
750 assert_eq!(
751 Commit::new(
752 String::from("3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853"),
753 String::from("chore: do something")
754 ),
755 Commit::from(String::from(
756 "3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853 chore: do something"
757 ))
758 );
759
760 assert_eq!(
761 Commit::new(
762 String::new(),
763 String::from("thisisinvalidsha1 style: add formatting")
764 ),
765 Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
766 );
767 }
768
769 #[test]
770 fn parse_body() -> Result<()> {
771 let mut commit = Commit::new(
772 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
773 String::from(
774 "fix: do something
775
776Introduce something great
777
778BREAKING CHANGE: drop support for something else
779Refs: #123
780",
781 ),
782 );
783 commit.author = Signature {
784 name: Some("John Doe".to_string()),
785 email: None,
786 timestamp: 0x0,
787 };
788 commit.remote = Some(crate::contributor::RemoteContributor {
789 username: None,
790 pr_author: None,
791 pr_title: Some("feat: do something".to_string()),
792 pr_number: None,
793 pr_numbers: vec![],
794 pr_labels: vec![String::from("feature"), String::from("deprecation")],
795 is_first_time: true,
796 });
797 let commit = commit.into_conventional()?;
798 let commit = commit.parse_links(&[
799 LinkParser {
800 pattern: Regex::new("RFC(\\d+)")?,
801 href: String::from("rfc://$1"),
802 text: None,
803 },
804 LinkParser {
805 pattern: Regex::new("#(\\d+)")?,
806 href: String::from("https://github.com/$1"),
807 text: None,
808 },
809 ]);
810
811 let parsed_commit = commit.clone().parse(
812 &[CommitParser {
813 sha: None,
814 message: None,
815 body: Regex::new("something great").ok(),
816 footer: None,
817 group: Some(String::from("Test group")),
818 default_scope: None,
819 scope: None,
820 skip: None,
821 field: None,
822 pattern: None,
823 }],
824 false,
825 false,
826 )?;
827 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
828
829 Ok(())
830 }
831
832 #[test]
833 fn parse_commit_field() -> Result<()> {
834 let mut commit = Commit::new(
835 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
836 String::from(
837 "fix: do something
838
839Introduce something great
840
841BREAKING CHANGE: drop support for something else
842Refs: #123
843",
844 ),
845 );
846 commit.author = Signature {
847 name: Some("John Doe".to_string()),
848 email: None,
849 timestamp: 0x0,
850 };
851 commit.remote = Some(crate::contributor::RemoteContributor {
852 username: None,
853 pr_author: None,
854 pr_title: Some("feat: do something".to_string()),
855 pr_number: None,
856 pr_numbers: vec![],
857 pr_labels: vec![String::from("feature"), String::from("deprecation")],
858 is_first_time: true,
859 });
860 let commit = commit.into_conventional()?;
861 let commit = commit.parse_links(&[
862 LinkParser {
863 pattern: Regex::new("RFC(\\d+)")?,
864 href: String::from("rfc://$1"),
865 text: None,
866 },
867 LinkParser {
868 pattern: Regex::new("#(\\d+)")?,
869 href: String::from("https://github.com/$1"),
870 text: None,
871 },
872 ]);
873
874 let parsed_commit = commit.clone().parse(
875 &[CommitParser {
876 sha: None,
877 message: None,
878 body: None,
879 footer: None,
880 group: Some(String::from("Test group")),
881 default_scope: None,
882 scope: None,
883 skip: None,
884 field: Some(String::from("author.name")),
885 pattern: Regex::new("John Doe").ok(),
886 }],
887 false,
888 false,
889 )?;
890 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
891
892 let parsed_commit = commit.clone().parse(
893 &[CommitParser {
894 sha: None,
895 message: None,
896 body: None,
897 footer: None,
898 group: Some(String::from("Test group")),
899 default_scope: None,
900 scope: None,
901 skip: None,
902 field: Some(String::from("remote.pr_title")),
903 pattern: Regex::new("feat: do something").ok(),
904 }],
905 false,
906 false,
907 )?;
908 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
909
910 let parsed_commit = commit.clone().parse(
911 &[CommitParser {
912 sha: None,
913 message: None,
914 body: None,
915 footer: None,
916 group: Some(String::from("Test group")),
917 default_scope: None,
918 scope: None,
919 skip: None,
920 field: Some(String::from("body")),
921 pattern: Regex::new("something great").ok(),
922 }],
923 false,
924 false,
925 )?;
926 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
927
928 let parsed_commit = commit.clone().parse(
929 &[CommitParser {
930 sha: None,
931 message: None,
932 body: None,
933 footer: None,
934 group: Some(String::from("Test group")),
935 default_scope: None,
936 scope: None,
937 skip: None,
938 field: Some(String::from("remote.pr_labels")),
939 pattern: Regex::new("feature|deprecation").ok(),
940 }],
941 false,
942 false,
943 )?;
944 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
945
946 let parsed_commit = commit.clone().parse(
947 &[CommitParser {
948 sha: None,
949 message: None,
950 body: None,
951 footer: None,
952 group: Some(String::from("Test group")),
953 default_scope: None,
954 scope: None,
955 skip: None,
956 field: Some(String::from("links")),
957 pattern: Regex::new(".*").ok(),
958 }],
959 false,
960 false,
961 )?;
962 assert_eq!(None, parsed_commit.group);
963
964 let parse_result = commit.clone().parse(
965 &[CommitParser {
966 sha: None,
967 message: None,
968 body: None,
969 footer: None,
970 group: Some(String::from("Test group")),
971 default_scope: None,
972 scope: None,
973 skip: None,
974 field: Some(String::from("remote")),
975 pattern: Regex::new(".*").ok(),
976 }],
977 false,
978 false,
979 );
980 assert!(
981 parse_result.is_err(),
982 "Expected error when using unsupported field `remote`, but got Ok"
983 );
984
985 Ok(())
986 }
987
988 #[test]
989 fn parse_commit_missing_field_falls_through_to_next_parser() -> Result<()> {
990 let commit = Commit::new(
991 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
992 String::from("feat: first feature"),
993 );
994 let parsers = [
999 CommitParser {
1000 sha: None,
1001 message: Some(Regex::new("^feat")?),
1002 body: None,
1003 footer: None,
1004 group: Some(String::from("Bug fixes")),
1005 default_scope: None,
1006 scope: None,
1007 skip: None,
1008 field: Some(String::from("remote.pr_labels")),
1009 pattern: Regex::new("bug").ok(),
1010 },
1011 CommitParser {
1012 sha: None,
1013 message: Some(Regex::new(".*")?),
1014 body: None,
1015 footer: None,
1016 group: Some(String::from("Miscellaneous")),
1017 default_scope: None,
1018 scope: None,
1019 skip: None,
1020 field: None,
1021 pattern: None,
1022 },
1023 ];
1024 let parsed = commit.parse(&parsers, false, false)?;
1025 assert_eq!(
1026 Some(String::from("Miscellaneous")),
1027 parsed.group,
1028 "a missing field on parser #1 must fall through to the catch-all parser #2"
1029 );
1030 Ok(())
1031 }
1032
1033 #[test]
1034 fn commit_sha() {
1035 let commit = Commit::new(
1036 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1037 String::from("feat: do something"),
1038 );
1039
1040 let parsed_commit = commit.clone().parse(
1041 &[CommitParser {
1042 sha: Some(String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6")),
1043 message: None,
1044 body: None,
1045 footer: None,
1046 group: None,
1047 default_scope: None,
1048 scope: None,
1049 skip: Some(true),
1050 field: None,
1051 pattern: None,
1052 }],
1053 false,
1054 false,
1055 );
1056 assert!(
1057 parsed_commit.is_err(),
1058 "Expected error when parsing with `skip: Some(true)`, but got Ok"
1059 );
1060 }
1061
1062 #[test]
1063 fn field_name_regex() -> Result<()> {
1064 let mut commit = Commit::new(
1065 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1066 String::from("feat: do something"),
1067 );
1068 commit.author = Signature {
1069 name: Some("John Doe".to_string()),
1070 email: None,
1071 timestamp: 0x0,
1072 };
1073 commit.remote = Some(crate::contributor::RemoteContributor {
1074 username: None,
1075 pr_author: None,
1076 pr_title: Some("feat: do something".to_string()),
1077 pr_number: None,
1078 pr_numbers: vec![],
1079 pr_labels: Vec::new(),
1080 is_first_time: true,
1081 });
1082
1083 let parsed_commit = commit.clone().parse(
1084 &[CommitParser {
1085 sha: None,
1086 message: None,
1087 body: None,
1088 footer: None,
1089 group: Some(String::from("Test group")),
1090 default_scope: None,
1091 scope: None,
1092 skip: None,
1093 field: Some(String::from("author.name")),
1094 pattern: Regex::new("^John Doe$").ok(),
1095 }],
1096 false,
1097 false,
1098 )?;
1099 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1100
1101 let parsed_commit = commit.clone().parse(
1102 &[CommitParser {
1103 sha: None,
1104 message: None,
1105 body: None,
1106 footer: None,
1107 group: Some(String::from("Test group")),
1108 default_scope: None,
1109 scope: None,
1110 skip: None,
1111 field: Some(String::from("remote.pr_title")),
1112 pattern: Regex::new("^feat(\\([^)]+\\))?").ok(),
1113 }],
1114 false,
1115 false,
1116 )?;
1117 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1118
1119 let parse_result = commit.parse(
1120 &[CommitParser {
1121 sha: None,
1122 message: None,
1123 body: None,
1124 footer: None,
1125 group: Some(String::from("Test group")),
1126 default_scope: None,
1127 scope: None,
1128 skip: None,
1129 field: Some(String::from("author.name")),
1130 pattern: Regex::new("Something else").ok(),
1131 }],
1132 false,
1133 true,
1134 );
1135 assert!(
1136 parse_result.is_err(),
1137 "Expected error because `author.name` did not match the given pattern, but got Ok"
1138 );
1139
1140 Ok(())
1141 }
1142}