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};
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
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/// Commit range (from..to)
102#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct Range {
104	/// Full commit SHA the range starts at
105	from: String,
106	/// Full commit SHA the range ends at
107	to:   String,
108}
109
110impl Range {
111	/// Creates a new [`Range`] from [`crate::commit::Commit`].
112	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/// Common commit object that is parsed from a repository.
121#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
122#[serde(rename_all(serialize = "camelCase"))]
123pub struct Commit<'a> {
124	/// Commit ID.
125	pub id:            String,
126	/// Commit message including title, description and summary.
127	pub message:       String,
128	/// Conventional commit.
129	#[serde(skip_deserializing)]
130	pub conv:          Option<ConventionalCommit<'a>>,
131	/// Commit group based on a commit parser or its conventional type.
132	pub group:         Option<String>,
133	/// Default commit scope based on (inherited from) conventional type or a
134	/// commit parser.
135	pub default_scope: Option<String>,
136	/// Commit scope for overriding the default one.
137	pub scope:         Option<String>,
138	/// A list of links found in the commit
139	pub links:         Vec<Link>,
140	/// Commit author.
141	pub author:        Signature,
142	/// Committer.
143	pub committer:     Signature,
144	/// Whether if the commit has two or more parents.
145	pub merge_commit:  bool,
146	/// Arbitrary data to be used with the `--from-context` CLI option.
147	pub extra:         Option<Value>,
148	/// Remote metadata of the commit.
149	pub remote:        Option<crate::contributor::RemoteContributor>,
150	/// GitHub metadata of the commit.
151	#[cfg(feature = "github")]
152	#[deprecated(note = "Use `remote` field instead")]
153	pub github:        crate::contributor::RemoteContributor,
154	/// GitLab metadata of the commit.
155	#[cfg(feature = "gitlab")]
156	#[deprecated(note = "Use `remote` field instead")]
157	pub gitlab:        crate::contributor::RemoteContributor,
158	/// Gitea metadata of the commit.
159	#[cfg(feature = "gitea")]
160	#[deprecated(note = "Use `remote` field instead")]
161	pub gitea:         crate::contributor::RemoteContributor,
162	/// Bitbucket metadata of the commit.
163	#[cfg(feature = "bitbucket")]
164	#[deprecated(note = "Use `remote` field instead")]
165	pub bitbucket:     crate::contributor::RemoteContributor,
166
167	/// Raw message of the normal commit, works as a placeholder for converting
168	/// normal commit into conventional commit.
169	///
170	/// Despite the name, it is not actually a raw message.
171	/// In fact, it is pre-processed by [`Commit::preprocess`], and only be
172	/// generated when serializing into `context` the first time.
173	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	/// Constructs a new instance.
214	pub fn new(id: String, message: String) -> Self {
215		Self {
216			id,
217			message,
218			..Default::default()
219		}
220	}
221
222	/// Get raw message for converting into conventional commit.
223	pub fn raw_message(&self) -> &str {
224		self.raw_message.as_deref().unwrap_or(&self.message)
225	}
226
227	/// Processes the commit.
228	///
229	/// * converts commit to a conventional commit
230	/// * sets the group for the commit
231	/// * extracts links and generates URLs
232	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	/// Returns the commit with its conventional type set.
258	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	/// Preprocesses the commit using [`TextProcessor`]s.
271	///
272	/// Modifies the commit [`message`] using regex or custom OS command.
273	///
274	/// [`message`]: Commit::message
275	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	/// States if the commit is skipped in the provided `CommitParser`.
285	///
286	/// Returns `false` if `protect_breaking_commits` is enabled in the config
287	/// and the commit is breaking, or the parser's `skip` field is None or
288	/// `false`. Returns `true` otherwise.
289	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	/// Parses the commit using [`CommitParser`]s.
296	///
297	/// Sets the [`group`] and [`scope`] of the commit.
298	///
299	/// [`group`]: Commit::group
300	/// [`scope`]: Commit::scope
301	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	/// Parses the commit using [`LinkParser`]s.
407	///
408	/// Sets the [`links`] of the commit.
409	///
410	/// [`links`]: Commit::links
411	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	/// Returns an iterator over this commit's [`Footer`]s, if this is a
433	/// conventional commit.
434	///
435	/// If this commit is not conventional, the returned iterator will be empty.
436	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		/// A wrapper to serialize commit footers from an iterator using
450		/// `Serializer::collect_seq` without having to allocate in order to
451		/// `collect` the footers  into a new to `Vec`.
452		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
519/// Deserialize commits into conventional commits if they are convertible.
520///
521/// Serialized commits cannot be deserialized into commits that have
522/// [`Commit::conv`]. Thus, we need to manually convert them using
523/// [`Commit::into_conventional`].
524///
525/// This function is to be used only in [`crate::release::Release::commits`].
526pub(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}