1use git_conventional::{Commit as ConventionalCommit, Footer as ConventionalFooter};
2#[cfg(feature = "repo")]
3use git2::{Commit as GitCommit, Signature as CommitSignature};
4use lazy_regex::{Lazy, Regex, lazy_regex};
5use serde::ser::{SerializeStruct, Serializer};
6use serde::{Deserialize, Deserializer, Serialize};
7use serde_json::value::Value;
8
9use crate::config::{CommitParser, GitConfig, LinkParser, TextProcessor};
10use crate::error::{Error as AppError, Result};
11
12static SHA1_REGEX: Lazy<Regex> = lazy_regex!(r#"^\b([a-f0-9]{40})\b (.*)$"#);
15
16#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
18#[serde(rename_all(serialize = "camelCase"))]
19pub struct Link {
20 pub text: String,
22 pub href: String,
24}
25
26#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
28struct Footer<'a> {
29 token: &'a str,
34 separator: &'a str,
38 value: &'a str,
40 breaking: bool,
42}
43
44impl<'a> From<&'a ConventionalFooter<'a>> for Footer<'a> {
45 fn from(footer: &'a ConventionalFooter<'a>) -> Self {
46 Self {
47 token: footer.token().as_str(),
48 separator: footer.separator().as_str(),
49 value: footer.value(),
50 breaking: footer.breaking(),
51 }
52 }
53}
54
55#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
57pub struct Signature {
58 pub name: Option<String>,
60 pub email: Option<String>,
62 pub timestamp: i64,
64}
65
66#[cfg(feature = "repo")]
67impl<'a> From<CommitSignature<'a>> for Signature {
68 fn from(signature: CommitSignature<'a>) -> Self {
69 Self {
70 name: signature.name().map(String::from),
71 email: signature.email().map(String::from),
72 timestamp: signature.when().seconds(),
73 }
74 }
75}
76
77#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Range {
80 from: String,
82 to: String,
84}
85
86impl Range {
87 pub fn new(from: &Commit, to: &Commit) -> Self {
89 Self {
90 from: from.id.clone(),
91 to: to.id.clone(),
92 }
93 }
94}
95
96#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
98#[serde(rename_all(serialize = "camelCase"))]
99pub struct Commit<'a> {
100 pub id: String,
102 pub message: String,
104 #[serde(skip_deserializing)]
106 pub conv: Option<ConventionalCommit<'a>>,
107 pub group: Option<String>,
109 pub default_scope: Option<String>,
112 pub scope: Option<String>,
114 pub links: Vec<Link>,
116 pub author: Signature,
118 pub committer: Signature,
120 pub merge_commit: bool,
122 pub extra: Option<Value>,
124 pub remote: Option<crate::contributor::RemoteContributor>,
126 #[cfg(feature = "github")]
128 #[deprecated(note = "Use `remote` field instead")]
129 pub github: crate::contributor::RemoteContributor,
130 #[cfg(feature = "gitlab")]
132 #[deprecated(note = "Use `remote` field instead")]
133 pub gitlab: crate::contributor::RemoteContributor,
134 #[cfg(feature = "gitea")]
136 #[deprecated(note = "Use `remote` field instead")]
137 pub gitea: crate::contributor::RemoteContributor,
138 #[cfg(feature = "bitbucket")]
140 #[deprecated(note = "Use `remote` field instead")]
141 pub bitbucket: crate::contributor::RemoteContributor,
142
143 pub raw_message: Option<String>,
150}
151
152impl From<String> for Commit<'_> {
153 fn from(message: String) -> Self {
154 if let Some(captures) = SHA1_REGEX.captures(&message) {
155 if let (Some(id), Some(message)) = (
156 captures.get(1).map(|v| v.as_str()),
157 captures.get(2).map(|v| v.as_str()),
158 ) {
159 return Commit {
160 id: id.to_string(),
161 message: message.to_string(),
162 ..Default::default()
163 };
164 }
165 }
166 Commit {
167 id: String::new(),
168 message,
169 ..Default::default()
170 }
171 }
172}
173
174#[cfg(feature = "repo")]
175impl From<&GitCommit<'_>> for Commit<'_> {
176 fn from(commit: &GitCommit<'_>) -> Self {
177 Commit {
178 id: commit.id().to_string(),
179 message: commit.message().unwrap_or_default().trim_end().to_string(),
180 author: commit.author().into(),
181 committer: commit.committer().into(),
182 merge_commit: commit.parent_count() > 1,
183 ..Default::default()
184 }
185 }
186}
187
188impl Commit<'_> {
189 pub fn new(id: String, message: String) -> Self {
191 Self {
192 id,
193 message,
194 ..Default::default()
195 }
196 }
197
198 pub fn raw_message(&self) -> &str {
200 self.raw_message.as_deref().unwrap_or(&self.message)
201 }
202
203 pub fn process(&self, config: &GitConfig) -> Result<Self> {
209 let mut commit = self.clone();
210 commit = commit.preprocess(&config.commit_preprocessors)?;
211 if config.conventional_commits {
212 if !config.require_conventional && config.filter_unconventional && !config.split_commits
213 {
214 commit = commit.into_conventional()?;
215 } else if let Ok(conv_commit) = commit.clone().into_conventional() {
216 commit = conv_commit;
217 }
218 }
219
220 commit = commit.parse(
221 &config.commit_parsers,
222 config.protect_breaking_commits,
223 config.filter_commits,
224 )?;
225
226 commit = commit.parse_links(&config.link_parsers);
227
228 Ok(commit)
229 }
230
231 pub fn into_conventional(mut self) -> Result<Self> {
233 match ConventionalCommit::parse(Box::leak(self.raw_message().to_string().into_boxed_str()))
234 {
235 Ok(conv) => {
236 self.conv = Some(conv);
237 Ok(self)
238 }
239 Err(e) => Err(AppError::ParseError(e)),
240 }
241 }
242
243 pub fn preprocess(mut self, preprocessors: &[TextProcessor]) -> Result<Self> {
249 preprocessors.iter().try_for_each(|preprocessor| {
250 preprocessor.replace(&mut self.message, vec![("COMMIT_SHA", &self.id)])?;
251 Ok::<(), AppError>(())
252 })?;
253 Ok(self)
254 }
255
256 fn skip_commit(&self, parser: &CommitParser, protect_breaking: bool) -> bool {
262 parser.skip.unwrap_or(false) &&
263 !(self.conv.as_ref().map(|c| c.breaking()).unwrap_or(false) && protect_breaking)
264 }
265
266 pub fn parse(
273 mut self,
274 parsers: &[CommitParser],
275 protect_breaking: bool,
276 filter: bool,
277 ) -> Result<Self> {
278 let lookup_context = serde_json::to_value(&self).map_err(|e| {
279 AppError::FieldError(format!("failed to convert context into value: {e}",))
280 })?;
281 for parser in parsers {
282 let mut regex_checks = Vec::new();
283 if let Some(message_regex) = parser.message.as_ref() {
284 regex_checks.push((message_regex, self.message.to_string()));
285 }
286 let body = self
287 .conv
288 .as_ref()
289 .and_then(|v| v.body())
290 .map(|v| v.to_string());
291 if let Some(body_regex) = parser.body.as_ref() {
292 regex_checks.push((body_regex, body.clone().unwrap_or_default()));
293 }
294 if let (Some(footer_regex), Some(footers)) = (
295 parser.footer.as_ref(),
296 self.conv.as_ref().map(|v| v.footers()),
297 ) {
298 regex_checks.extend(footers.iter().map(|f| (footer_regex, f.to_string())));
299 }
300 if let (Some(field_name), Some(pattern_regex)) =
301 (parser.field.as_ref(), parser.pattern.as_ref())
302 {
303 let values = if field_name == "body" {
304 vec![body.clone()].into_iter().collect()
305 } else {
306 tera::dotted_pointer(&lookup_context, field_name).and_then(|v| match v {
307 Value::String(s) => Some(vec![s.clone()]),
308 Value::Number(_) | Value::Bool(_) | Value::Null => {
309 Some(vec![v.to_string()])
310 }
311 Value::Array(arr) => {
312 let mut values = Vec::new();
313 for item in arr {
314 match item {
315 Value::String(s) => values.push(s.clone()),
316 Value::Number(_) | Value::Bool(_) | Value::Null => {
317 values.push(item.to_string())
318 }
319 _ => continue,
320 }
321 }
322 Some(values)
323 }
324 _ => None,
325 })
326 };
327 match values {
328 Some(values) => {
329 if values.is_empty() {
330 trace!("field '{field_name}' is present but empty");
331 } else {
332 for value in values {
333 regex_checks.push((pattern_regex, value));
334 }
335 }
336 }
337 None => {
338 return Err(AppError::FieldError(format!(
339 "field '{field_name}' is missing or has unsupported type (expected a \
340 String, Number, Bool, or Null — or an Array of these scalar values)",
341 )));
342 }
343 }
344 }
345 if parser.sha.clone().map(|v| v.to_lowercase()).as_deref() == Some(&self.id) {
346 if self.skip_commit(parser, protect_breaking) {
347 return Err(AppError::GroupError(String::from("Skipping commit")));
348 } else {
349 self.group = parser.group.clone().or(self.group);
350 self.scope = parser.scope.clone().or(self.scope);
351 self.default_scope = parser.default_scope.clone().or(self.default_scope);
352 return Ok(self);
353 }
354 }
355 for (regex, text) in regex_checks {
356 if regex.is_match(text.trim()) {
357 if self.skip_commit(parser, protect_breaking) {
358 return Err(AppError::GroupError(String::from("Skipping commit")));
359 } else {
360 let regex_replace = |mut value: String| {
361 for mat in regex.find_iter(&text) {
362 value = regex.replace(mat.as_str(), value).to_string();
363 }
364 value
365 };
366 self.group = parser.group.clone().map(regex_replace);
367 self.scope = parser.scope.clone().map(regex_replace);
368 self.default_scope.clone_from(&parser.default_scope);
369 return Ok(self);
370 }
371 }
372 }
373 }
374 if filter {
375 Err(AppError::GroupError(String::from(
376 "Commit does not belong to any group",
377 )))
378 } else {
379 Ok(self)
380 }
381 }
382
383 pub fn parse_links(mut self, parsers: &[LinkParser]) -> Self {
389 for parser in parsers {
390 let regex = &parser.pattern;
391 let replace = &parser.href;
392 for mat in regex.find_iter(&self.message) {
393 let m = mat.as_str();
394 let text = if let Some(text_replace) = &parser.text {
395 regex.replace(m, text_replace).to_string()
396 } else {
397 m.to_string()
398 };
399 let href = regex.replace(m, replace);
400 self.links.push(Link {
401 text,
402 href: href.to_string(),
403 });
404 }
405 }
406 self
407 }
408
409 fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
414 self.conv
415 .iter()
416 .flat_map(|conv| conv.footers().iter().map(Footer::from))
417 }
418}
419
420impl Serialize for Commit<'_> {
421 #[allow(deprecated)]
422 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
423 where
424 S: Serializer,
425 {
426 struct SerializeFooters<'a>(&'a Commit<'a>);
430 impl Serialize for SerializeFooters<'_> {
431 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
432 where
433 S: Serializer,
434 {
435 serializer.collect_seq(self.0.footers())
436 }
437 }
438
439 let mut commit = serializer.serialize_struct("Commit", 20)?;
440 commit.serialize_field("id", &self.id)?;
441 if let Some(conv) = &self.conv {
442 commit.serialize_field("message", conv.description())?;
443 commit.serialize_field("body", &conv.body())?;
444 commit.serialize_field("footers", &SerializeFooters(self))?;
445 commit.serialize_field(
446 "group",
447 self.group.as_ref().unwrap_or(&conv.type_().to_string()),
448 )?;
449 commit.serialize_field("breaking_description", &conv.breaking_description())?;
450 commit.serialize_field("breaking", &conv.breaking())?;
451 commit.serialize_field(
452 "scope",
453 &self
454 .scope
455 .as_deref()
456 .or_else(|| conv.scope().map(|v| v.as_str()))
457 .or(self.default_scope.as_deref()),
458 )?;
459 } else {
460 commit.serialize_field("message", &self.message)?;
461 commit.serialize_field("group", &self.group)?;
462 commit.serialize_field(
463 "scope",
464 &self.scope.as_deref().or(self.default_scope.as_deref()),
465 )?;
466 }
467
468 commit.serialize_field("links", &self.links)?;
469 commit.serialize_field("author", &self.author)?;
470 commit.serialize_field("committer", &self.committer)?;
471 commit.serialize_field("conventional", &self.conv.is_some())?;
472 commit.serialize_field("merge_commit", &self.merge_commit)?;
473 commit.serialize_field("extra", &self.extra)?;
474 #[cfg(feature = "github")]
475 commit.serialize_field("github", &self.github)?;
476 #[cfg(feature = "gitlab")]
477 commit.serialize_field("gitlab", &self.gitlab)?;
478 #[cfg(feature = "gitea")]
479 commit.serialize_field("gitea", &self.gitea)?;
480 #[cfg(feature = "bitbucket")]
481 commit.serialize_field("bitbucket", &self.bitbucket)?;
482 if let Some(remote) = &self.remote {
483 commit.serialize_field("remote", remote)?;
484 }
485 commit.serialize_field("raw_message", &self.raw_message())?;
486 commit.end()
487 }
488}
489
490pub(crate) fn commits_to_conventional_commits<'de, 'a, D: Deserializer<'de>>(
498 deserializer: D,
499) -> std::result::Result<Vec<Commit<'a>>, D::Error> {
500 let commits = Vec::<Commit<'a>>::deserialize(deserializer)?;
501 let commits = commits
502 .into_iter()
503 .map(|commit| commit.clone().into_conventional().unwrap_or(commit))
504 .collect();
505 Ok(commits)
506}
507
508#[cfg(test)]
509mod test {
510 use super::*;
511 #[test]
512 fn conventional_commit() -> Result<()> {
513 let test_cases = vec![
514 (
515 Commit::new(
516 String::from("123123"),
517 String::from("test(commit): add test"),
518 ),
519 true,
520 ),
521 (
522 Commit::new(String::from("124124"), String::from("xyz")),
523 false,
524 ),
525 ];
526
527 for (commit, is_conventional) in &test_cases {
528 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
529 }
530
531 let commit = test_cases[0].0.clone().parse(
532 &[CommitParser {
533 sha: None,
534 message: Regex::new("test*").ok(),
535 body: None,
536 footer: None,
537 group: Some(String::from("test_group")),
538 default_scope: Some(String::from("test_scope")),
539 scope: None,
540 skip: None,
541 field: None,
542 pattern: None,
543 }],
544 false,
545 false,
546 )?;
547 assert_eq!(Some(String::from("test_group")), commit.group);
548 assert_eq!(Some(String::from("test_scope")), commit.default_scope);
549
550 Ok(())
551 }
552
553 #[test]
554 fn conventional_footers() {
555 let cfg = crate::config::GitConfig {
556 conventional_commits: true,
557 ..Default::default()
558 };
559 let test_cases = vec![
560 (
561 Commit::new(
562 String::from("123123"),
563 String::from(
564 "test(commit): add test\n\nSigned-off-by: Test User <test@example.com>",
565 ),
566 ),
567 vec![Footer {
568 token: "Signed-off-by",
569 separator: ":",
570 value: "Test User <test@example.com>",
571 breaking: false,
572 }],
573 ),
574 (
575 Commit::new(
576 String::from("123124"),
577 String::from(
578 "fix(commit): break stuff\n\nBREAKING CHANGE: This commit breaks \
579 stuff\nSigned-off-by: Test User <test@example.com>",
580 ),
581 ),
582 vec![
583 Footer {
584 token: "BREAKING CHANGE",
585 separator: ":",
586 value: "This commit breaks stuff",
587 breaking: true,
588 },
589 Footer {
590 token: "Signed-off-by",
591 separator: ":",
592 value: "Test User <test@example.com>",
593 breaking: false,
594 },
595 ],
596 ),
597 ];
598
599 for (commit, footers) in &test_cases {
600 let commit = commit.process(&cfg).expect("commit should process");
601 assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
602 }
603 }
604
605 #[test]
606 fn parse_link() -> Result<()> {
607 let test_cases = vec![
608 (
609 Commit::new(
610 String::from("123123"),
611 String::from("test(commit): add test\n\nBody with issue #123"),
612 ),
613 true,
614 ),
615 (
616 Commit::new(
617 String::from("123123"),
618 String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #456"),
619 ),
620 true,
621 ),
622 ];
623
624 for (commit, is_conventional) in &test_cases {
625 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
626 }
627
628 let commit = Commit::new(
629 String::from("123123"),
630 String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"),
631 );
632
633 let commit = commit.parse_links(&[
634 LinkParser {
635 pattern: Regex::new("RFC(\\d+)")?,
636 href: String::from("rfc://$1"),
637 text: None,
638 },
639 LinkParser {
640 pattern: Regex::new("#(\\d+)")?,
641 href: String::from("https://github.com/$1"),
642 text: None,
643 },
644 ]);
645 assert_eq!(
646 vec![
647 Link {
648 text: String::from("RFC456"),
649 href: String::from("rfc://456"),
650 },
651 Link {
652 text: String::from("#455"),
653 href: String::from("https://github.com/455"),
654 }
655 ],
656 commit.links
657 );
658
659 Ok(())
660 }
661
662 #[test]
663 fn parse_commit() {
664 assert_eq!(
665 Commit::new(String::new(), String::from("test: no sha1 given")),
666 Commit::from(String::from("test: no sha1 given"))
667 );
668
669 assert_eq!(
670 Commit::new(
671 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
672 String::from("feat: do something")
673 ),
674 Commit::from(String::from(
675 "8f55e69eba6e6ce811ace32bd84cc82215673cb6 feat: do something"
676 ))
677 );
678
679 assert_eq!(
680 Commit::new(
681 String::from("3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853"),
682 String::from("chore: do something")
683 ),
684 Commit::from(String::from(
685 "3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853 chore: do something"
686 ))
687 );
688
689 assert_eq!(
690 Commit::new(
691 String::new(),
692 String::from("thisisinvalidsha1 style: add formatting")
693 ),
694 Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
695 );
696 }
697
698 #[test]
699 fn parse_body() -> Result<()> {
700 let mut commit = Commit::new(
701 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
702 String::from(
703 "fix: do something
704
705Introduce something great
706
707BREAKING CHANGE: drop support for something else
708Refs: #123
709",
710 ),
711 );
712 commit.author = Signature {
713 name: Some("John Doe".to_string()),
714 email: None,
715 timestamp: 0x0,
716 };
717 commit.remote = Some(crate::contributor::RemoteContributor {
718 username: None,
719 pr_title: Some("feat: do something".to_string()),
720 pr_number: None,
721 pr_labels: vec![String::from("feature"), String::from("deprecation")],
722 is_first_time: true,
723 });
724 let commit = commit.into_conventional()?;
725 let commit = commit.parse_links(&[
726 LinkParser {
727 pattern: Regex::new("RFC(\\d+)")?,
728 href: String::from("rfc://$1"),
729 text: None,
730 },
731 LinkParser {
732 pattern: Regex::new("#(\\d+)")?,
733 href: String::from("https://github.com/$1"),
734 text: None,
735 },
736 ]);
737
738 let parsed_commit = commit.clone().parse(
739 &[CommitParser {
740 sha: None,
741 message: None,
742 body: Regex::new("something great").ok(),
743 footer: None,
744 group: Some(String::from("Test group")),
745 default_scope: None,
746 scope: None,
747 skip: None,
748 field: None,
749 pattern: None,
750 }],
751 false,
752 false,
753 )?;
754 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
755
756 Ok(())
757 }
758
759 #[test]
760 fn parse_commit_field() -> Result<()> {
761 let mut commit = Commit::new(
762 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
763 String::from(
764 "fix: do something
765
766Introduce something great
767
768BREAKING CHANGE: drop support for something else
769Refs: #123
770",
771 ),
772 );
773 commit.author = Signature {
774 name: Some("John Doe".to_string()),
775 email: None,
776 timestamp: 0x0,
777 };
778 commit.remote = Some(crate::contributor::RemoteContributor {
779 username: None,
780 pr_title: Some("feat: do something".to_string()),
781 pr_number: None,
782 pr_labels: vec![String::from("feature"), String::from("deprecation")],
783 is_first_time: true,
784 });
785 let commit = commit.into_conventional()?;
786 let commit = commit.parse_links(&[
787 LinkParser {
788 pattern: Regex::new("RFC(\\d+)")?,
789 href: String::from("rfc://$1"),
790 text: None,
791 },
792 LinkParser {
793 pattern: Regex::new("#(\\d+)")?,
794 href: String::from("https://github.com/$1"),
795 text: None,
796 },
797 ]);
798
799 let parsed_commit = commit.clone().parse(
800 &[CommitParser {
801 sha: None,
802 message: None,
803 body: None,
804 footer: None,
805 group: Some(String::from("Test group")),
806 default_scope: None,
807 scope: None,
808 skip: None,
809 field: Some(String::from("author.name")),
810 pattern: Regex::new("John Doe").ok(),
811 }],
812 false,
813 false,
814 )?;
815 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
816
817 let parsed_commit = commit.clone().parse(
818 &[CommitParser {
819 sha: None,
820 message: None,
821 body: None,
822 footer: None,
823 group: Some(String::from("Test group")),
824 default_scope: None,
825 scope: None,
826 skip: None,
827 field: Some(String::from("remote.pr_title")),
828 pattern: Regex::new("feat: do something").ok(),
829 }],
830 false,
831 false,
832 )?;
833 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
834
835 let parsed_commit = commit.clone().parse(
836 &[CommitParser {
837 sha: None,
838 message: None,
839 body: None,
840 footer: None,
841 group: Some(String::from("Test group")),
842 default_scope: None,
843 scope: None,
844 skip: None,
845 field: Some(String::from("body")),
846 pattern: Regex::new("something great").ok(),
847 }],
848 false,
849 false,
850 )?;
851 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
852
853 let parsed_commit = commit.clone().parse(
854 &[CommitParser {
855 sha: None,
856 message: None,
857 body: None,
858 footer: None,
859 group: Some(String::from("Test group")),
860 default_scope: None,
861 scope: None,
862 skip: None,
863 field: Some(String::from("remote.pr_labels")),
864 pattern: Regex::new("feature|deprecation").ok(),
865 }],
866 false,
867 false,
868 )?;
869 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
870
871 let parsed_commit = commit.clone().parse(
872 &[CommitParser {
873 sha: None,
874 message: None,
875 body: None,
876 footer: None,
877 group: Some(String::from("Test group")),
878 default_scope: None,
879 scope: None,
880 skip: None,
881 field: Some(String::from("links")),
882 pattern: Regex::new(".*").ok(),
883 }],
884 false,
885 false,
886 )?;
887 assert_eq!(None, parsed_commit.group);
888
889 let parse_result = commit.clone().parse(
890 &[CommitParser {
891 sha: None,
892 message: None,
893 body: None,
894 footer: None,
895 group: Some(String::from("Test group")),
896 default_scope: None,
897 scope: None,
898 skip: None,
899 field: Some(String::from("remote")),
900 pattern: Regex::new(".*").ok(),
901 }],
902 false,
903 false,
904 );
905 assert!(
906 parse_result.is_err(),
907 "Expected error when using unsupported field `remote`, but got Ok"
908 );
909
910 Ok(())
911 }
912
913 #[test]
914 fn commit_sha() -> Result<()> {
915 let commit = Commit::new(
916 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
917 String::from("feat: do something"),
918 );
919
920 let parsed_commit = commit.clone().parse(
921 &[CommitParser {
922 sha: Some(String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6")),
923 message: None,
924 body: None,
925 footer: None,
926 group: None,
927 default_scope: None,
928 scope: None,
929 skip: Some(true),
930 field: None,
931 pattern: None,
932 }],
933 false,
934 false,
935 );
936 assert!(
937 parsed_commit.is_err(),
938 "Expected error when parsing with `skip: Some(true)`, but got Ok"
939 );
940
941 Ok(())
942 }
943
944 #[test]
945 fn field_name_regex() -> Result<()> {
946 let mut commit = Commit::new(
947 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
948 String::from("feat: do something"),
949 );
950 commit.author = Signature {
951 name: Some("John Doe".to_string()),
952 email: None,
953 timestamp: 0x0,
954 };
955 commit.remote = Some(crate::contributor::RemoteContributor {
956 username: None,
957 pr_title: Some("feat: do something".to_string()),
958 pr_number: None,
959 pr_labels: Vec::new(),
960 is_first_time: true,
961 });
962
963 let parsed_commit = commit.clone().parse(
964 &[CommitParser {
965 sha: None,
966 message: None,
967 body: None,
968 footer: None,
969 group: Some(String::from("Test group")),
970 default_scope: None,
971 scope: None,
972 skip: None,
973 field: Some(String::from("author.name")),
974 pattern: Regex::new("^John Doe$").ok(),
975 }],
976 false,
977 false,
978 )?;
979 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
980
981 let parsed_commit = commit.clone().parse(
982 &[CommitParser {
983 sha: None,
984 message: None,
985 body: None,
986 footer: None,
987 group: Some(String::from("Test group")),
988 default_scope: None,
989 scope: None,
990 skip: None,
991 field: Some(String::from("remote.pr_title")),
992 pattern: Regex::new("^feat(\\([^)]+\\))?").ok(),
993 }],
994 false,
995 false,
996 )?;
997 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
998
999 let parse_result = commit.parse(
1000 &[CommitParser {
1001 sha: None,
1002 message: None,
1003 body: None,
1004 footer: None,
1005 group: Some(String::from("Test group")),
1006 default_scope: None,
1007 scope: None,
1008 skip: None,
1009 field: Some(String::from("author.name")),
1010 pattern: Regex::new("Something else").ok(),
1011 }],
1012 false,
1013 true,
1014 );
1015 assert!(
1016 parse_result.is_err(),
1017 "Expected error because `author.name` did not match the given pattern, but got Ok"
1018 );
1019
1020 Ok(())
1021 }
1022}