1use crate::config::{
2 CommitParser,
3 GitConfig,
4 LinkParser,
5 TextProcessor,
6};
7use crate::error::{
8 Error as AppError,
9 Result,
10};
11#[cfg(feature = "repo")]
12use git2::{
13 Commit as GitCommit,
14 Signature as CommitSignature,
15};
16use git_conventional::{
17 Commit as ConventionalCommit,
18 Footer as ConventionalFooter,
19};
20use lazy_regex::{
21 lazy_regex,
22 Lazy,
23 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(Debug, Default, Clone, PartialEq, Deserialize)]
103#[serde(rename_all(serialize = "camelCase"))]
104pub struct Commit<'a> {
105 pub id: String,
107 pub message: String,
109 #[serde(skip_deserializing)]
111 pub conv: Option<ConventionalCommit<'a>>,
112 pub group: Option<String>,
114 pub default_scope: Option<String>,
117 pub scope: Option<String>,
119 pub links: Vec<Link>,
121 pub author: Signature,
123 pub committer: Signature,
125 pub merge_commit: bool,
127 pub extra: Option<Value>,
129 pub remote: Option<crate::contributor::RemoteContributor>,
131 #[cfg(feature = "github")]
133 #[deprecated(note = "Use `remote` field instead")]
134 pub github: crate::contributor::RemoteContributor,
135 #[cfg(feature = "gitlab")]
137 #[deprecated(note = "Use `remote` field instead")]
138 pub gitlab: crate::contributor::RemoteContributor,
139 #[cfg(feature = "gitea")]
141 #[deprecated(note = "Use `remote` field instead")]
142 pub gitea: crate::contributor::RemoteContributor,
143 #[cfg(feature = "bitbucket")]
145 #[deprecated(note = "Use `remote` field instead")]
146 pub bitbucket: crate::contributor::RemoteContributor,
147
148 pub raw_message: Option<String>,
155}
156
157impl From<String> for Commit<'_> {
158 fn from(message: String) -> Self {
159 if let Some(captures) = SHA1_REGEX.captures(&message) {
160 if let (Some(id), Some(message)) = (
161 captures.get(1).map(|v| v.as_str()),
162 captures.get(2).map(|v| v.as_str()),
163 ) {
164 return Commit {
165 id: id.to_string(),
166 message: message.to_string(),
167 ..Default::default()
168 };
169 }
170 }
171 Commit {
172 id: String::new(),
173 message,
174 ..Default::default()
175 }
176 }
177}
178
179#[cfg(feature = "repo")]
180impl<'a> From<&GitCommit<'a>> for Commit<'a> {
181 fn from(commit: &GitCommit<'a>) -> Self {
182 Commit {
183 id: commit.id().to_string(),
184 message: commit.message().unwrap_or_default().trim_end().to_string(),
185 author: commit.author().into(),
186 committer: commit.committer().into(),
187 merge_commit: commit.parent_count() > 1,
188 ..Default::default()
189 }
190 }
191}
192
193impl Commit<'_> {
194 pub fn new(id: String, message: String) -> Self {
196 Self {
197 id,
198 message,
199 ..Default::default()
200 }
201 }
202
203 pub fn raw_message(&self) -> &str {
205 self.raw_message.as_deref().unwrap_or(&self.message)
206 }
207
208 pub fn process(&self, config: &GitConfig) -> Result<Self> {
214 let mut commit = self.clone();
215 if let Some(preprocessors) = &config.commit_preprocessors {
216 commit = commit.preprocess(preprocessors)?;
217 }
218 if config.conventional_commits.unwrap_or(true) {
219 if config.filter_unconventional.unwrap_or(true) &&
220 !config.split_commits.unwrap_or(false)
221 {
222 commit = commit.into_conventional()?;
223 } else if let Ok(conv_commit) = commit.clone().into_conventional() {
224 commit = conv_commit;
225 }
226 }
227 if let Some(parsers) = &config.commit_parsers {
228 commit = commit.parse(
229 parsers,
230 config.protect_breaking_commits.unwrap_or(false),
231 config.filter_commits.unwrap_or(false),
232 )?;
233 }
234 if let Some(parsers) = &config.link_parsers {
235 commit = commit.parse_links(parsers)?;
236 }
237 Ok(commit)
238 }
239
240 pub fn into_conventional(mut self) -> Result<Self> {
242 match ConventionalCommit::parse(Box::leak(
243 self.raw_message().to_string().into_boxed_str(),
244 )) {
245 Ok(conv) => {
246 self.conv = Some(conv);
247 Ok(self)
248 }
249 Err(e) => Err(AppError::ParseError(e)),
250 }
251 }
252
253 pub fn preprocess(mut self, preprocessors: &[TextProcessor]) -> Result<Self> {
259 preprocessors.iter().try_for_each(|preprocessor| {
260 preprocessor
261 .replace(&mut self.message, vec![("COMMIT_SHA", &self.id)])?;
262 Ok::<(), AppError>(())
263 })?;
264 Ok(self)
265 }
266
267 fn skip_commit(&self, parser: &CommitParser, protect_breaking: bool) -> bool {
273 parser.skip.unwrap_or(false) &&
274 !(self.conv.as_ref().map(|c| c.breaking()).unwrap_or(false) &&
275 protect_breaking)
276 }
277
278 pub fn parse(
285 mut self,
286 parsers: &[CommitParser],
287 protect_breaking: bool,
288 filter: bool,
289 ) -> Result<Self> {
290 let lookup_context = serde_json::to_value(&self).map_err(|e| {
291 AppError::FieldError(format!(
292 "failed to convert context into value: {e}",
293 ))
294 })?;
295 for parser in parsers {
296 let mut regex_checks = Vec::new();
297 if let Some(message_regex) = parser.message.as_ref() {
298 regex_checks.push((message_regex, self.message.to_string()));
299 }
300 let body = self
301 .conv
302 .as_ref()
303 .and_then(|v| v.body())
304 .map(|v| v.to_string());
305 if let Some(body_regex) = parser.body.as_ref() {
306 regex_checks.push((body_regex, body.clone().unwrap_or_default()));
307 }
308 if let (Some(footer_regex), Some(footers)) = (
309 parser.footer.as_ref(),
310 self.conv.as_ref().map(|v| v.footers()),
311 ) {
312 regex_checks
313 .extend(footers.iter().map(|f| (footer_regex, f.to_string())));
314 }
315 if let (Some(field_name), Some(pattern_regex)) =
316 (parser.field.as_ref(), parser.pattern.as_ref())
317 {
318 let value = if field_name == "body" {
319 body.clone()
320 } else {
321 tera::dotted_pointer(&lookup_context, field_name)
322 .map(|v| v.to_string())
323 };
324 match value {
325 Some(value) => {
326 regex_checks.push((pattern_regex, value));
327 }
328 None => {
329 return Err(AppError::FieldError(format!(
330 "field {field_name} does not have a value",
331 )));
332 }
333 }
334 }
335 if parser.sha.clone().map(|v| v.to_lowercase()).as_deref() ==
336 Some(&self.id)
337 {
338 if self.skip_commit(parser, protect_breaking) {
339 return Err(AppError::GroupError(String::from(
340 "Skipping commit",
341 )));
342 } else {
343 self.group = parser.group.clone().or(self.group);
344 self.scope = parser.scope.clone().or(self.scope);
345 self.default_scope =
346 parser.default_scope.clone().or(self.default_scope);
347 return Ok(self);
348 }
349 }
350 for (regex, text) in regex_checks {
351 if regex.is_match(text.trim()) {
352 if self.skip_commit(parser, protect_breaking) {
353 return Err(AppError::GroupError(String::from(
354 "Skipping commit",
355 )));
356 } else {
357 let regex_replace = |mut value: String| {
358 for mat in regex.find_iter(&text) {
359 value =
360 regex.replace(mat.as_str(), value).to_string();
361 }
362 value
363 };
364 self.group = parser.group.clone().map(regex_replace);
365 self.scope = parser.scope.clone().map(regex_replace);
366 self.default_scope.clone_from(&parser.default_scope);
367 return Ok(self);
368 }
369 }
370 }
371 }
372 if filter {
373 Err(AppError::GroupError(String::from(
374 "Commit does not belong to any group",
375 )))
376 } else {
377 Ok(self)
378 }
379 }
380
381 pub fn parse_links(mut self, parsers: &[LinkParser]) -> Result<Self> {
387 for parser in parsers {
388 let regex = &parser.pattern;
389 let replace = &parser.href;
390 for mat in regex.find_iter(&self.message) {
391 let m = mat.as_str();
392 let text = if let Some(text_replace) = &parser.text {
393 regex.replace(m, text_replace).to_string()
394 } else {
395 m.to_string()
396 };
397 let href = regex.replace(m, replace);
398 self.links.push(Link {
399 text,
400 href: href.to_string(),
401 });
402 }
403 }
404 Ok(self)
405 }
406
407 fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
412 self.conv
413 .iter()
414 .flat_map(|conv| conv.footers().iter().map(Footer::from))
415 }
416}
417
418impl Serialize for Commit<'_> {
419 #[allow(deprecated)]
420 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
421 where
422 S: Serializer,
423 {
424 struct SerializeFooters<'a>(&'a Commit<'a>);
428 impl Serialize for SerializeFooters<'_> {
429 fn serialize<S>(
430 &self,
431 serializer: S,
432 ) -> std::result::Result<S::Ok, S::Error>
433 where
434 S: Serializer,
435 {
436 serializer.collect_seq(self.0.footers())
437 }
438 }
439
440 let mut commit = serializer.serialize_struct("Commit", 20)?;
441 commit.serialize_field("id", &self.id)?;
442 if let Some(conv) = &self.conv {
443 commit.serialize_field("message", conv.description())?;
444 commit.serialize_field("body", &conv.body())?;
445 commit.serialize_field("footers", &SerializeFooters(self))?;
446 commit.serialize_field(
447 "group",
448 self.group.as_ref().unwrap_or(&conv.type_().to_string()),
449 )?;
450 commit.serialize_field(
451 "breaking_description",
452 &conv.breaking_description(),
453 )?;
454 commit.serialize_field("breaking", &conv.breaking())?;
455 commit.serialize_field(
456 "scope",
457 &self
458 .scope
459 .as_deref()
460 .or_else(|| conv.scope().map(|v| v.as_str()))
461 .or(self.default_scope.as_deref()),
462 )?;
463 } else {
464 commit.serialize_field("message", &self.message)?;
465 commit.serialize_field("group", &self.group)?;
466 commit.serialize_field(
467 "scope",
468 &self.scope.as_deref().or(self.default_scope.as_deref()),
469 )?;
470 }
471
472 commit.serialize_field("links", &self.links)?;
473 commit.serialize_field("author", &self.author)?;
474 commit.serialize_field("committer", &self.committer)?;
475 commit.serialize_field("conventional", &self.conv.is_some())?;
476 commit.serialize_field("merge_commit", &self.merge_commit)?;
477 commit.serialize_field("extra", &self.extra)?;
478 #[cfg(feature = "github")]
479 commit.serialize_field("github", &self.github)?;
480 #[cfg(feature = "gitlab")]
481 commit.serialize_field("gitlab", &self.gitlab)?;
482 #[cfg(feature = "gitea")]
483 commit.serialize_field("gitea", &self.gitea)?;
484 #[cfg(feature = "bitbucket")]
485 commit.serialize_field("bitbucket", &self.bitbucket)?;
486 if let Some(remote) = &self.remote {
487 commit.serialize_field("remote", remote)?;
488 }
489 commit.serialize_field("raw_message", &self.raw_message())?;
490 commit.end()
491 }
492}
493
494pub(crate) fn commits_to_conventional_commits<'de, 'a, D: Deserializer<'de>>(
502 deserializer: D,
503) -> std::result::Result<Vec<Commit<'a>>, D::Error> {
504 let commits = Vec::<Commit<'a>>::deserialize(deserializer)?;
505 let commits = commits
506 .into_iter()
507 .map(|commit| commit.clone().into_conventional().unwrap_or(commit))
508 .collect();
509 Ok(commits)
510}
511
512#[cfg(test)]
513mod test {
514 use super::*;
515 #[test]
516 fn conventional_commit() -> Result<()> {
517 let test_cases = vec![
518 (
519 Commit::new(
520 String::from("123123"),
521 String::from("test(commit): add test"),
522 ),
523 true,
524 ),
525 (
526 Commit::new(String::from("124124"), String::from("xyz")),
527 false,
528 ),
529 ];
530 for (commit, is_conventional) in &test_cases {
531 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
532 }
533 let commit = test_cases[0].0.clone().parse(
534 &[CommitParser {
535 sha: None,
536 message: Regex::new("test*").ok(),
537 body: None,
538 footer: None,
539 group: Some(String::from("test_group")),
540 default_scope: Some(String::from("test_scope")),
541 scope: None,
542 skip: None,
543 field: None,
544 pattern: None,
545 }],
546 false,
547 false,
548 )?;
549 assert_eq!(Some(String::from("test_group")), commit.group);
550 assert_eq!(Some(String::from("test_scope")), commit.default_scope);
551 Ok(())
552 }
553
554 #[test]
555 fn conventional_footers() {
556 let cfg = crate::config::GitConfig {
557 conventional_commits: Some(true),
558 ..Default::default()
559 };
560 let test_cases = vec![
561 (
562 Commit::new(
563 String::from("123123"),
564 String::from(
565 "test(commit): add test\n\nSigned-off-by: Test User \
566 <test@example.com>",
567 ),
568 ),
569 vec![Footer {
570 token: "Signed-off-by",
571 separator: ":",
572 value: "Test User <test@example.com>",
573 breaking: false,
574 }],
575 ),
576 (
577 Commit::new(
578 String::from("123124"),
579 String::from(
580 "fix(commit): break stuff\n\nBREAKING CHANGE: This commit \
581 breaks stuff\nSigned-off-by: Test User <test@example.com>",
582 ),
583 ),
584 vec![
585 Footer {
586 token: "BREAKING CHANGE",
587 separator: ":",
588 value: "This commit breaks stuff",
589 breaking: true,
590 },
591 Footer {
592 token: "Signed-off-by",
593 separator: ":",
594 value: "Test User <test@example.com>",
595 breaking: false,
596 },
597 ],
598 ),
599 ];
600 for (commit, footers) in &test_cases {
601 let commit = commit.process(&cfg).expect("commit should process");
602 assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
603 }
604 }
605
606 #[test]
607 fn parse_link() -> Result<()> {
608 let test_cases = vec![
609 (
610 Commit::new(
611 String::from("123123"),
612 String::from("test(commit): add test\n\nBody with issue #123"),
613 ),
614 true,
615 ),
616 (
617 Commit::new(
618 String::from("123123"),
619 String::from(
620 "test(commit): add test\n\nImlement RFC456\n\nFixes: #456",
621 ),
622 ),
623 true,
624 ),
625 ];
626 for (commit, is_conventional) in &test_cases {
627 assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
628 }
629 let commit = Commit::new(
630 String::from("123123"),
631 String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"),
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 Ok(())
659 }
660
661 #[test]
662 fn parse_commit() {
663 assert_eq!(
664 Commit::new(String::new(), String::from("test: no sha1 given")),
665 Commit::from(String::from("test: no sha1 given"))
666 );
667 assert_eq!(
668 Commit::new(
669 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
670 String::from("feat: do something")
671 ),
672 Commit::from(String::from(
673 "8f55e69eba6e6ce811ace32bd84cc82215673cb6 feat: do something"
674 ))
675 );
676 assert_eq!(
677 Commit::new(
678 String::from("3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853"),
679 String::from("chore: do something")
680 ),
681 Commit::from(String::from(
682 "3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853 chore: do something"
683 ))
684 );
685 assert_eq!(
686 Commit::new(
687 String::new(),
688 String::from("thisisinvalidsha1 style: add formatting")
689 ),
690 Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
691 );
692 }
693
694 #[test]
695 fn parse_commit_field() -> Result<()> {
696 let mut commit = Commit::new(
697 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
698 String::from("feat: do something"),
699 );
700
701 commit.author = Signature {
702 name: Some("John Doe".to_string()),
703 email: None,
704 timestamp: 0x0,
705 };
706
707 let parsed_commit = commit.parse(
708 &[CommitParser {
709 sha: None,
710 message: None,
711 body: None,
712 footer: None,
713 group: Some(String::from("Test group")),
714 default_scope: None,
715 scope: None,
716 skip: None,
717 field: Some(String::from("author.name")),
718 pattern: Regex::new("John Doe").ok(),
719 }],
720 false,
721 false,
722 )?;
723
724 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
725 Ok(())
726 }
727
728 #[test]
729 fn commit_sha() -> Result<()> {
730 let commit = Commit::new(
731 String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
732 String::from("feat: do something"),
733 );
734 let parsed_commit = commit.clone().parse(
735 &[CommitParser {
736 sha: Some(String::from(
737 "8f55e69eba6e6ce811ace32bd84cc82215673cb6",
738 )),
739 message: None,
740 body: None,
741 footer: None,
742 group: None,
743 default_scope: None,
744 scope: None,
745 skip: Some(true),
746 field: None,
747 pattern: None,
748 }],
749 false,
750 false,
751 );
752 assert!(parsed_commit.is_err());
753
754 let parsed_commit = commit.parse(
755 &[CommitParser {
756 sha: Some(String::from(
757 "8f55e69eba6e6ce811ace32bd84cc82215673cb6",
758 )),
759 message: None,
760 body: None,
761 footer: None,
762 group: Some(String::from("Test group")),
763 default_scope: None,
764 scope: None,
765 skip: None,
766 field: None,
767 pattern: None,
768 }],
769 false,
770 false,
771 )?;
772 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
773
774 Ok(())
775 }
776
777 #[test]
778 fn field_name_regex() -> Result<()> {
779 let commit = Commit {
780 message: String::from("feat: do something"),
781 author: Signature {
782 name: Some("John Doe".to_string()),
783 email: None,
784 timestamp: 0x0,
785 },
786 ..Default::default()
787 };
788 let parsed_commit = commit.clone().parse(
789 &[CommitParser {
790 sha: None,
791 message: None,
792 body: None,
793 footer: None,
794 group: Some(String::from("Test group")),
795 default_scope: None,
796 scope: None,
797 skip: None,
798 field: Some(String::from("author.name")),
799 pattern: Regex::new("Something else").ok(),
800 }],
801 false,
802 true,
803 );
804
805 assert!(parsed_commit.is_err());
806
807 let parsed_commit = commit.parse(
808 &[CommitParser {
809 sha: None,
810 message: None,
811 body: None,
812 footer: None,
813 group: Some(String::from("Test group")),
814 default_scope: None,
815 scope: None,
816 skip: None,
817 field: Some(String::from("author.name")),
818 pattern: Regex::new("John Doe").ok(),
819 }],
820 false,
821 false,
822 )?;
823
824 assert_eq!(Some(String::from("Test group")), parsed_commit.group);
825 Ok(())
826 }
827}