Skip to main content

git_cliff_core/
commit.rs

1use std::sync::LazyLock;
2
3use git_conventional::{Commit as ConventionalCommit, Footer as ConventionalFooter};
4#[cfg(feature = "repo")]
5use git2::{Commit as GitCommit, Signature as CommitSignature};
6use regex::Regex;
7use serde::ser::{SerializeStruct, Serializer};
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::value::Value;
10
11use crate::config::{CommitParser, GitConfig, LinkParser, TextProcessor};
12use crate::error::{Error as AppError, Result};
13
14/// Regular expression for matching SHA1 and a following commit message
15/// separated by a whitespace.
16//static SHA1_REGEX: Lazy<Regex> = lazy_regex!(r#"^\b([a-f0-9]{40})\b (.*)$"#);
17static SHA1_REGEX: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(r"^\b([a-f0-9]{40})\b (.*)$").expect("valid SHA1 regex"));
19
20/// Object representing a link
21#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
22#[serde(rename_all(serialize = "camelCase"))]
23pub struct Link {
24    /// Text of the link.
25    pub text: String,
26    /// URL of the link
27    pub href: String,
28}
29
30/// A conventional commit footer.
31#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
32struct Footer<'a> {
33    /// Token of the footer.
34    ///
35    /// This is the part of the footer preceding the separator. For example, for
36    /// the `Signed-off-by: <user.name>` footer, this would be `Signed-off-by`.
37    token: &'a str,
38    /// The separator between the footer token and its value.
39    ///
40    /// This is typically either `:` or `#`.
41    separator: &'a str,
42    /// The value of the footer.
43    value: &'a str,
44    /// A flag to signal that the footer describes a breaking change.
45    breaking: bool,
46}
47
48impl<'a> From<&'a ConventionalFooter<'a>> for Footer<'a> {
49    fn from(footer: &'a ConventionalFooter<'a>) -> Self {
50        Self {
51            token: footer.token().as_str(),
52            separator: footer.separator().as_str(),
53            value: footer.value(),
54            breaking: footer.breaking(),
55        }
56    }
57}
58
59/// Commit signature that indicates authorship.
60#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
61pub struct Signature {
62    /// Name on the signature.
63    pub name: Option<String>,
64    /// Email on the signature.
65    pub email: Option<String>,
66    /// Time of the signature.
67    pub timestamp: i64,
68}
69
70#[cfg(feature = "repo")]
71impl<'a> From<CommitSignature<'a>> for Signature {
72    fn from(signature: CommitSignature<'a>) -> Self {
73        Self {
74            name: signature.name().ok().map(String::from),
75            email: signature.email().ok().map(String::from),
76            timestamp: signature.when().seconds(),
77        }
78    }
79}
80
81/// Statistics about the changes in a single commit.
82#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
83pub struct CommitStatistics {
84    /// Total number of files changed in the commit.
85    pub files_changed: usize,
86    /// Total number of inserted lines in the commit.
87    pub additions: usize,
88    /// Total number of deleted lines in the commit.
89    pub deletions: usize,
90}
91
92/// Commit range (from..to)
93#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct Range {
95    /// Full commit SHA the range starts at
96    from: String,
97    /// Full commit SHA the range ends at
98    to: String,
99}
100
101impl Range {
102    /// Creates a new [`Range`] from [`crate::commit::Commit`].
103    #[must_use]
104    pub fn new(from: &Commit, to: &Commit) -> Self {
105        Self {
106            from: from.id.clone(),
107            to: to.id.clone(),
108        }
109    }
110}
111
112/// Common commit object that is parsed from a repository.
113#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
114#[serde(rename_all(serialize = "camelCase"))]
115pub struct Commit<'a> {
116    /// Commit ID.
117    pub id: String,
118    /// Commit message including title, description and summary.
119    pub message: String,
120    /// Conventional commit.
121    #[serde(skip_deserializing)]
122    pub conv: Option<ConventionalCommit<'a>>,
123    /// Commit group based on a commit parser or its conventional type.
124    pub group: Option<String>,
125    /// Default commit scope based on (inherited from) conventional type or a
126    /// commit parser.
127    pub default_scope: Option<String>,
128    /// Commit scope for overriding the default one.
129    pub scope: Option<String>,
130    /// A list of links found in the commit
131    pub links: Vec<Link>,
132    /// Commit author.
133    pub author: Signature,
134    /// Committer.
135    pub committer: Signature,
136    /// Whether if the commit has two or more parents.
137    pub merge_commit: bool,
138    /// Per-commit diff statistics exposed to the template context.
139    #[serde(default)]
140    pub statistics: CommitStatistics,
141    /// Arbitrary data to be used with the `--from-context` CLI option.
142    pub extra: Option<Value>,
143    /// Remote metadata of the commit.
144    pub remote: Option<crate::contributor::RemoteContributor>,
145    /// GitHub metadata of the commit.
146    #[cfg(feature = "github")]
147    #[deprecated(note = "Use `remote` field instead")]
148    pub github: crate::contributor::RemoteContributor,
149    /// GitLab metadata of the commit.
150    #[cfg(feature = "gitlab")]
151    #[deprecated(note = "Use `remote` field instead")]
152    pub gitlab: crate::contributor::RemoteContributor,
153    /// Gitea metadata of the commit.
154    #[cfg(feature = "gitea")]
155    #[deprecated(note = "Use `remote` field instead")]
156    pub gitea: crate::contributor::RemoteContributor,
157    /// Bitbucket metadata of the commit.
158    #[cfg(feature = "bitbucket")]
159    #[deprecated(note = "Use `remote` field instead")]
160    pub bitbucket: crate::contributor::RemoteContributor,
161    /// Azure DevOps metadata of the commit.
162    #[cfg(feature = "azure_devops")]
163    #[deprecated(note = "Use `remote` field instead")]
164    pub azure_devops: crate::contributor::RemoteContributor,
165
166    /// Raw message of the normal commit, works as a placeholder for converting
167    /// normal commit into conventional commit.
168    ///
169    /// Despite the name, it is not actually a raw message.
170    /// In fact, it is pre-processed by [`Commit::preprocess`], and only be
171    /// generated when serializing into `context` the first time.
172    pub raw_message: Option<String>,
173}
174
175impl From<String> for Commit<'_> {
176    fn from(message: String) -> Self {
177        if let Some(captures) = SHA1_REGEX.captures(&message) &&
178            let (Some(id), Some(message)) = (
179                captures.get(1).map(|v| v.as_str()),
180                captures.get(2).map(|v| v.as_str()),
181            )
182        {
183            return Commit {
184                id: id.to_string(),
185                message: message.to_string(),
186                ..Default::default()
187            };
188        }
189        Commit {
190            id: String::new(),
191            message,
192            ..Default::default()
193        }
194    }
195}
196
197#[cfg(feature = "repo")]
198impl From<&GitCommit<'_>> for Commit<'_> {
199    fn from(commit: &GitCommit<'_>) -> Self {
200        Commit {
201            id: commit.id().to_string(),
202            message: commit.message().unwrap_or_default().trim_end().to_string(),
203            author: commit.author().into(),
204            committer: commit.committer().into(),
205            merge_commit: commit.parent_count() > 1,
206            ..Default::default()
207        }
208    }
209}
210
211impl Commit<'_> {
212    /// Constructs a new instance.
213    #[must_use]
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    #[must_use]
224    pub fn raw_message(&self) -> &str {
225        self.raw_message.as_deref().unwrap_or(&self.message)
226    }
227
228    /// Processes the commit.
229    ///
230    /// * converts commit to a conventional commit
231    /// * sets the group for the commit
232    /// * extracts links and generates URLs
233    #[cfg_attr(
234        feature = "tracing",
235        tracing::instrument(
236            skip_all,
237            fields(id = self.id)
238        )
239    )]
240    pub fn process(&self, config: &GitConfig) -> Result<Self> {
241        crate::set_progress_message!(
242            "Converting the commit to conventional format, setting its group, and extracting links"
243        );
244        let mut commit = self.clone();
245        commit = commit.preprocess(&config.commit_preprocessors)?;
246        if config.conventional_commits {
247            if !config.require_conventional && config.filter_unconventional && !config.split_commits
248            {
249                commit = commit.into_conventional()?;
250            } else if let Ok(conv_commit) = commit.clone().into_conventional() {
251                commit = conv_commit;
252            }
253        }
254
255        commit = commit.parse(
256            &config.commit_parsers,
257            config.protect_breaking_commits,
258            config.filter_commits,
259        )?;
260
261        commit = commit.parse_links(&config.link_parsers);
262
263        Ok(commit)
264    }
265
266    /// Returns the commit with its conventional type set.
267    pub fn into_conventional(mut self) -> Result<Self> {
268        match ConventionalCommit::parse(Box::leak(self.raw_message().to_string().into_boxed_str()))
269        {
270            Ok(conv) => {
271                self.conv = Some(conv);
272                Ok(self)
273            }
274            Err(e) => Err(AppError::ParseError(e)),
275        }
276    }
277
278    /// Preprocesses the commit using [`TextProcessor`]s.
279    ///
280    /// Modifies the commit [`message`] using regex or custom OS command.
281    ///
282    /// [`message`]: Commit::message
283    #[cfg_attr(
284        feature = "tracing",
285        tracing::instrument(
286            skip_all,
287            fields(id = self.id)
288        )
289    )]
290    pub fn preprocess(mut self, preprocessors: &[TextProcessor]) -> Result<Self> {
291        crate::set_progress_message!("Preprocessing the commit message using text processors");
292        preprocessors.iter().try_for_each(|preprocessor| {
293            preprocessor.replace(&mut self.message, vec![("COMMIT_SHA", &self.id)])?;
294            Ok::<(), AppError>(())
295        })?;
296        Ok(self)
297    }
298
299    /// States if the commit is skipped in the provided `CommitParser`.
300    ///
301    /// Returns `false` if `protect_breaking_commits` is enabled in the config
302    /// and the commit is breaking, or the parser's `skip` field is None or
303    /// `false`. Returns `true` otherwise.
304    fn skip_commit(&self, parser: &CommitParser, protect_breaking: bool) -> bool {
305        parser.skip.unwrap_or(false) &&
306            !(self.conv.as_ref().is_some_and(ConventionalCommit::breaking) && protect_breaking)
307    }
308
309    /// Parses the commit using [`CommitParser`]s.
310    ///
311    /// Sets the [`group`] and [`scope`] of the commit.
312    ///
313    /// [`group`]: Commit::group
314    /// [`scope`]: Commit::scope
315    #[cfg_attr(
316        feature = "tracing",
317        tracing::instrument(
318            skip_all,
319            fields(id = self.id)
320        )
321    )]
322    pub fn parse(
323        mut self,
324        parsers: &[CommitParser],
325        protect_breaking: bool,
326        filter: bool,
327    ) -> Result<Self> {
328        crate::set_progress_message!("Parsing the commit and setting its group and scope");
329        let lookup_context = serde_json::to_value(&self).map_err(|e| {
330            AppError::FieldError(format!("failed to convert context into value: {e}",))
331        })?;
332        // Set when a `continue` parser matches, so the commit isn't filtered out
333        // at the end even though no parser returned early.
334        let mut matched = false;
335        'parsers: for parser in parsers {
336            if let Some(sha) = parser.sha.as_ref() &&
337                sha.to_lowercase() != self.id
338            {
339                continue 'parsers;
340            }
341            let mut regex_checks = Vec::new();
342            if let Some(message_regex) = parser.message.as_ref() {
343                if !message_regex.is_match(self.message.trim()) {
344                    continue 'parsers;
345                }
346                regex_checks.push((message_regex, self.message.clone()));
347            }
348            let body = self
349                .conv
350                .as_ref()
351                .and_then(ConventionalCommit::body)
352                .map(ToString::to_string);
353            if let Some(body_regex) = parser.body.as_ref() {
354                let body_text = body.clone().unwrap_or_default();
355                if !body_regex.is_match(body_text.trim()) {
356                    continue 'parsers;
357                }
358                regex_checks.push((body_regex, body_text));
359            }
360            if let Some(footer_regex) = parser.footer.as_ref() {
361                let Some(footers) = self.conv.as_ref().map(ConventionalCommit::footers) else {
362                    continue 'parsers;
363                };
364                let Some(matched_footer) = footers
365                    .iter()
366                    .map(ToString::to_string)
367                    .find(|f| footer_regex.is_match(f.trim()))
368                else {
369                    continue 'parsers;
370                };
371                regex_checks.push((footer_regex, matched_footer));
372            }
373            if let (Some(field_name), Some(pattern_regex)) =
374                (parser.field.as_ref(), parser.pattern.as_ref())
375            {
376                let values = if field_name == "body" {
377                    vec![body.clone()].into_iter().collect()
378                } else {
379                    let Some(field_value) = tera::dotted_pointer(&lookup_context, field_name)
380                    else {
381                        tracing::trace!("Field '{field_name}' is absent; trying the next parser");
382                        continue 'parsers;
383                    };
384                    match field_value {
385                        Value::String(s) => Some(vec![s.clone()]),
386                        Value::Number(_) | Value::Bool(_) | Value::Null => {
387                            Some(vec![field_value.to_string()])
388                        }
389                        Value::Array(arr) => {
390                            let mut values = Vec::new();
391                            for item in arr {
392                                match item {
393                                    Value::String(s) => values.push(s.clone()),
394                                    Value::Number(_) | Value::Bool(_) | Value::Null => {
395                                        values.push(item.to_string());
396                                    }
397                                    _ => {}
398                                }
399                            }
400                            Some(values)
401                        }
402                        Value::Object(_) => None,
403                    }
404                };
405                match values {
406                    Some(values) => {
407                        if values.is_empty() {
408                            tracing::trace!("Field '{field_name}' is present but empty");
409                        }
410                        let Some(matched_value) = values
411                            .into_iter()
412                            .find(|v| pattern_regex.is_match(v.trim()))
413                        else {
414                            continue 'parsers;
415                        };
416                        regex_checks.push((pattern_regex, matched_value));
417                    }
418                    None => {
419                        return Err(AppError::FieldError(format!(
420                            "field '{field_name}' is missing or has unsupported type (expected a \
421                             String, Number, Bool, or Null — or an Array of these scalar values)",
422                        )));
423                    }
424                }
425            }
426            if regex_checks.is_empty() {
427                if parser.sha.is_none() {
428                    continue 'parsers;
429                }
430                if self.skip_commit(parser, protect_breaking) {
431                    return Err(AppError::GroupError(String::from("Skipping commit")));
432                } else {
433                    self.group = parser.group.clone().or(self.group);
434                    self.scope = parser.scope.clone().or(self.scope);
435                    self.default_scope = parser.default_scope.clone().or(self.default_scope);
436                    if parser.r#continue.unwrap_or(false) {
437                        matched = true;
438                        continue;
439                    }
440                    return Ok(self);
441                }
442            } else if self.skip_commit(parser, protect_breaking) {
443                return Err(AppError::GroupError(String::from("Skipping commit")));
444            } else {
445                let regex_replace = |mut value: String| {
446                    for (regex, text) in &regex_checks {
447                        for mat in regex.find_iter(text) {
448                            value = regex.replace(mat.as_str(), value).to_string();
449                        }
450                    }
451                    value
452                };
453                if parser.r#continue.unwrap_or(false) {
454                    // Only override the fields this parser sets, so later
455                    // parsers can fill in the rest.
456                    if let Some(group) = parser.group.clone() {
457                        self.group = Some(regex_replace(group));
458                    }
459                    if let Some(scope) = parser.scope.clone() {
460                        self.scope = Some(regex_replace(scope));
461                    }
462                    if parser.default_scope.is_some() {
463                        self.default_scope.clone_from(&parser.default_scope);
464                    }
465                    matched = true;
466                    continue 'parsers;
467                }
468                if matched {
469                    // Preserve fields contributed by preceding parsers.
470                    self.group = parser.group.clone().map(regex_replace).or(self.group);
471                    self.scope = parser.scope.clone().map(regex_replace).or(self.scope);
472                    if parser.default_scope.is_some() {
473                        self.default_scope.clone_from(&parser.default_scope);
474                    }
475                } else {
476                    // Keep the original first-match-wins behavior when
477                    // no preceding parser continued.
478                    self.group = parser.group.clone().map(regex_replace);
479                    self.scope = parser.scope.clone().map(regex_replace);
480                    self.default_scope.clone_from(&parser.default_scope);
481                }
482                return Ok(self);
483            }
484        }
485        if filter && !matched {
486            Err(AppError::GroupError(String::from(
487                "Commit does not belong to any group",
488            )))
489        } else {
490            Ok(self)
491        }
492    }
493
494    /// Parses the commit using [`LinkParser`]s.
495    ///
496    /// Sets the [`links`] of the commit.
497    ///
498    /// [`links`]: Commit::links
499    #[must_use]
500    #[cfg_attr(
501        feature = "tracing",
502        tracing::instrument(
503            skip_all,
504            fields(id = self.id)
505        )
506    )]
507    pub fn parse_links(mut self, parsers: &[LinkParser]) -> Self {
508        crate::set_progress_message!("Parsing links for the commit using link parsers");
509        for parser in parsers {
510            let regex = &parser.pattern;
511            let replace = &parser.href;
512            for mat in regex.find_iter(&self.message) {
513                let m = mat.as_str();
514                let text = if let Some(text_replace) = &parser.text {
515                    regex.replace(m, text_replace).to_string()
516                } else {
517                    m.to_string()
518                };
519                let href = regex.replace(m, replace);
520                self.links.push(Link {
521                    text,
522                    href: href.to_string(),
523                });
524            }
525        }
526        self
527    }
528
529    /// Returns an iterator over this commit's [`Footer`]s, if this is a
530    /// conventional commit.
531    ///
532    /// If this commit is not conventional, the returned iterator will be empty.
533    fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
534        self.conv
535            .iter()
536            .flat_map(|conv| conv.footers().iter().map(Footer::from))
537    }
538}
539
540impl Serialize for Commit<'_> {
541    #[allow(deprecated)]
542    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
543    where
544        S: Serializer,
545    {
546        /// A wrapper to serialize commit footers from an iterator using
547        /// `Serializer::collect_seq` without having to allocate in order to
548        /// `collect` the footers  into a new to `Vec`.
549        struct SerializeFooters<'a>(&'a Commit<'a>);
550        impl Serialize for SerializeFooters<'_> {
551            fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
552            where
553                S: Serializer,
554            {
555                serializer.collect_seq(self.0.footers())
556            }
557        }
558
559        let mut commit = serializer.serialize_struct("Commit", 21)?;
560        commit.serialize_field("id", &self.id)?;
561        if let Some(conv) = &self.conv {
562            commit.serialize_field("message", conv.description())?;
563            commit.serialize_field("body", &conv.body())?;
564            commit.serialize_field("footers", &SerializeFooters(self))?;
565            commit.serialize_field(
566                "group",
567                self.group.as_ref().unwrap_or(&conv.type_().to_string()),
568            )?;
569            commit.serialize_field("breaking_description", &conv.breaking_description())?;
570            commit.serialize_field("breaking", &conv.breaking())?;
571            commit.serialize_field(
572                "scope",
573                &self
574                    .scope
575                    .as_deref()
576                    .or_else(|| conv.scope().map(|v| v.as_str()))
577                    .or(self.default_scope.as_deref()),
578            )?;
579        } else {
580            commit.serialize_field("message", &self.message)?;
581            commit.serialize_field("group", &self.group)?;
582            commit.serialize_field(
583                "scope",
584                &self.scope.as_deref().or(self.default_scope.as_deref()),
585            )?;
586        }
587
588        commit.serialize_field("links", &self.links)?;
589        commit.serialize_field("author", &self.author)?;
590        commit.serialize_field("committer", &self.committer)?;
591        commit.serialize_field("conventional", &self.conv.is_some())?;
592        commit.serialize_field("merge_commit", &self.merge_commit)?;
593        commit.serialize_field("statistics", &self.statistics)?;
594        commit.serialize_field("extra", &self.extra)?;
595        #[cfg(feature = "github")]
596        commit.serialize_field("github", &self.github)?;
597        #[cfg(feature = "gitlab")]
598        commit.serialize_field("gitlab", &self.gitlab)?;
599        #[cfg(feature = "gitea")]
600        commit.serialize_field("gitea", &self.gitea)?;
601        #[cfg(feature = "bitbucket")]
602        commit.serialize_field("bitbucket", &self.bitbucket)?;
603        #[cfg(feature = "azure_devops")]
604        commit.serialize_field("azure_devops", &self.azure_devops)?;
605        if let Some(remote) = &self.remote {
606            commit.serialize_field("remote", remote)?;
607        }
608        commit.serialize_field("raw_message", &self.raw_message())?;
609        commit.end()
610    }
611}
612
613/// Deserialize commits into conventional commits if they are convertible.
614///
615/// Serialized commits cannot be deserialized into commits that have
616/// [`Commit::conv`]. Thus, we need to manually convert them using
617/// [`Commit::into_conventional`].
618///
619/// This function is to be used only in [`crate::release::Release::commits`].
620#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
621pub(crate) fn commits_to_conventional_commits<'de, 'a, D: Deserializer<'de>>(
622    deserializer: D,
623) -> std::result::Result<Vec<Commit<'a>>, D::Error> {
624    crate::set_progress_message!("Converting commits to conventional commits");
625    let commits = Vec::<Commit<'a>>::deserialize(deserializer)?;
626    let commits = commits
627        .into_iter()
628        .map(|commit| commit.clone().into_conventional().unwrap_or(commit))
629        .collect();
630    Ok(commits)
631}
632
633#[cfg(test)]
634mod test {
635    use super::*;
636
637    #[test]
638    fn conventional_commit() -> Result<()> {
639        let test_cases = vec![
640            (
641                Commit::new(
642                    String::from("123123"),
643                    String::from("test(commit): add test"),
644                ),
645                true,
646            ),
647            (
648                Commit::new(String::from("124124"), String::from("xyz")),
649                false,
650            ),
651        ];
652
653        for (commit, is_conventional) in &test_cases {
654            assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
655        }
656
657        let commit = test_cases[0].0.clone().parse(
658            &[CommitParser {
659                sha: None,
660                message: Regex::new("test*").ok(),
661                body: None,
662                footer: None,
663                group: Some(String::from("test_group")),
664                default_scope: Some(String::from("test_scope")),
665                scope: None,
666                skip: None,
667                r#continue: None,
668                field: None,
669                pattern: None,
670            }],
671            false,
672            false,
673        )?;
674        assert_eq!(Some(String::from("test_group")), commit.group);
675        assert_eq!(Some(String::from("test_scope")), commit.default_scope);
676
677        Ok(())
678    }
679
680    #[test]
681    fn conventional_footers() {
682        let cfg = crate::config::GitConfig {
683            conventional_commits: true,
684            ..Default::default()
685        };
686        let test_cases = vec![
687            (
688                Commit::new(
689                    String::from("123123"),
690                    String::from(
691                        "test(commit): add test\n\nSigned-off-by: Test User <test@example.com>",
692                    ),
693                ),
694                vec![Footer {
695                    token: "Signed-off-by",
696                    separator: ":",
697                    value: "Test User <test@example.com>",
698                    breaking: false,
699                }],
700            ),
701            (
702                Commit::new(
703                    String::from("123124"),
704                    String::from(
705                        "fix(commit): break stuff\n\nBREAKING CHANGE: This commit breaks \
706                         stuff\nSigned-off-by: Test User <test@example.com>",
707                    ),
708                ),
709                vec![
710                    Footer {
711                        token: "BREAKING CHANGE",
712                        separator: ":",
713                        value: "This commit breaks stuff",
714                        breaking: true,
715                    },
716                    Footer {
717                        token: "Signed-off-by",
718                        separator: ":",
719                        value: "Test User <test@example.com>",
720                        breaking: false,
721                    },
722                ],
723            ),
724        ];
725
726        for (commit, footers) in &test_cases {
727            let commit = commit.process(&cfg).expect("commit should process");
728            assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
729        }
730    }
731
732    #[test]
733    fn parse_link() -> Result<()> {
734        let test_cases = vec![
735            (
736                Commit::new(
737                    String::from("123123"),
738                    String::from("test(commit): add test\n\nBody with issue #123"),
739                ),
740                true,
741            ),
742            (
743                Commit::new(
744                    String::from("123123"),
745                    String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #456"),
746                ),
747                true,
748            ),
749        ];
750
751        for (commit, is_conventional) in &test_cases {
752            assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
753        }
754
755        let commit = Commit::new(
756            String::from("123123"),
757            String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"),
758        );
759
760        let commit = commit.parse_links(&[
761            LinkParser {
762                pattern: Regex::new("RFC(\\d+)")?,
763                href: String::from("rfc://$1"),
764                text: None,
765            },
766            LinkParser {
767                pattern: Regex::new("#(\\d+)")?,
768                href: String::from("https://github.com/$1"),
769                text: None,
770            },
771        ]);
772        assert_eq!(
773            vec![
774                Link {
775                    text: String::from("RFC456"),
776                    href: String::from("rfc://456"),
777                },
778                Link {
779                    text: String::from("#455"),
780                    href: String::from("https://github.com/455"),
781                }
782            ],
783            commit.links
784        );
785
786        Ok(())
787    }
788
789    #[test]
790    fn parse_commit() {
791        assert_eq!(
792            Commit::new(String::new(), String::from("test: no sha1 given")),
793            Commit::from(String::from("test: no sha1 given"))
794        );
795
796        assert_eq!(
797            Commit::new(
798                String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
799                String::from("feat: do something")
800            ),
801            Commit::from(String::from(
802                "8f55e69eba6e6ce811ace32bd84cc82215673cb6 feat: do something"
803            ))
804        );
805
806        assert_eq!(
807            Commit::new(
808                String::from("3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853"),
809                String::from("chore: do something")
810            ),
811            Commit::from(String::from(
812                "3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853 chore: do something"
813            ))
814        );
815
816        assert_eq!(
817            Commit::new(
818                String::new(),
819                String::from("thisisinvalidsha1 style: add formatting")
820            ),
821            Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
822        );
823    }
824
825    #[test]
826    fn parse_body() -> Result<()> {
827        let mut commit = Commit::new(
828            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
829            String::from(
830                "fix: do something
831
832Introduce something great
833
834BREAKING CHANGE: drop support for something else
835Refs: #123
836",
837            ),
838        );
839        commit.author = Signature {
840            name: Some("John Doe".to_string()),
841            email: None,
842            timestamp: 0x0,
843        };
844        commit.remote = Some(crate::contributor::RemoteContributor {
845            username: None,
846            pr_author: None,
847            pr_title: Some("feat: do something".to_string()),
848            pr_number: None,
849            pr_numbers: vec![],
850            pr_labels: vec![String::from("feature"), String::from("deprecation")],
851            is_first_time: true,
852        });
853        let commit = commit.into_conventional()?;
854        let commit = commit.parse_links(&[
855            LinkParser {
856                pattern: Regex::new("RFC(\\d+)")?,
857                href: String::from("rfc://$1"),
858                text: None,
859            },
860            LinkParser {
861                pattern: Regex::new("#(\\d+)")?,
862                href: String::from("https://github.com/$1"),
863                text: None,
864            },
865        ]);
866
867        let parsed_commit = commit.clone().parse(
868            &[CommitParser {
869                sha: None,
870                message: None,
871                body: Regex::new("something great").ok(),
872                footer: None,
873                group: Some(String::from("Test group")),
874                default_scope: None,
875                scope: None,
876                skip: None,
877                r#continue: None,
878                field: None,
879                pattern: None,
880            }],
881            false,
882            false,
883        )?;
884        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
885
886        Ok(())
887    }
888
889    #[test]
890    fn parse_commit_field() -> Result<()> {
891        let mut commit = Commit::new(
892            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
893            String::from(
894                "fix: do something
895
896Introduce something great
897
898BREAKING CHANGE: drop support for something else
899Refs: #123
900",
901            ),
902        );
903        commit.author = Signature {
904            name: Some("John Doe".to_string()),
905            email: None,
906            timestamp: 0x0,
907        };
908        commit.remote = Some(crate::contributor::RemoteContributor {
909            username: None,
910            pr_author: None,
911            pr_title: Some("feat: do something".to_string()),
912            pr_number: None,
913            pr_numbers: vec![],
914            pr_labels: vec![String::from("feature"), String::from("deprecation")],
915            is_first_time: true,
916        });
917        let commit = commit.into_conventional()?;
918        let commit = commit.parse_links(&[
919            LinkParser {
920                pattern: Regex::new("RFC(\\d+)")?,
921                href: String::from("rfc://$1"),
922                text: None,
923            },
924            LinkParser {
925                pattern: Regex::new("#(\\d+)")?,
926                href: String::from("https://github.com/$1"),
927                text: None,
928            },
929        ]);
930
931        let parsed_commit = commit.clone().parse(
932            &[CommitParser {
933                sha: None,
934                message: None,
935                body: None,
936                footer: None,
937                group: Some(String::from("Test group")),
938                default_scope: None,
939                scope: None,
940                skip: None,
941                r#continue: None,
942                field: Some(String::from("author.name")),
943                pattern: Regex::new("John Doe").ok(),
944            }],
945            false,
946            false,
947        )?;
948        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
949
950        let parsed_commit = commit.clone().parse(
951            &[CommitParser {
952                sha: None,
953                message: None,
954                body: None,
955                footer: None,
956                group: Some(String::from("Test group")),
957                default_scope: None,
958                scope: None,
959                skip: None,
960                r#continue: None,
961                field: Some(String::from("remote.pr_title")),
962                pattern: Regex::new("feat: do something").ok(),
963            }],
964            false,
965            false,
966        )?;
967        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
968
969        let parsed_commit = commit.clone().parse(
970            &[CommitParser {
971                sha: None,
972                message: None,
973                body: None,
974                footer: None,
975                group: Some(String::from("Test group")),
976                default_scope: None,
977                scope: None,
978                skip: None,
979                r#continue: None,
980                field: Some(String::from("body")),
981                pattern: Regex::new("something great").ok(),
982            }],
983            false,
984            false,
985        )?;
986        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
987
988        let parsed_commit = commit.clone().parse(
989            &[CommitParser {
990                sha: None,
991                message: None,
992                body: None,
993                footer: None,
994                group: Some(String::from("Test group")),
995                default_scope: None,
996                scope: None,
997                skip: None,
998                r#continue: None,
999                field: Some(String::from("remote.pr_labels")),
1000                pattern: Regex::new("feature|deprecation").ok(),
1001            }],
1002            false,
1003            false,
1004        )?;
1005        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1006
1007        let parsed_commit = commit.clone().parse(
1008            &[CommitParser {
1009                sha: None,
1010                message: None,
1011                body: None,
1012                footer: None,
1013                group: Some(String::from("Test group")),
1014                default_scope: None,
1015                scope: None,
1016                skip: None,
1017                r#continue: None,
1018                field: Some(String::from("links")),
1019                pattern: Regex::new(".*").ok(),
1020            }],
1021            false,
1022            false,
1023        )?;
1024        assert_eq!(None, parsed_commit.group);
1025
1026        let parse_result = commit.clone().parse(
1027            &[CommitParser {
1028                sha: None,
1029                message: None,
1030                body: None,
1031                footer: None,
1032                group: Some(String::from("Test group")),
1033                default_scope: None,
1034                scope: None,
1035                skip: None,
1036                r#continue: None,
1037                field: Some(String::from("remote")),
1038                pattern: Regex::new(".*").ok(),
1039            }],
1040            false,
1041            false,
1042        );
1043        assert!(
1044            parse_result.is_err(),
1045            "Expected error when using unsupported field `remote`, but got Ok"
1046        );
1047
1048        Ok(())
1049    }
1050
1051    #[test]
1052    fn parse_commit_multiple_parsers() -> Result<()> {
1053        let commit = Commit::new(
1054            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1055            String::from("feat(deep): support multiple parsers"),
1056        );
1057        let commit = commit.into_conventional()?;
1058
1059        // Without `continue`, the first matching parser wins and short-circuits:
1060        // the scope-only parser matches, so the group from the later parser is
1061        // never applied.
1062        let parsers = vec![
1063            CommitParser {
1064                sha: None,
1065                message: Regex::new("\\(deep\\)").ok(),
1066                body: None,
1067                footer: None,
1068                group: None,
1069                default_scope: None,
1070                scope: Some(String::from("Deep Scope")),
1071                skip: None,
1072                r#continue: None,
1073                field: None,
1074                pattern: None,
1075            },
1076            CommitParser {
1077                sha: None,
1078                message: Regex::new("^feat").ok(),
1079                body: None,
1080                footer: None,
1081                group: Some(String::from("Features")),
1082                default_scope: None,
1083                scope: None,
1084                skip: None,
1085                r#continue: None,
1086                field: None,
1087                pattern: None,
1088            },
1089        ];
1090        let parsed = commit.clone().parse(&parsers, false, false)?;
1091        assert_eq!(Some(String::from("Deep Scope")), parsed.scope);
1092        assert_eq!(None, parsed.group);
1093
1094        // With `continue = true` on the composing parsers, the commit picks up
1095        // the scope from the first and the group from the second.
1096        let parsers = vec![
1097            CommitParser {
1098                sha: None,
1099                message: Regex::new("\\(deep\\)").ok(),
1100                body: None,
1101                footer: None,
1102                group: None,
1103                default_scope: None,
1104                scope: Some(String::from("Deep Scope")),
1105                skip: None,
1106                r#continue: Some(true),
1107                field: None,
1108                pattern: None,
1109            },
1110            CommitParser {
1111                sha: None,
1112                message: Regex::new("^feat").ok(),
1113                body: None,
1114                footer: None,
1115                group: Some(String::from("Features")),
1116                default_scope: None,
1117                scope: None,
1118                skip: None,
1119                r#continue: Some(true),
1120                field: None,
1121                pattern: None,
1122            },
1123        ];
1124        let parsed = commit.clone().parse(&parsers, false, true)?;
1125        assert_eq!(Some(String::from("Deep Scope")), parsed.scope);
1126        assert_eq!(Some(String::from("Features")), parsed.group);
1127
1128        // A `continue` parser that matches keeps the commit even when filtering
1129        // is on and it only set a scope (no group).
1130        let scope_only = vec![CommitParser {
1131            sha: None,
1132            message: Regex::new("^feat").ok(),
1133            body: None,
1134            footer: None,
1135            group: None,
1136            default_scope: None,
1137            scope: Some(String::from("Deep Scope")),
1138            skip: None,
1139            r#continue: Some(true),
1140            field: None,
1141            pattern: None,
1142        }];
1143        let parsed = commit.clone().parse(&scope_only, false, true)?;
1144        assert_eq!(Some(String::from("Deep Scope")), parsed.scope);
1145        assert_eq!(None, parsed.group);
1146
1147        // A `continue` parser can set the scope and a following terminal parser
1148        // (no `continue`) can set the group without wiping the scope. The
1149        // terminal parser only overwrites the fields it actually sets, so the
1150        // scope from the first parser is kept instead of being reset to None.
1151        let parsers = vec![
1152            CommitParser {
1153                sha: None,
1154                message: Regex::new("\\(deep\\)").ok(),
1155                body: None,
1156                footer: None,
1157                group: None,
1158                default_scope: None,
1159                scope: Some(String::from("Deep Scope")),
1160                skip: None,
1161                r#continue: Some(true),
1162                field: None,
1163                pattern: None,
1164            },
1165            CommitParser {
1166                sha: None,
1167                message: Regex::new("^feat").ok(),
1168                body: None,
1169                footer: None,
1170                group: Some(String::from("Features")),
1171                default_scope: None,
1172                scope: None,
1173                skip: None,
1174                r#continue: None,
1175                field: None,
1176                pattern: None,
1177            },
1178        ];
1179        let parsed = commit.clone().parse(&parsers, false, false)?;
1180        assert_eq!(Some(String::from("Deep Scope")), parsed.scope);
1181        assert_eq!(Some(String::from("Features")), parsed.group);
1182
1183        // Without a preceding `continue` match, terminal parsers retain the
1184        // original behavior of clearing fields they do not set.
1185        let mut populated_commit = commit;
1186        populated_commit.group = Some(String::from("Old Group"));
1187        populated_commit.scope = Some(String::from("Old Scope"));
1188        populated_commit.default_scope = Some(String::from("Old Default Scope"));
1189        let terminal = vec![CommitParser {
1190            message: Regex::new("^feat").ok(),
1191            group: Some(String::from("Features")),
1192            ..Default::default()
1193        }];
1194        let parsed = populated_commit.parse(&terminal, false, false)?;
1195        assert_eq!(Some(String::from("Features")), parsed.group);
1196        assert_eq!(None, parsed.scope);
1197        assert_eq!(None, parsed.default_scope);
1198
1199        Ok(())
1200    }
1201
1202    #[test]
1203    fn parse_commit_missing_field_falls_through_to_next_parser() -> Result<()> {
1204        let commit = Commit::new(
1205            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1206            String::from("feat: first feature"),
1207        );
1208        // `commit.remote` is `None` by default (this is a LOCAL / non-PR commit),
1209        // so `remote.pr_labels` cannot be resolved.
1210        // The first parser must be skipped (not abort the chain) so the
1211        // catch-all parser below can still match and group the commit.
1212        let parsers = [
1213            CommitParser {
1214                sha: None,
1215                message: Some(Regex::new("^feat")?),
1216                body: None,
1217                footer: None,
1218                group: Some(String::from("Bug fixes")),
1219                default_scope: None,
1220                scope: None,
1221                skip: None,
1222                r#continue: None,
1223                field: Some(String::from("remote.pr_labels")),
1224                pattern: Regex::new("bug").ok(),
1225            },
1226            CommitParser {
1227                sha: None,
1228                message: Some(Regex::new(".*")?),
1229                body: None,
1230                footer: None,
1231                group: Some(String::from("Miscellaneous")),
1232                default_scope: None,
1233                scope: None,
1234                skip: None,
1235                r#continue: None,
1236                field: None,
1237                pattern: None,
1238            },
1239        ];
1240        let parsed = commit.parse(&parsers, false, false)?;
1241        assert_eq!(
1242            Some(String::from("Miscellaneous")),
1243            parsed.group,
1244            "a missing field on parser #1 must fall through to the catch-all parser #2"
1245        );
1246        Ok(())
1247    }
1248
1249    #[test]
1250    fn parse_commit_footer_requires_conventional_data() -> Result<()> {
1251        let parsers = [
1252            CommitParser {
1253                sha: None,
1254                message: Regex::new("^feat:.*?remove").ok(),
1255                body: None,
1256                footer: Regex::new("^BREAKING CHANGE:").ok(),
1257                group: Some(String::from("Removed")),
1258                default_scope: None,
1259                scope: None,
1260                skip: None,
1261                r#continue: None,
1262                field: None,
1263                pattern: None,
1264            },
1265            CommitParser {
1266                sha: None,
1267                message: Some(Regex::new(".*")?),
1268                body: None,
1269                footer: None,
1270                group: Some(String::from("Miscellaneous")),
1271                default_scope: None,
1272                scope: None,
1273                skip: None,
1274                r#continue: None,
1275                field: None,
1276                pattern: None,
1277            },
1278        ];
1279
1280        let commit = Commit::new(
1281            String::new(),
1282            String::from("feat: remove old api\n\nBREAKING CHANGE: drop legacy support"),
1283        )
1284        .parse(&parsers, false, false)?;
1285        assert_eq!(
1286            Some(String::from("Miscellaneous")),
1287            commit.group,
1288            "footer requires conventional data; must not match the combined parser"
1289        );
1290
1291        Ok(())
1292    }
1293
1294    #[test]
1295    fn parse_commit_and_semantics_message_footer() -> Result<()> {
1296        let parsers = [
1297            CommitParser {
1298                sha: None,
1299                message: Regex::new("^feat:.*?remove").ok(),
1300                body: None,
1301                footer: Regex::new("^BREAKING CHANGE:").ok(),
1302                group: Some(String::from("Removed")),
1303                default_scope: None,
1304                scope: None,
1305                skip: None,
1306                r#continue: None,
1307                field: None,
1308                pattern: None,
1309            },
1310            CommitParser {
1311                sha: None,
1312                message: Some(Regex::new(".*")?),
1313                body: None,
1314                footer: None,
1315                group: Some(String::from("Miscellaneous")),
1316                default_scope: None,
1317                scope: None,
1318                skip: None,
1319                r#continue: None,
1320                field: None,
1321                pattern: None,
1322            },
1323        ];
1324
1325        let commit = Commit::new(String::new(), String::from("feat: remove old api"))
1326            .into_conventional()?
1327            .parse(&parsers, false, false)?;
1328        assert_eq!(
1329            Some(String::from("Miscellaneous")),
1330            commit.group,
1331            "message matches but footer doesn't; must not match the combined parser"
1332        );
1333
1334        let commit = Commit::new(
1335            String::new(),
1336            String::from("feat: remove old api\n\nBREAKING CHANGE: drop legacy support"),
1337        )
1338        .into_conventional()?
1339        .parse(&parsers, false, false)?;
1340        assert_eq!(Some(String::from("Removed")), commit.group);
1341
1342        Ok(())
1343    }
1344
1345    #[test]
1346    fn parse_commit_and_semantics_message_body() -> Result<()> {
1347        let parsers = [
1348            CommitParser {
1349                sha: None,
1350                message: Regex::new("^fix:").ok(),
1351                body: Regex::new("security").ok(),
1352                footer: None,
1353                group: Some(String::from("Security")),
1354                default_scope: None,
1355                scope: None,
1356                skip: None,
1357                r#continue: None,
1358                field: None,
1359                pattern: None,
1360            },
1361            CommitParser {
1362                sha: None,
1363                message: Some(Regex::new(".*")?),
1364                body: None,
1365                footer: None,
1366                group: Some(String::from("Miscellaneous")),
1367                default_scope: None,
1368                scope: None,
1369                skip: None,
1370                r#continue: None,
1371                field: None,
1372                pattern: None,
1373            },
1374        ];
1375
1376        let commit = Commit::new(
1377            String::new(),
1378            String::from("fix: patch bug\n\nregular body"),
1379        )
1380        .into_conventional()?
1381        .parse(&parsers, false, false)?;
1382        assert_eq!(
1383            Some(String::from("Miscellaneous")),
1384            commit.group,
1385            "message matches but body doesn't; must not match the combined parser"
1386        );
1387
1388        let commit = Commit::new(
1389            String::new(),
1390            String::from("fix: patch bug\n\nfix a security bug"),
1391        )
1392        .into_conventional()?
1393        .parse(&parsers, false, false)?;
1394        assert_eq!(Some(String::from("Security")), commit.group);
1395
1396        Ok(())
1397    }
1398
1399    #[test]
1400    fn parse_commit_and_semantics_footer_field() -> Result<()> {
1401        let parsers = [
1402            CommitParser {
1403                sha: None,
1404                message: None,
1405                body: None,
1406                footer: Regex::new("^BREAKING CHANGE:").ok(),
1407                group: Some(String::from("Removed")),
1408                default_scope: None,
1409                scope: None,
1410                skip: None,
1411                r#continue: None,
1412                field: Some(String::from("message")),
1413                pattern: Regex::new("remove").ok(),
1414            },
1415            CommitParser {
1416                sha: None,
1417                message: Some(Regex::new(".*")?),
1418                body: None,
1419                footer: None,
1420                group: Some(String::from("Miscellaneous")),
1421                default_scope: None,
1422                scope: None,
1423                skip: None,
1424                r#continue: None,
1425                field: None,
1426                pattern: None,
1427            },
1428        ];
1429
1430        let commit = Commit::new(String::new(), String::from("feat: remove old api"))
1431            .into_conventional()?
1432            .parse(&parsers, false, false)?;
1433        assert_eq!(
1434            Some(String::from("Miscellaneous")),
1435            commit.group,
1436            "field pattern matches but footer doesn't; must not match the combined parser"
1437        );
1438
1439        let commit = Commit::new(
1440            String::new(),
1441            String::from("feat: remove old api\n\nBREAKING CHANGE: drop legacy support"),
1442        )
1443        .into_conventional()?
1444        .parse(&parsers, false, false)?;
1445        assert_eq!(Some(String::from("Removed")), commit.group);
1446
1447        Ok(())
1448    }
1449
1450    #[test]
1451    fn parse_commit_and_semantics_sha_message() -> Result<()> {
1452        let sha = String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6");
1453        let parsers = [
1454            CommitParser {
1455                sha: Some(sha.clone()),
1456                message: Regex::new("^feat:").ok(),
1457                body: None,
1458                footer: None,
1459                group: Some(String::from("Added")),
1460                default_scope: None,
1461                scope: None,
1462                skip: None,
1463                r#continue: None,
1464                field: None,
1465                pattern: None,
1466            },
1467            CommitParser {
1468                sha: None,
1469                message: Some(Regex::new(".*")?),
1470                body: None,
1471                footer: None,
1472                group: Some(String::from("Miscellaneous")),
1473                default_scope: None,
1474                scope: None,
1475                skip: None,
1476                r#continue: None,
1477                field: None,
1478                pattern: None,
1479            },
1480        ];
1481
1482        let commit = Commit::new(sha.clone(), String::from("fix: patch bug"))
1483            .parse(&parsers, false, false)?;
1484        assert_eq!(
1485            Some(String::from("Miscellaneous")),
1486            commit.group,
1487            "sha matches but message doesn't; must not match the combined parser"
1488        );
1489
1490        let commit = Commit::new(
1491            String::from("0000000000000000000000000000000000000000"),
1492            String::from("feat: add feature"),
1493        )
1494        .parse(&parsers, false, false)?;
1495        assert_eq!(
1496            Some(String::from("Miscellaneous")),
1497            commit.group,
1498            "message matches but sha doesn't; must not match the combined parser"
1499        );
1500
1501        let commit =
1502            Commit::new(sha, String::from("feat: add feature")).parse(&parsers, false, false)?;
1503        assert_eq!(Some(String::from("Added")), commit.group);
1504
1505        Ok(())
1506    }
1507
1508    #[test]
1509    fn commit_sha() {
1510        let commit = Commit::new(
1511            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1512            String::from("feat: do something"),
1513        );
1514
1515        let parsed_commit = commit.clone().parse(
1516            &[CommitParser {
1517                sha: Some(String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6")),
1518                message: None,
1519                body: None,
1520                footer: None,
1521                group: None,
1522                default_scope: None,
1523                scope: None,
1524                skip: Some(true),
1525                r#continue: None,
1526                field: None,
1527                pattern: None,
1528            }],
1529            false,
1530            false,
1531        );
1532        assert!(
1533            parsed_commit.is_err(),
1534            "Expected error when parsing with `skip: Some(true)`, but got Ok"
1535        );
1536    }
1537
1538    #[test]
1539    fn field_name_regex() -> Result<()> {
1540        let mut commit = Commit::new(
1541            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1542            String::from("feat: do something"),
1543        );
1544        commit.author = Signature {
1545            name: Some("John Doe".to_string()),
1546            email: None,
1547            timestamp: 0x0,
1548        };
1549        commit.remote = Some(crate::contributor::RemoteContributor {
1550            username: None,
1551            pr_author: None,
1552            pr_title: Some("feat: do something".to_string()),
1553            pr_number: None,
1554            pr_numbers: vec![],
1555            pr_labels: Vec::new(),
1556            is_first_time: true,
1557        });
1558
1559        let parsed_commit = commit.clone().parse(
1560            &[CommitParser {
1561                sha: None,
1562                message: None,
1563                body: None,
1564                footer: None,
1565                group: Some(String::from("Test group")),
1566                default_scope: None,
1567                scope: None,
1568                skip: None,
1569                r#continue: None,
1570                field: Some(String::from("author.name")),
1571                pattern: Regex::new("^John Doe$").ok(),
1572            }],
1573            false,
1574            false,
1575        )?;
1576        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1577
1578        let parsed_commit = commit.clone().parse(
1579            &[CommitParser {
1580                sha: None,
1581                message: None,
1582                body: None,
1583                footer: None,
1584                group: Some(String::from("Test group")),
1585                default_scope: None,
1586                scope: None,
1587                skip: None,
1588                r#continue: None,
1589                field: Some(String::from("remote.pr_title")),
1590                pattern: Regex::new("^feat(\\([^)]+\\))?").ok(),
1591            }],
1592            false,
1593            false,
1594        )?;
1595        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1596
1597        let parse_result = commit.parse(
1598            &[CommitParser {
1599                sha: None,
1600                message: None,
1601                body: None,
1602                footer: None,
1603                group: Some(String::from("Test group")),
1604                default_scope: None,
1605                scope: None,
1606                skip: None,
1607                r#continue: None,
1608                field: Some(String::from("author.name")),
1609                pattern: Regex::new("Something else").ok(),
1610            }],
1611            false,
1612            true,
1613        );
1614        assert!(
1615            parse_result.is_err(),
1616            "Expected error because `author.name` did not match the given pattern, but got Ok"
1617        );
1618
1619        Ok(())
1620    }
1621}