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