git_cliff_core/
commit.rs

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
36/// Regular expression for matching SHA1 and a following commit message
37/// separated by a whitespace.
38static SHA1_REGEX: Lazy<Regex> = lazy_regex!(r#"^\b([a-f0-9]{40})\b (.*)$"#);
39
40/// Object representing a link
41#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
42#[serde(rename_all(serialize = "camelCase"))]
43pub struct Link {
44	/// Text of the link.
45	pub text: String,
46	/// URL of the link
47	pub href: String,
48}
49
50/// A conventional commit footer.
51#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
52struct Footer<'a> {
53	/// Token of the footer.
54	///
55	/// This is the part of the footer preceding the separator. For example, for
56	/// the `Signed-off-by: <user.name>` footer, this would be `Signed-off-by`.
57	token:     &'a str,
58	/// The separator between the footer token and its value.
59	///
60	/// This is typically either `:` or `#`.
61	separator: &'a str,
62	/// The value of the footer.
63	value:     &'a str,
64	/// A flag to signal that the footer describes a breaking change.
65	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/// Commit signature that indicates authorship.
80#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
81pub struct Signature {
82	/// Name on the signature.
83	pub name:      Option<String>,
84	/// Email on the signature.
85	pub email:     Option<String>,
86	/// Time of the signature.
87	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/// Common commit object that is parsed from a repository.
102#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
103#[serde(rename_all(serialize = "camelCase"))]
104pub struct Commit<'a> {
105	/// Commit ID.
106	pub id:            String,
107	/// Commit message including title, description and summary.
108	pub message:       String,
109	/// Conventional commit.
110	#[serde(skip_deserializing)]
111	pub conv:          Option<ConventionalCommit<'a>>,
112	/// Commit group based on a commit parser or its conventional type.
113	pub group:         Option<String>,
114	/// Default commit scope based on (inherited from) conventional type or a
115	/// commit parser.
116	pub default_scope: Option<String>,
117	/// Commit scope for overriding the default one.
118	pub scope:         Option<String>,
119	/// A list of links found in the commit
120	pub links:         Vec<Link>,
121	/// Commit author.
122	pub author:        Signature,
123	/// Committer.
124	pub committer:     Signature,
125	/// Whether if the commit has two or more parents.
126	pub merge_commit:  bool,
127	/// Arbitrary data to be used with the `--from-context` CLI option.
128	pub extra:         Option<Value>,
129	/// Remote metadata of the commit.
130	pub remote:        Option<crate::contributor::RemoteContributor>,
131	/// GitHub metadata of the commit.
132	#[cfg(feature = "github")]
133	#[deprecated(note = "Use `remote` field instead")]
134	pub github:        crate::contributor::RemoteContributor,
135	/// GitLab metadata of the commit.
136	#[cfg(feature = "gitlab")]
137	#[deprecated(note = "Use `remote` field instead")]
138	pub gitlab:        crate::contributor::RemoteContributor,
139	/// Gitea metadata of the commit.
140	#[cfg(feature = "gitea")]
141	#[deprecated(note = "Use `remote` field instead")]
142	pub gitea:         crate::contributor::RemoteContributor,
143	/// Bitbucket metadata of the commit.
144	#[cfg(feature = "bitbucket")]
145	#[deprecated(note = "Use `remote` field instead")]
146	pub bitbucket:     crate::contributor::RemoteContributor,
147
148	/// Raw message of the normal commit, works as a placeholder for converting
149	/// normal commit into conventional commit.
150	///
151	/// Despite the name, it is not actually a raw message.
152	/// In fact, it is pre-processed by [`Commit::preprocess`], and only be
153	/// generated when serializing into `context` the first time.
154	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	/// Constructs a new instance.
195	pub fn new(id: String, message: String) -> Self {
196		Self {
197			id,
198			message,
199			..Default::default()
200		}
201	}
202
203	/// Get raw message for converting into conventional commit.
204	pub fn raw_message(&self) -> &str {
205		self.raw_message.as_deref().unwrap_or(&self.message)
206	}
207
208	/// Processes the commit.
209	///
210	/// * converts commit to a conventional commit
211	/// * sets the group for the commit
212	/// * extracts links and generates URLs
213	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	/// Returns the commit with its conventional type set.
241	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	/// Preprocesses the commit using [`TextProcessor`]s.
254	///
255	/// Modifies the commit [`message`] using regex or custom OS command.
256	///
257	/// [`message`]: Commit::message
258	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	/// States if the commit is skipped in the provided `CommitParser`.
268	///
269	/// Returns `false` if `protect_breaking_commits` is enabled in the config
270	/// and the commit is breaking, or the parser's `skip` field is None or
271	/// `false`. Returns `true` otherwise.
272	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	/// Parses the commit using [`CommitParser`]s.
279	///
280	/// Sets the [`group`] and [`scope`] of the commit.
281	///
282	/// [`group`]: Commit::group
283	/// [`scope`]: Commit::scope
284	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	/// Parses the commit using [`LinkParser`]s.
382	///
383	/// Sets the [`links`] of the commit.
384	///
385	/// [`links`]: Commit::links
386	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	/// Returns an iterator over this commit's [`Footer`]s, if this is a
408	/// conventional commit.
409	///
410	/// If this commit is not conventional, the returned iterator will be empty.
411	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		/// A wrapper to serialize commit footers from an iterator using
425		/// `Serializer::collect_seq` without having to allocate in order to
426		/// `collect` the footers  into a new to `Vec`.
427		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
494/// Deserialize commits into conventional commits if they are convertible.
495///
496/// Serialized commits cannot be deserialized into commits that have
497/// [`Commit::conv`]. Thus, we need to manually convert them using
498/// [`Commit::into_conventional`].
499///
500/// This function is to be used only in [`crate::release::Release::commits`].
501pub(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}