git_cliff_core/
commit.rs

1use git_conventional::{Commit as ConventionalCommit, Footer as ConventionalFooter};
2#[cfg(feature = "repo")]
3use git2::{Commit as GitCommit, Signature as CommitSignature};
4use lazy_regex::{Lazy, Regex, lazy_regex};
5use serde::ser::{SerializeStruct, Serializer};
6use serde::{Deserialize, Deserializer, Serialize};
7use serde_json::value::Value;
8
9use crate::config::{CommitParser, GitConfig, LinkParser, TextProcessor};
10use crate::error::{Error as AppError, Result};
11
12/// Regular expression for matching SHA1 and a following commit message
13/// separated by a whitespace.
14static SHA1_REGEX: Lazy<Regex> = lazy_regex!(r#"^\b([a-f0-9]{40})\b (.*)$"#);
15
16/// Object representing a link
17#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
18#[serde(rename_all(serialize = "camelCase"))]
19pub struct Link {
20    /// Text of the link.
21    pub text: String,
22    /// URL of the link
23    pub href: String,
24}
25
26/// A conventional commit footer.
27#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
28struct Footer<'a> {
29    /// Token of the footer.
30    ///
31    /// This is the part of the footer preceding the separator. For example, for
32    /// the `Signed-off-by: <user.name>` footer, this would be `Signed-off-by`.
33    token: &'a str,
34    /// The separator between the footer token and its value.
35    ///
36    /// This is typically either `:` or `#`.
37    separator: &'a str,
38    /// The value of the footer.
39    value: &'a str,
40    /// A flag to signal that the footer describes a breaking change.
41    breaking: bool,
42}
43
44impl<'a> From<&'a ConventionalFooter<'a>> for Footer<'a> {
45    fn from(footer: &'a ConventionalFooter<'a>) -> Self {
46        Self {
47            token: footer.token().as_str(),
48            separator: footer.separator().as_str(),
49            value: footer.value(),
50            breaking: footer.breaking(),
51        }
52    }
53}
54
55/// Commit signature that indicates authorship.
56#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
57pub struct Signature {
58    /// Name on the signature.
59    pub name: Option<String>,
60    /// Email on the signature.
61    pub email: Option<String>,
62    /// Time of the signature.
63    pub timestamp: i64,
64}
65
66#[cfg(feature = "repo")]
67impl<'a> From<CommitSignature<'a>> for Signature {
68    fn from(signature: CommitSignature<'a>) -> Self {
69        Self {
70            name: signature.name().map(String::from),
71            email: signature.email().map(String::from),
72            timestamp: signature.when().seconds(),
73        }
74    }
75}
76
77/// Commit range (from..to)
78#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Range {
80    /// Full commit SHA the range starts at
81    from: String,
82    /// Full commit SHA the range ends at
83    to: String,
84}
85
86impl Range {
87    /// Creates a new [`Range`] from [`crate::commit::Commit`].
88    pub fn new(from: &Commit, to: &Commit) -> Self {
89        Self {
90            from: from.id.clone(),
91            to: to.id.clone(),
92        }
93    }
94}
95
96/// Common commit object that is parsed from a repository.
97#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
98#[serde(rename_all(serialize = "camelCase"))]
99pub struct Commit<'a> {
100    /// Commit ID.
101    pub id: String,
102    /// Commit message including title, description and summary.
103    pub message: String,
104    /// Conventional commit.
105    #[serde(skip_deserializing)]
106    pub conv: Option<ConventionalCommit<'a>>,
107    /// Commit group based on a commit parser or its conventional type.
108    pub group: Option<String>,
109    /// Default commit scope based on (inherited from) conventional type or a
110    /// commit parser.
111    pub default_scope: Option<String>,
112    /// Commit scope for overriding the default one.
113    pub scope: Option<String>,
114    /// A list of links found in the commit
115    pub links: Vec<Link>,
116    /// Commit author.
117    pub author: Signature,
118    /// Committer.
119    pub committer: Signature,
120    /// Whether if the commit has two or more parents.
121    pub merge_commit: bool,
122    /// Arbitrary data to be used with the `--from-context` CLI option.
123    pub extra: Option<Value>,
124    /// Remote metadata of the commit.
125    pub remote: Option<crate::contributor::RemoteContributor>,
126    /// GitHub metadata of the commit.
127    #[cfg(feature = "github")]
128    #[deprecated(note = "Use `remote` field instead")]
129    pub github: crate::contributor::RemoteContributor,
130    /// GitLab metadata of the commit.
131    #[cfg(feature = "gitlab")]
132    #[deprecated(note = "Use `remote` field instead")]
133    pub gitlab: crate::contributor::RemoteContributor,
134    /// Gitea metadata of the commit.
135    #[cfg(feature = "gitea")]
136    #[deprecated(note = "Use `remote` field instead")]
137    pub gitea: crate::contributor::RemoteContributor,
138    /// Bitbucket metadata of the commit.
139    #[cfg(feature = "bitbucket")]
140    #[deprecated(note = "Use `remote` field instead")]
141    pub bitbucket: crate::contributor::RemoteContributor,
142
143    /// Raw message of the normal commit, works as a placeholder for converting
144    /// normal commit into conventional commit.
145    ///
146    /// Despite the name, it is not actually a raw message.
147    /// In fact, it is pre-processed by [`Commit::preprocess`], and only be
148    /// generated when serializing into `context` the first time.
149    pub raw_message: Option<String>,
150}
151
152impl From<String> for Commit<'_> {
153    fn from(message: String) -> Self {
154        if let Some(captures) = SHA1_REGEX.captures(&message) {
155            if let (Some(id), Some(message)) = (
156                captures.get(1).map(|v| v.as_str()),
157                captures.get(2).map(|v| v.as_str()),
158            ) {
159                return Commit {
160                    id: id.to_string(),
161                    message: message.to_string(),
162                    ..Default::default()
163                };
164            }
165        }
166        Commit {
167            id: String::new(),
168            message,
169            ..Default::default()
170        }
171    }
172}
173
174#[cfg(feature = "repo")]
175impl From<&GitCommit<'_>> for Commit<'_> {
176    fn from(commit: &GitCommit<'_>) -> Self {
177        Commit {
178            id: commit.id().to_string(),
179            message: commit.message().unwrap_or_default().trim_end().to_string(),
180            author: commit.author().into(),
181            committer: commit.committer().into(),
182            merge_commit: commit.parent_count() > 1,
183            ..Default::default()
184        }
185    }
186}
187
188impl Commit<'_> {
189    /// Constructs a new instance.
190    pub fn new(id: String, message: String) -> Self {
191        Self {
192            id,
193            message,
194            ..Default::default()
195        }
196    }
197
198    /// Get raw message for converting into conventional commit.
199    pub fn raw_message(&self) -> &str {
200        self.raw_message.as_deref().unwrap_or(&self.message)
201    }
202
203    /// Processes the commit.
204    ///
205    /// * converts commit to a conventional commit
206    /// * sets the group for the commit
207    /// * extracts links and generates URLs
208    pub fn process(&self, config: &GitConfig) -> Result<Self> {
209        let mut commit = self.clone();
210        commit = commit.preprocess(&config.commit_preprocessors)?;
211        if config.conventional_commits {
212            if !config.require_conventional && config.filter_unconventional && !config.split_commits
213            {
214                commit = commit.into_conventional()?;
215            } else if let Ok(conv_commit) = commit.clone().into_conventional() {
216                commit = conv_commit;
217            }
218        }
219
220        commit = commit.parse(
221            &config.commit_parsers,
222            config.protect_breaking_commits,
223            config.filter_commits,
224        )?;
225
226        commit = commit.parse_links(&config.link_parsers);
227
228        Ok(commit)
229    }
230
231    /// Returns the commit with its conventional type set.
232    pub fn into_conventional(mut self) -> Result<Self> {
233        match ConventionalCommit::parse(Box::leak(self.raw_message().to_string().into_boxed_str()))
234        {
235            Ok(conv) => {
236                self.conv = Some(conv);
237                Ok(self)
238            }
239            Err(e) => Err(AppError::ParseError(e)),
240        }
241    }
242
243    /// Preprocesses the commit using [`TextProcessor`]s.
244    ///
245    /// Modifies the commit [`message`] using regex or custom OS command.
246    ///
247    /// [`message`]: Commit::message
248    pub fn preprocess(mut self, preprocessors: &[TextProcessor]) -> Result<Self> {
249        preprocessors.iter().try_for_each(|preprocessor| {
250            preprocessor.replace(&mut self.message, vec![("COMMIT_SHA", &self.id)])?;
251            Ok::<(), AppError>(())
252        })?;
253        Ok(self)
254    }
255
256    /// States if the commit is skipped in the provided `CommitParser`.
257    ///
258    /// Returns `false` if `protect_breaking_commits` is enabled in the config
259    /// and the commit is breaking, or the parser's `skip` field is None or
260    /// `false`. Returns `true` otherwise.
261    fn skip_commit(&self, parser: &CommitParser, protect_breaking: bool) -> bool {
262        parser.skip.unwrap_or(false) &&
263            !(self.conv.as_ref().map(|c| c.breaking()).unwrap_or(false) && protect_breaking)
264    }
265
266    /// Parses the commit using [`CommitParser`]s.
267    ///
268    /// Sets the [`group`] and [`scope`] of the commit.
269    ///
270    /// [`group`]: Commit::group
271    /// [`scope`]: Commit::scope
272    pub fn parse(
273        mut self,
274        parsers: &[CommitParser],
275        protect_breaking: bool,
276        filter: bool,
277    ) -> Result<Self> {
278        let lookup_context = serde_json::to_value(&self).map_err(|e| {
279            AppError::FieldError(format!("failed to convert context into value: {e}",))
280        })?;
281        for parser in parsers {
282            let mut regex_checks = Vec::new();
283            if let Some(message_regex) = parser.message.as_ref() {
284                regex_checks.push((message_regex, self.message.to_string()));
285            }
286            let body = self
287                .conv
288                .as_ref()
289                .and_then(|v| v.body())
290                .map(|v| v.to_string());
291            if let Some(body_regex) = parser.body.as_ref() {
292                regex_checks.push((body_regex, body.clone().unwrap_or_default()));
293            }
294            if let (Some(footer_regex), Some(footers)) = (
295                parser.footer.as_ref(),
296                self.conv.as_ref().map(|v| v.footers()),
297            ) {
298                regex_checks.extend(footers.iter().map(|f| (footer_regex, f.to_string())));
299            }
300            if let (Some(field_name), Some(pattern_regex)) =
301                (parser.field.as_ref(), parser.pattern.as_ref())
302            {
303                let values = if field_name == "body" {
304                    vec![body.clone()].into_iter().collect()
305                } else {
306                    tera::dotted_pointer(&lookup_context, field_name).and_then(|v| match v {
307                        Value::String(s) => Some(vec![s.clone()]),
308                        Value::Number(_) | Value::Bool(_) | Value::Null => {
309                            Some(vec![v.to_string()])
310                        }
311                        Value::Array(arr) => {
312                            let mut values = Vec::new();
313                            for item in arr {
314                                match item {
315                                    Value::String(s) => values.push(s.clone()),
316                                    Value::Number(_) | Value::Bool(_) | Value::Null => {
317                                        values.push(item.to_string())
318                                    }
319                                    _ => continue,
320                                }
321                            }
322                            Some(values)
323                        }
324                        _ => None,
325                    })
326                };
327                match values {
328                    Some(values) => {
329                        if values.is_empty() {
330                            trace!("field '{field_name}' is present but empty");
331                        } else {
332                            for value in values {
333                                regex_checks.push((pattern_regex, value));
334                            }
335                        }
336                    }
337                    None => {
338                        return Err(AppError::FieldError(format!(
339                            "field '{field_name}' is missing or has unsupported type (expected a \
340                             String, Number, Bool, or Null — or an Array of these scalar values)",
341                        )));
342                    }
343                }
344            }
345            if parser.sha.clone().map(|v| v.to_lowercase()).as_deref() == Some(&self.id) {
346                if self.skip_commit(parser, protect_breaking) {
347                    return Err(AppError::GroupError(String::from("Skipping commit")));
348                } else {
349                    self.group = parser.group.clone().or(self.group);
350                    self.scope = parser.scope.clone().or(self.scope);
351                    self.default_scope = parser.default_scope.clone().or(self.default_scope);
352                    return Ok(self);
353                }
354            }
355            for (regex, text) in regex_checks {
356                if regex.is_match(text.trim()) {
357                    if self.skip_commit(parser, protect_breaking) {
358                        return Err(AppError::GroupError(String::from("Skipping commit")));
359                    } else {
360                        let regex_replace = |mut value: String| {
361                            for mat in regex.find_iter(&text) {
362                                value = regex.replace(mat.as_str(), value).to_string();
363                            }
364                            value
365                        };
366                        self.group = parser.group.clone().map(regex_replace);
367                        self.scope = parser.scope.clone().map(regex_replace);
368                        self.default_scope.clone_from(&parser.default_scope);
369                        return Ok(self);
370                    }
371                }
372            }
373        }
374        if filter {
375            Err(AppError::GroupError(String::from(
376                "Commit does not belong to any group",
377            )))
378        } else {
379            Ok(self)
380        }
381    }
382
383    /// Parses the commit using [`LinkParser`]s.
384    ///
385    /// Sets the [`links`] of the commit.
386    ///
387    /// [`links`]: Commit::links
388    pub fn parse_links(mut self, parsers: &[LinkParser]) -> Self {
389        for parser in parsers {
390            let regex = &parser.pattern;
391            let replace = &parser.href;
392            for mat in regex.find_iter(&self.message) {
393                let m = mat.as_str();
394                let text = if let Some(text_replace) = &parser.text {
395                    regex.replace(m, text_replace).to_string()
396                } else {
397                    m.to_string()
398                };
399                let href = regex.replace(m, replace);
400                self.links.push(Link {
401                    text,
402                    href: href.to_string(),
403                });
404            }
405        }
406        self
407    }
408
409    /// Returns an iterator over this commit's [`Footer`]s, if this is a
410    /// conventional commit.
411    ///
412    /// If this commit is not conventional, the returned iterator will be empty.
413    fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
414        self.conv
415            .iter()
416            .flat_map(|conv| conv.footers().iter().map(Footer::from))
417    }
418}
419
420impl Serialize for Commit<'_> {
421    #[allow(deprecated)]
422    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
423    where
424        S: Serializer,
425    {
426        /// A wrapper to serialize commit footers from an iterator using
427        /// `Serializer::collect_seq` without having to allocate in order to
428        /// `collect` the footers  into a new to `Vec`.
429        struct SerializeFooters<'a>(&'a Commit<'a>);
430        impl Serialize for SerializeFooters<'_> {
431            fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
432            where
433                S: Serializer,
434            {
435                serializer.collect_seq(self.0.footers())
436            }
437        }
438
439        let mut commit = serializer.serialize_struct("Commit", 20)?;
440        commit.serialize_field("id", &self.id)?;
441        if let Some(conv) = &self.conv {
442            commit.serialize_field("message", conv.description())?;
443            commit.serialize_field("body", &conv.body())?;
444            commit.serialize_field("footers", &SerializeFooters(self))?;
445            commit.serialize_field(
446                "group",
447                self.group.as_ref().unwrap_or(&conv.type_().to_string()),
448            )?;
449            commit.serialize_field("breaking_description", &conv.breaking_description())?;
450            commit.serialize_field("breaking", &conv.breaking())?;
451            commit.serialize_field(
452                "scope",
453                &self
454                    .scope
455                    .as_deref()
456                    .or_else(|| conv.scope().map(|v| v.as_str()))
457                    .or(self.default_scope.as_deref()),
458            )?;
459        } else {
460            commit.serialize_field("message", &self.message)?;
461            commit.serialize_field("group", &self.group)?;
462            commit.serialize_field(
463                "scope",
464                &self.scope.as_deref().or(self.default_scope.as_deref()),
465            )?;
466        }
467
468        commit.serialize_field("links", &self.links)?;
469        commit.serialize_field("author", &self.author)?;
470        commit.serialize_field("committer", &self.committer)?;
471        commit.serialize_field("conventional", &self.conv.is_some())?;
472        commit.serialize_field("merge_commit", &self.merge_commit)?;
473        commit.serialize_field("extra", &self.extra)?;
474        #[cfg(feature = "github")]
475        commit.serialize_field("github", &self.github)?;
476        #[cfg(feature = "gitlab")]
477        commit.serialize_field("gitlab", &self.gitlab)?;
478        #[cfg(feature = "gitea")]
479        commit.serialize_field("gitea", &self.gitea)?;
480        #[cfg(feature = "bitbucket")]
481        commit.serialize_field("bitbucket", &self.bitbucket)?;
482        if let Some(remote) = &self.remote {
483            commit.serialize_field("remote", remote)?;
484        }
485        commit.serialize_field("raw_message", &self.raw_message())?;
486        commit.end()
487    }
488}
489
490/// Deserialize commits into conventional commits if they are convertible.
491///
492/// Serialized commits cannot be deserialized into commits that have
493/// [`Commit::conv`]. Thus, we need to manually convert them using
494/// [`Commit::into_conventional`].
495///
496/// This function is to be used only in [`crate::release::Release::commits`].
497pub(crate) fn commits_to_conventional_commits<'de, 'a, D: Deserializer<'de>>(
498    deserializer: D,
499) -> std::result::Result<Vec<Commit<'a>>, D::Error> {
500    let commits = Vec::<Commit<'a>>::deserialize(deserializer)?;
501    let commits = commits
502        .into_iter()
503        .map(|commit| commit.clone().into_conventional().unwrap_or(commit))
504        .collect();
505    Ok(commits)
506}
507
508#[cfg(test)]
509mod test {
510    use super::*;
511    #[test]
512    fn conventional_commit() -> Result<()> {
513        let test_cases = vec![
514            (
515                Commit::new(
516                    String::from("123123"),
517                    String::from("test(commit): add test"),
518                ),
519                true,
520            ),
521            (
522                Commit::new(String::from("124124"), String::from("xyz")),
523                false,
524            ),
525        ];
526
527        for (commit, is_conventional) in &test_cases {
528            assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
529        }
530
531        let commit = test_cases[0].0.clone().parse(
532            &[CommitParser {
533                sha: None,
534                message: Regex::new("test*").ok(),
535                body: None,
536                footer: None,
537                group: Some(String::from("test_group")),
538                default_scope: Some(String::from("test_scope")),
539                scope: None,
540                skip: None,
541                field: None,
542                pattern: None,
543            }],
544            false,
545            false,
546        )?;
547        assert_eq!(Some(String::from("test_group")), commit.group);
548        assert_eq!(Some(String::from("test_scope")), commit.default_scope);
549
550        Ok(())
551    }
552
553    #[test]
554    fn conventional_footers() {
555        let cfg = crate::config::GitConfig {
556            conventional_commits: true,
557            ..Default::default()
558        };
559        let test_cases = vec![
560            (
561                Commit::new(
562                    String::from("123123"),
563                    String::from(
564                        "test(commit): add test\n\nSigned-off-by: Test User <test@example.com>",
565                    ),
566                ),
567                vec![Footer {
568                    token: "Signed-off-by",
569                    separator: ":",
570                    value: "Test User <test@example.com>",
571                    breaking: false,
572                }],
573            ),
574            (
575                Commit::new(
576                    String::from("123124"),
577                    String::from(
578                        "fix(commit): break stuff\n\nBREAKING CHANGE: This commit breaks \
579                         stuff\nSigned-off-by: Test User <test@example.com>",
580                    ),
581                ),
582                vec![
583                    Footer {
584                        token: "BREAKING CHANGE",
585                        separator: ":",
586                        value: "This commit breaks stuff",
587                        breaking: true,
588                    },
589                    Footer {
590                        token: "Signed-off-by",
591                        separator: ":",
592                        value: "Test User <test@example.com>",
593                        breaking: false,
594                    },
595                ],
596            ),
597        ];
598
599        for (commit, footers) in &test_cases {
600            let commit = commit.process(&cfg).expect("commit should process");
601            assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
602        }
603    }
604
605    #[test]
606    fn parse_link() -> Result<()> {
607        let test_cases = vec![
608            (
609                Commit::new(
610                    String::from("123123"),
611                    String::from("test(commit): add test\n\nBody with issue #123"),
612                ),
613                true,
614            ),
615            (
616                Commit::new(
617                    String::from("123123"),
618                    String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #456"),
619                ),
620                true,
621            ),
622        ];
623
624        for (commit, is_conventional) in &test_cases {
625            assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
626        }
627
628        let commit = Commit::new(
629            String::from("123123"),
630            String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"),
631        );
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
659        Ok(())
660    }
661
662    #[test]
663    fn parse_commit() {
664        assert_eq!(
665            Commit::new(String::new(), String::from("test: no sha1 given")),
666            Commit::from(String::from("test: no sha1 given"))
667        );
668
669        assert_eq!(
670            Commit::new(
671                String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
672                String::from("feat: do something")
673            ),
674            Commit::from(String::from(
675                "8f55e69eba6e6ce811ace32bd84cc82215673cb6 feat: do something"
676            ))
677        );
678
679        assert_eq!(
680            Commit::new(
681                String::from("3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853"),
682                String::from("chore: do something")
683            ),
684            Commit::from(String::from(
685                "3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853 chore: do something"
686            ))
687        );
688
689        assert_eq!(
690            Commit::new(
691                String::new(),
692                String::from("thisisinvalidsha1 style: add formatting")
693            ),
694            Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
695        );
696    }
697
698    #[test]
699    fn parse_body() -> Result<()> {
700        let mut commit = Commit::new(
701            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
702            String::from(
703                "fix: do something
704
705Introduce something great
706
707BREAKING CHANGE: drop support for something else
708Refs: #123
709",
710            ),
711        );
712        commit.author = Signature {
713            name: Some("John Doe".to_string()),
714            email: None,
715            timestamp: 0x0,
716        };
717        commit.remote = Some(crate::contributor::RemoteContributor {
718            username: None,
719            pr_title: Some("feat: do something".to_string()),
720            pr_number: None,
721            pr_labels: vec![String::from("feature"), String::from("deprecation")],
722            is_first_time: true,
723        });
724        let commit = commit.into_conventional()?;
725        let commit = commit.parse_links(&[
726            LinkParser {
727                pattern: Regex::new("RFC(\\d+)")?,
728                href: String::from("rfc://$1"),
729                text: None,
730            },
731            LinkParser {
732                pattern: Regex::new("#(\\d+)")?,
733                href: String::from("https://github.com/$1"),
734                text: None,
735            },
736        ]);
737
738        let parsed_commit = commit.clone().parse(
739            &[CommitParser {
740                sha: None,
741                message: None,
742                body: Regex::new("something great").ok(),
743                footer: None,
744                group: Some(String::from("Test group")),
745                default_scope: None,
746                scope: None,
747                skip: None,
748                field: None,
749                pattern: None,
750            }],
751            false,
752            false,
753        )?;
754        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
755
756        Ok(())
757    }
758
759    #[test]
760    fn parse_commit_field() -> Result<()> {
761        let mut commit = Commit::new(
762            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
763            String::from(
764                "fix: do something
765
766Introduce something great
767
768BREAKING CHANGE: drop support for something else
769Refs: #123
770",
771            ),
772        );
773        commit.author = Signature {
774            name: Some("John Doe".to_string()),
775            email: None,
776            timestamp: 0x0,
777        };
778        commit.remote = Some(crate::contributor::RemoteContributor {
779            username: None,
780            pr_title: Some("feat: do something".to_string()),
781            pr_number: None,
782            pr_labels: vec![String::from("feature"), String::from("deprecation")],
783            is_first_time: true,
784        });
785        let commit = commit.into_conventional()?;
786        let commit = commit.parse_links(&[
787            LinkParser {
788                pattern: Regex::new("RFC(\\d+)")?,
789                href: String::from("rfc://$1"),
790                text: None,
791            },
792            LinkParser {
793                pattern: Regex::new("#(\\d+)")?,
794                href: String::from("https://github.com/$1"),
795                text: None,
796            },
797        ]);
798
799        let parsed_commit = commit.clone().parse(
800            &[CommitParser {
801                sha: None,
802                message: None,
803                body: None,
804                footer: None,
805                group: Some(String::from("Test group")),
806                default_scope: None,
807                scope: None,
808                skip: None,
809                field: Some(String::from("author.name")),
810                pattern: Regex::new("John Doe").ok(),
811            }],
812            false,
813            false,
814        )?;
815        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
816
817        let parsed_commit = commit.clone().parse(
818            &[CommitParser {
819                sha: None,
820                message: None,
821                body: None,
822                footer: None,
823                group: Some(String::from("Test group")),
824                default_scope: None,
825                scope: None,
826                skip: None,
827                field: Some(String::from("remote.pr_title")),
828                pattern: Regex::new("feat: do something").ok(),
829            }],
830            false,
831            false,
832        )?;
833        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
834
835        let parsed_commit = commit.clone().parse(
836            &[CommitParser {
837                sha: None,
838                message: None,
839                body: None,
840                footer: None,
841                group: Some(String::from("Test group")),
842                default_scope: None,
843                scope: None,
844                skip: None,
845                field: Some(String::from("body")),
846                pattern: Regex::new("something great").ok(),
847            }],
848            false,
849            false,
850        )?;
851        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
852
853        let parsed_commit = commit.clone().parse(
854            &[CommitParser {
855                sha: None,
856                message: None,
857                body: None,
858                footer: None,
859                group: Some(String::from("Test group")),
860                default_scope: None,
861                scope: None,
862                skip: None,
863                field: Some(String::from("remote.pr_labels")),
864                pattern: Regex::new("feature|deprecation").ok(),
865            }],
866            false,
867            false,
868        )?;
869        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
870
871        let parsed_commit = commit.clone().parse(
872            &[CommitParser {
873                sha: None,
874                message: None,
875                body: None,
876                footer: None,
877                group: Some(String::from("Test group")),
878                default_scope: None,
879                scope: None,
880                skip: None,
881                field: Some(String::from("links")),
882                pattern: Regex::new(".*").ok(),
883            }],
884            false,
885            false,
886        )?;
887        assert_eq!(None, parsed_commit.group);
888
889        let parse_result = commit.clone().parse(
890            &[CommitParser {
891                sha: None,
892                message: None,
893                body: None,
894                footer: None,
895                group: Some(String::from("Test group")),
896                default_scope: None,
897                scope: None,
898                skip: None,
899                field: Some(String::from("remote")),
900                pattern: Regex::new(".*").ok(),
901            }],
902            false,
903            false,
904        );
905        assert!(
906            parse_result.is_err(),
907            "Expected error when using unsupported field `remote`, but got Ok"
908        );
909
910        Ok(())
911    }
912
913    #[test]
914    fn commit_sha() -> Result<()> {
915        let commit = Commit::new(
916            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
917            String::from("feat: do something"),
918        );
919
920        let parsed_commit = commit.clone().parse(
921            &[CommitParser {
922                sha: Some(String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6")),
923                message: None,
924                body: None,
925                footer: None,
926                group: None,
927                default_scope: None,
928                scope: None,
929                skip: Some(true),
930                field: None,
931                pattern: None,
932            }],
933            false,
934            false,
935        );
936        assert!(
937            parsed_commit.is_err(),
938            "Expected error when parsing with `skip: Some(true)`, but got Ok"
939        );
940
941        Ok(())
942    }
943
944    #[test]
945    fn field_name_regex() -> Result<()> {
946        let mut commit = Commit::new(
947            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
948            String::from("feat: do something"),
949        );
950        commit.author = Signature {
951            name: Some("John Doe".to_string()),
952            email: None,
953            timestamp: 0x0,
954        };
955        commit.remote = Some(crate::contributor::RemoteContributor {
956            username: None,
957            pr_title: Some("feat: do something".to_string()),
958            pr_number: None,
959            pr_labels: Vec::new(),
960            is_first_time: true,
961        });
962
963        let parsed_commit = commit.clone().parse(
964            &[CommitParser {
965                sha: None,
966                message: None,
967                body: None,
968                footer: None,
969                group: Some(String::from("Test group")),
970                default_scope: None,
971                scope: None,
972                skip: None,
973                field: Some(String::from("author.name")),
974                pattern: Regex::new("^John Doe$").ok(),
975            }],
976            false,
977            false,
978        )?;
979        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
980
981        let parsed_commit = commit.clone().parse(
982            &[CommitParser {
983                sha: None,
984                message: None,
985                body: None,
986                footer: None,
987                group: Some(String::from("Test group")),
988                default_scope: None,
989                scope: None,
990                skip: None,
991                field: Some(String::from("remote.pr_title")),
992                pattern: Regex::new("^feat(\\([^)]+\\))?").ok(),
993            }],
994            false,
995            false,
996        )?;
997        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
998
999        let parse_result = commit.parse(
1000            &[CommitParser {
1001                sha: None,
1002                message: None,
1003                body: None,
1004                footer: None,
1005                group: Some(String::from("Test group")),
1006                default_scope: None,
1007                scope: None,
1008                skip: None,
1009                field: Some(String::from("author.name")),
1010                pattern: Regex::new("Something else").ok(),
1011            }],
1012            false,
1013            true,
1014        );
1015        assert!(
1016            parse_result.is_err(),
1017            "Expected error because `author.name` did not match the given pattern, but got Ok"
1018        );
1019
1020        Ok(())
1021    }
1022}