1use crate::config::{
2 CommitParser,
3 GitConfig,
4 LinkParser,
5 TextProcessor,
6};
7use crate::error::{
8 Error as AppError,
9 Result,
10};
11use git_conventional::{
12 Commit as ConventionalCommit,
13 Footer as ConventionalFooter,
14};
15#[cfg(feature = "repo")]
16use git2::{
17 Commit as GitCommit,
18 Signature as CommitSignature,
19};
20use lazy_regex::{
21 Lazy,
22 Regex,
23 lazy_regex,
24};
25use serde::ser::{
26 SerializeStruct,
27 Serializer,
28};
29use serde::{
30 Deserialize,
31 Deserializer,
32 Serialize,
33};
34use serde_json::value::Value;
35
36static SHA1_REGEX: Lazy<Regex> = lazy_regex!(r#"^\b([a-f0-9]{40})\b (.*)$"#);
39
40#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
42#[serde(rename_all(serialize = "camelCase"))]
43pub struct Link {
44 pub text: String,
46 pub href: String,
48}
49
50#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
52struct Footer<'a> {
53 token: &'a str,
58 separator: &'a str,
62 value: &'a str,
64 breaking: bool,
66}
67
68impl<'a> From<&'a ConventionalFooter<'a>> for Footer<'a> {
69 fn from(footer: &'a ConventionalFooter<'a>) -> Self {
70 Self {
71 token: footer.token().as_str(),
72 separator: footer.separator().as_str(),
73 value: footer.value(),
74 breaking: footer.breaking(),
75 }
76 }
77}
78
79#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
81pub struct Signature {
82 pub name: Option<String>,
84 pub email: Option<String>,
86 pub timestamp: i64,
88}
89
90#[cfg(feature = "repo")]
91impl<'a> From<CommitSignature<'a>> for Signature {
92 fn from(signature: CommitSignature<'a>) -> Self {
93 Self {
94 name: signature.name().map(String::from),
95 email: signature.email().map(String::from),
96 timestamp: signature.when().seconds(),
97 }
98 }
99}
100
101#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct Range {
104 from: String,
106 to: String,
108}
109
110impl Range {
111 pub fn new(from: &Commit, to: &Commit) -> Self {
113 Self {
114 from: from.id.clone(),
115 to: to.id.clone(),
116 }
117 }
118}
119
120#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
122#[serde(rename_all(serialize = "camelCase"))]
123pub struct Commit<'a> {
124 pub id: String,
126 pub message: String,
128 #[serde(skip_deserializing)]
130 pub conv: Option<ConventionalCommit<'a>>,
131 pub group: Option<String>,
133 pub default_scope: Option<String>,
136 pub scope: Option<String>,
138 pub links: Vec<Link>,
140 pub author: Signature,
142 pub committer: Signature,
144 pub merge_commit: bool,
146 pub extra: Option<Value>,
148 pub remote: Option<crate::contributor::RemoteContributor>,
150 #[cfg(feature = "github")]
152 #[deprecated(note = "Use `remote` field instead")]
153 pub github: crate::contributor::RemoteContributor,
154 #[cfg(feature = "gitlab")]
156 #[deprecated(note = "Use `remote` field instead")]
157 pub gitlab: crate::contributor::RemoteContributor,
158 #[cfg(feature = "gitea")]
160 #[deprecated(note = "Use `remote` field instead")]
161 pub gitea: crate::contributor::RemoteContributor,
162 #[cfg(feature = "bitbucket")]
164 #[deprecated(note = "Use `remote` field instead")]
165 pub bitbucket: crate::contributor::RemoteContributor,
166
167 pub raw_message: Option<String>,
174}
175
176impl From<String> for Commit<'_> {
177 fn from(message: String) -> Self {
178 if let Some(captures) = SHA1_REGEX.captures(&message) {
179 if let (Some(id), Some(message)) = (
180 captures.get(1).map(|v| v.as_str()),
181 captures.get(2).map(|v| v.as_str()),
182 ) {
183 return Commit {
184 id: id.to_string(),
185 message: message.to_string(),
186 ..Default::default()
187 };
188 }
189 }
190 Commit {
191 id: String::new(),
192 message,
193 ..Default::default()
194 }
195 }
196}
197
198#[cfg(feature = "repo")]
199impl From<&GitCommit<'_>> for Commit<'_> {
200 fn from(commit: &GitCommit<'_>) -> Self {
201 Commit {
202 id: commit.id().to_string(),
203 message: commit.message().unwrap_or_default().trim_end().to_string(),
204 author: commit.author().into(),
205 committer: commit.committer().into(),
206 merge_commit: commit.parent_count() > 1,
207 ..Default::default()
208 }
209 }
210}
211
212impl Commit<'_> {
213 pub fn new(id: String, message: String) -> Self {
215 Self {
216 id,
217 message,
218 ..Default::default()
219 }
220 }
221
222 pub fn raw_message(&self) -> &str {
224 self.raw_message.as_deref().unwrap_or(&self.message)
225 }
226
227 pub fn process(&self, config: &GitConfig) -> Result<Self> {
233 let mut commit = self.clone();
234 commit = commit.preprocess(&config.commit_preprocessors)?;
235 if config.conventional_commits {
236 if !config.require_conventional &&
237 config.filter_unconventional &&
238 !config.split_commits
239 {
240 commit = commit.into_conventional()?;
241 } else if let Ok(conv_commit) = commit.clone().into_conventional() {
242 commit = conv_commit;
243 }
244 }
245
246 commit = commit.parse(
247 &config.commit_parsers,
248 config.protect_breaking_commits,
249 config.filter_commits,
250 )?;
251
252 commit = commit.parse_links(&config.link_parsers)?;
253
254 Ok(commit)
255 }
256
257 pub fn into_conventional(mut self) -> Result<Self> {
259 match ConventionalCommit::parse(Box::leak(
260 self.raw_message().to_string().into_boxed_str(),
261 )) {
262 Ok(conv) => {
263 self.conv = Some(conv);
264 Ok(self)
265 }
266 Err(e) => Err(AppError::ParseError(e)),
267 }
268 }
269
270 pub fn preprocess(mut self, preprocessors: &[TextProcessor]) -> Result<Self> {
276 preprocessors.iter().try_for_each(|preprocessor| {
277 preprocessor
278 .replace(&mut self.message, vec![("COMMIT_SHA", &self.id)])?;
279 Ok::<(), AppError>(())
280 })?;
281 Ok(self)
282 }
283
284 fn skip_commit(&self, parser: &CommitParser, protect_breaking: bool) -> bool {
290 parser.skip.unwrap_or(false) &&
291 !(self.conv.as_ref().map(|c| c.breaking()).unwrap_or(false) &&
292 protect_breaking)
293 }
294
295 pub fn parse(
302 mut self,
303 parsers: &[CommitParser],
304 protect_breaking: bool,
305 filter: bool,
306 ) -> Result<Self> {
307 let lookup_context = serde_json::to_value(&self).map_err(|e| {
308 AppError::FieldError(format!(
309 "failed to convert context into value: {e}",
310 ))
311 })?;
312 for parser in parsers {
313 let mut regex_checks = Vec::new();
314 if let Some(message_regex) = parser.message.as_ref() {
315 regex_checks.push((message_regex, self.message.to_string()));
316 }
317 let body = self
318 .conv
319 .as_ref()
320 .and_then(|v| v.body())
321 .map(|v| v.to_string());
322 if let Some(body_regex) = parser.body.as_ref() {
323 regex_checks.push((body_regex, body.clone().unwrap_or_default()));
324 }
325 if let (Some(footer_regex), Some(footers)) = (
326 parser.footer.as_ref(),
327 self.conv.as_ref().map(|v| v.footers()),
328 ) {
329 regex_checks
330 .extend(footers.iter().map(|f| (footer_regex, f.to_string())));
331 }
332 if let (Some(field_name), Some(pattern_regex)) =
333 (parser.field.as_ref(), parser.pattern.as_ref())
334 {
335 let value = if field_name == "body" {
336 body.clone()
337 } else {
338 tera::dotted_pointer(&lookup_context, field_name).and_then(|v| {
339 match &v {
340 Value::String(s) => Some(s.clone()),
341 Value::Number(_) | Value::Bool(_) | Value::Null => {
342 Some(v.to_string())
343 }
344 _ => None,
345 }
346 })
347 };
348 match value {
349 Some(value) => {
350 regex_checks.push((pattern_regex, value));
351 }
352 None => {
353 return Err(AppError::FieldError(format!(
354 "field '{field_name}' is missing or has unsupported \
355 type (expected String, Number, Bool, or Null)",
356 )));
357 }
358 }
359 }
360 if parser.sha.clone().map(|v| v.to_lowercase()).as_deref() ==
361 Some(&self.id)
362 {
363 if self.skip_commit(parser, protect_breaking) {
364 return Err(AppError::GroupError(String::from(
365 "Skipping commit",
366 )));
367 } else {
368 self.group = parser.group.clone().or(self.group);
369 self.scope = parser.scope.clone().or(self.scope);
370 self.default_scope =
371 parser.default_scope.clone().or(self.default_scope);
372 return Ok(self);
373 }
374 }
375 for (regex, text) in regex_checks {
376 if regex.is_match(text.trim()) {
377 if self.skip_commit(parser, protect_breaking) {
378 return Err(AppError::GroupError(String::from(
379 "Skipping commit",
380 )));
381 } else {
382 let regex_replace = |mut value: String| {
383 for mat in regex.find_iter(&text) {
384 value =
385 regex.replace(mat.as_str(), value).to_string();
386 }
387 value
388 };
389 self.group = parser.group.clone().map(regex_replace);
390 self.scope = parser.scope.clone().map(regex_replace);
391 self.default_scope.clone_from(&parser.default_scope);
392 return Ok(self);
393 }
394 }
395 }
396 }
397 if filter {
398 Err(AppError::GroupError(String::from(
399 "Commit does not belong to any group",
400 )))
401 } else {
402 Ok(self)
403 }
404 }
405
406 pub fn parse_links(mut self, parsers: &[LinkParser]) -> Result<Self> {
412 for parser in parsers {
413 let regex = &parser.pattern;
414 let replace = &parser.href;
415 for mat in regex.find_iter(&self.message) {
416 let m = mat.as_str();
417 let text = if let Some(text_replace) = &parser.text {
418 regex.replace(m, text_replace).to_string()
419 } else {
420 m.to_string()
421 };
422 let href = regex.replace(m, replace);
423 self.links.push(Link {
424 text,
425 href: href.to_string(),
426 });
427 }
428 }
429 Ok(self)
430 }
431
432 fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
437 self.conv
438 .iter()
439 .flat_map(|conv| conv.footers().iter().map(Footer::from))
440 }
441}
442
443impl Serialize for Commit<'_> {
444 #[allow(deprecated)]
445 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
446 where
447 S: Serializer,
448 {
449 struct SerializeFooters<'a>(&'a Commit<'a>);
453 impl Serialize for SerializeFooters<'_> {
454 fn serialize<S>(
455 &self,
456 serializer: S,
457 ) -> std::result::Result<S::Ok, S::Error>
458 where
459 S: Serializer,
460 {
461 serializer.collect_seq(self.0.footers())
462 }
463 }
464
465 let mut commit = serializer.serialize_struct("Commit", 20)?;
466 commit.serialize_field("id", &self.id)?;
467 if let Some(conv) = &self.conv {
468 commit.serialize_field("message", conv.description())?;
469 commit.serialize_field("body", &conv.body())?;
470 commit.serialize_field("footers", &SerializeFooters(self))?;
471 commit.serialize_field(
472 "group",
473 self.group.as_ref().unwrap_or(&conv.type_().to_string()),
474 )?;
475 commit.serialize_field(
476 "breaking_description",
477 &conv.breaking_description(),
478 )?;
479 commit.serialize_field("breaking", &conv.breaking())?;
480 commit.serialize_field(
481 "scope",
482 &self
483 .scope
484 .as_deref()
485 .or_else(|| conv.scope().map(|v| v.as_str()))
486 .or(self.default_scope.as_deref()),
487 )?;
488 } else {
489 commit.serialize_field("message", &self.message)?;
490 commit.serialize_field("group", &self.group)?;
491 commit.serialize_field(
492 "scope",
493 &self.scope.as_deref().or(self.default_scope.as_deref()),
494 )?;
495 }
496
497 commit.serialize_field("links", &self.links)?;
498 commit.serialize_field("author", &self.author)?;
499 commit.serialize_field("committer", &self.committer)?;
500 commit.serialize_field("conventional", &self.conv.is_some())?;
501 commit.serialize_field("merge_commit", &self.merge_commit)?;
502 commit.serialize_field("extra", &self.extra)?;
503 #[cfg(feature = "github")]
504 commit.serialize_field("github", &self.github)?;
505 #[cfg(feature = "gitlab")]
506 commit.serialize_field("gitlab", &self.gitlab)?;
507 #[cfg(feature = "gitea")]
508 commit.serialize_field("gitea", &self.gitea)?;
509 #[cfg(feature = "bitbucket")]
510 commit.serialize_field("bitbucket", &self.bitbucket)?;
511 if let Some(remote) = &self.remote {
512 commit.serialize_field("remote", remote)?;
513 }
514 commit.serialize_field("raw_message", &self.raw_message())?;
515 commit.end()
516 }
517}
518
519pub(crate) fn commits_to_conventional_commits<'de, 'a, D: Deserializer<'de>>(
527 deserializer: D,
528) -> std::result::Result<Vec<Commit<'a>>, D::Error> {
529 let commits = Vec::<Commit<'a>>::deserialize(deserializer)?;
530 let commits = commits
531 .into_iter()
532 .map(|commit| commit.clone().into_conventional().unwrap_or(commit))
533 .collect();
534 Ok(commits)
535}
536
537#[cfg(test)]
538mod test {
539 use super::*;
540 #[test]
541 fn conventional_commit() -> Result<()> {
542 let test_cases = vec![
543 (
544 Commit::new(
545 String::from("123123"),
546 String::from("test(commit): add test"),
547 ),
548 true,
549 ),
550 (
551 Commit::new(String::from("124124"), String::from("xyz")),
552 false,
553 ),
554 ];
555 for (commit, is_conventional) in &test_cases {
556 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
557 }
558 let commit = test_cases[0].0.clone().parse(
559 &[CommitParser {
560 sha: None,
561 message: Regex::new("test*").ok(),
562 body: None,
563 footer: None,
564 group: Some(String::from("test_group")),
565 default_scope: Some(String::from("test_scope")),
566 scope: None,
567 skip: None,
568 field: None,
569 pattern: None,
570 }],
571 false,
572 false,
573 )?;
574 assert_eq!(Some(String::from("test_group")), commit.group);
575 assert_eq!(Some(String::from("test_scope")), commit.default_scope);
576 Ok(())
577 }
578
579 #[test]
580 fn conventional_footers() {
581 let cfg = crate::config::GitConfig {
582 conventional_commits: true,
583 ..Default::default()
584 };
585 let test_cases = vec![
586 (
587 Commit::new(
588 String::from("123123"),
589 String::from(
590 "test(commit): add test\n\nSigned-off-by: Test User \
591 <test@example.com>",
592 ),
593 ),
594 vec![Footer {
595 token: "Signed-off-by",
596 separator: ":",
597 value: "Test User <test@example.com>",
598 breaking: false,
599 }],
600 ),
601 (
602 Commit::new(
603 String::from("123124"),
604 String::from(
605 "fix(commit): break stuff\n\nBREAKING CHANGE: This commit \
606 breaks stuff\nSigned-off-by: Test User <test@example.com>",
607 ),
608 ),
609 vec![
610 Footer {
611 token: "BREAKING CHANGE",
612 separator: ":",
613 value: "This commit breaks stuff",
614 breaking: true,
615 },
616 Footer {
617 token: "Signed-off-by",
618 separator: ":",
619 value: "Test User <test@example.com>",
620 breaking: false,
621 },
622 ],
623 ),
624 ];
625 for (commit, footers) in &test_cases {
626 let commit = commit.process(&cfg).expect("commit should process");
627 assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
628 }
629 }
630
631 #[test]
632 fn parse_link() -> Result<()> {
633 let test_cases = vec![
634 (
635 Commit::new(
636 String::from("123123"),
637 String::from("test(commit): add test\n\nBody with issue #123"),
638 ),
639 true,
640 ),
641 (
642 Commit::new(
643 String::from("123123"),
644 String::from(
645 "test(commit): add test\n\nImlement RFC456\n\nFixes: #456",
646 ),
647 ),
648 true,
649 ),
650 ];
651 for (commit, is_conventional) in &test_cases {
652 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
653 }
654 let commit = Commit::new(
655 String::from("123123"),
656 String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"),
657 );
658 let commit = commit.parse_links(&[
659 LinkParser {
660 pattern: Regex::new("RFC(\\d+)")?,
661 href: String::from("rfc://$1"),
662 text: None,
663 },
664 LinkParser {
665 pattern: Regex::new("#(\\d+)")?,
666 href: String::from("https://github.com/$1"),
667 text: None,
668 },
669 ])?;
670 assert_eq!(
671 vec![
672 Link {
673 text: String::from("RFC456"),
674 href: String::from("rfc://456"),
675 },
676 Link {
677 text: String::from("#455"),
678 href: String::from("https://github.com/455"),
679 }
680 ],
681 commit.links
682 );
683 Ok(())
684 }
685
686 #[test]
687 fn parse_commit() {
688 assert_eq!(
689 Commit::new(String::new(), String::from("test: no sha1 given")),
690 Commit::from(String::from("test: no sha1 given"))
691 );
692 assert_eq!(
693 Commit::new(
694 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
695 String::from("feat: do something")
696 ),
697 Commit::from(String::from(
698 "8f55e69eba6e6ce811ace32bd84cc82215673cb6 feat: do something"
699 ))
700 );
701 assert_eq!(
702 Commit::new(
703 String::from("3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853"),
704 String::from("chore: do something")
705 ),
706 Commit::from(String::from(
707 "3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853 chore: do something"
708 ))
709 );
710 assert_eq!(
711 Commit::new(
712 String::new(),
713 String::from("thisisinvalidsha1 style: add formatting")
714 ),
715 Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
716 );
717 }
718
719 #[test]
720 fn parse_commit_fields() -> Result<()> {
721 let mut commit = Commit::new(
722 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
723 String::from("feat: do something"),
724 );
725
726 commit.author = Signature {
727 name: Some("John Doe".to_string()),
728 email: None,
729 timestamp: 0x0,
730 };
731
732 commit.remote = Some(crate::contributor::RemoteContributor {
733 username: None,
734 pr_title: Some("feat: do something".to_string()),
735 pr_number: None,
736 pr_labels: Vec::new(),
737 is_first_time: true,
738 });
739
740 let parsed_commit = commit.clone().parse(
741 &[CommitParser {
742 sha: None,
743 message: None,
744 body: None,
745 footer: None,
746 group: Some(String::from("Test group")),
747 default_scope: None,
748 scope: None,
749 skip: None,
750 field: Some(String::from("author.name")),
751 pattern: Regex::new("John Doe").ok(),
752 }],
753 false,
754 false,
755 )?;
756
757 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
758
759 let parsed_commit = commit.clone().parse(
760 &[CommitParser {
761 sha: None,
762 message: None,
763 body: None,
764 footer: None,
765 group: Some(String::from("Test group")),
766 default_scope: None,
767 scope: None,
768 skip: None,
769 field: Some(String::from("remote.pr_title")),
770 pattern: Regex::new("^feat").ok(),
771 }],
772 false,
773 false,
774 )?;
775
776 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
777
778 let parse_result = commit.clone().parse(
779 &[CommitParser {
780 sha: None,
781 message: None,
782 body: None,
783 footer: None,
784 group: Some(String::from("Invalid group")),
785 default_scope: None,
786 scope: None,
787 skip: None,
788 field: Some(String::from("remote.pr_labels")),
789 pattern: Regex::new(".*").ok(),
790 }],
791 false,
792 false,
793 );
794
795 assert!(
796 parse_result.is_err(),
797 "Expected error when using unsupported field `remote.pr_labels`, but \
798 got Ok"
799 );
800
801 Ok(())
802 }
803
804 #[test]
805 fn commit_sha() -> Result<()> {
806 let commit = Commit::new(
807 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
808 String::from("feat: do something"),
809 );
810 let parsed_commit = commit.clone().parse(
811 &[CommitParser {
812 sha: Some(String::from(
813 "8f55e69eba6e6ce811ace32bd84cc82215673cb6",
814 )),
815 message: None,
816 body: None,
817 footer: None,
818 group: None,
819 default_scope: None,
820 scope: None,
821 skip: Some(true),
822 field: None,
823 pattern: None,
824 }],
825 false,
826 false,
827 );
828 assert!(parsed_commit.is_err());
829
830 let parsed_commit = commit.parse(
831 &[CommitParser {
832 sha: Some(String::from(
833 "8f55e69eba6e6ce811ace32bd84cc82215673cb6",
834 )),
835 message: None,
836 body: None,
837 footer: None,
838 group: Some(String::from("Test group")),
839 default_scope: None,
840 scope: None,
841 skip: None,
842 field: None,
843 pattern: None,
844 }],
845 false,
846 false,
847 )?;
848 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
849
850 Ok(())
851 }
852
853 #[test]
854 fn field_name_regex() -> Result<()> {
855 let commit = Commit {
856 message: String::from("feat: do something"),
857 author: Signature {
858 name: Some("John Doe".to_string()),
859 email: None,
860 timestamp: 0x0,
861 },
862 ..Default::default()
863 };
864 let parsed_commit = commit.clone().parse(
865 &[CommitParser {
866 sha: None,
867 message: None,
868 body: None,
869 footer: None,
870 group: Some(String::from("Test group")),
871 default_scope: None,
872 scope: None,
873 skip: None,
874 field: Some(String::from("author.name")),
875 pattern: Regex::new("Something else").ok(),
876 }],
877 false,
878 true,
879 );
880
881 assert!(parsed_commit.is_err());
882
883 let parsed_commit = commit.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("author.name")),
894 pattern: Regex::new("John Doe").ok(),
895 }],
896 false,
897 false,
898 )?;
899
900 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
901 Ok(())
902 }
903}