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().map(String::from),
75            email: signature.email().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            if 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                return Commit {
183                    id: id.to_string(),
184                    message: message.to_string(),
185                    ..Default::default()
186                };
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        for parser in parsers {
333            let mut regex_checks = Vec::new();
334            if let Some(message_regex) = parser.message.as_ref() {
335                regex_checks.push((message_regex, self.message.clone()));
336            }
337            let body = self
338                .conv
339                .as_ref()
340                .and_then(ConventionalCommit::body)
341                .map(ToString::to_string);
342            if let Some(body_regex) = parser.body.as_ref() {
343                regex_checks.push((body_regex, body.clone().unwrap_or_default()));
344            }
345            if let (Some(footer_regex), Some(footers)) = (
346                parser.footer.as_ref(),
347                self.conv.as_ref().map(ConventionalCommit::footers),
348            ) {
349                regex_checks.extend(footers.iter().map(|f| (footer_regex, f.to_string())));
350            }
351            if let (Some(field_name), Some(pattern_regex)) =
352                (parser.field.as_ref(), parser.pattern.as_ref())
353            {
354                let values = if field_name == "body" {
355                    vec![body.clone()].into_iter().collect()
356                } else {
357                    tera::dotted_pointer(&lookup_context, field_name).and_then(|v| match v {
358                        Value::String(s) => Some(vec![s.clone()]),
359                        Value::Number(_) | Value::Bool(_) | Value::Null => {
360                            Some(vec![v.to_string()])
361                        }
362                        Value::Array(arr) => {
363                            let mut values = Vec::new();
364                            for item in arr {
365                                match item {
366                                    Value::String(s) => values.push(s.clone()),
367                                    Value::Number(_) | Value::Bool(_) | Value::Null => {
368                                        values.push(item.to_string());
369                                    }
370                                    _ => {}
371                                }
372                            }
373                            Some(values)
374                        }
375                        Value::Object(_) => None,
376                    })
377                };
378                match values {
379                    Some(values) => {
380                        if values.is_empty() {
381                            tracing::trace!("Field '{field_name}' is present but empty");
382                        } else {
383                            for value in values {
384                                regex_checks.push((pattern_regex, value));
385                            }
386                        }
387                    }
388                    None => {
389                        return Err(AppError::FieldError(format!(
390                            "field '{field_name}' is missing or has unsupported type (expected a \
391                             String, Number, Bool, or Null — or an Array of these scalar values)",
392                        )));
393                    }
394                }
395            }
396            if parser.sha.clone().map(|v| v.to_lowercase()).as_deref() == Some(&self.id) {
397                if self.skip_commit(parser, protect_breaking) {
398                    return Err(AppError::GroupError(String::from("Skipping commit")));
399                } else {
400                    self.group = parser.group.clone().or(self.group);
401                    self.scope = parser.scope.clone().or(self.scope);
402                    self.default_scope = parser.default_scope.clone().or(self.default_scope);
403                    return Ok(self);
404                }
405            }
406            for (regex, text) in regex_checks {
407                if regex.is_match(text.trim()) {
408                    if self.skip_commit(parser, protect_breaking) {
409                        return Err(AppError::GroupError(String::from("Skipping commit")));
410                    } else {
411                        let regex_replace = |mut value: String| {
412                            for mat in regex.find_iter(&text) {
413                                value = regex.replace(mat.as_str(), value).to_string();
414                            }
415                            value
416                        };
417                        self.group = parser.group.clone().map(regex_replace);
418                        self.scope = parser.scope.clone().map(regex_replace);
419                        self.default_scope.clone_from(&parser.default_scope);
420                        return Ok(self);
421                    }
422                }
423            }
424        }
425        if filter {
426            Err(AppError::GroupError(String::from(
427                "Commit does not belong to any group",
428            )))
429        } else {
430            Ok(self)
431        }
432    }
433
434    /// Parses the commit using [`LinkParser`]s.
435    ///
436    /// Sets the [`links`] of the commit.
437    ///
438    /// [`links`]: Commit::links
439    #[must_use]
440    #[cfg_attr(
441        feature = "tracing",
442        tracing::instrument(
443            skip_all,
444            fields(id = self.id)
445        )
446    )]
447    pub fn parse_links(mut self, parsers: &[LinkParser]) -> Self {
448        crate::set_progress_message!("Parsing links for the commit using link parsers");
449        for parser in parsers {
450            let regex = &parser.pattern;
451            let replace = &parser.href;
452            for mat in regex.find_iter(&self.message) {
453                let m = mat.as_str();
454                let text = if let Some(text_replace) = &parser.text {
455                    regex.replace(m, text_replace).to_string()
456                } else {
457                    m.to_string()
458                };
459                let href = regex.replace(m, replace);
460                self.links.push(Link {
461                    text,
462                    href: href.to_string(),
463                });
464            }
465        }
466        self
467    }
468
469    /// Returns an iterator over this commit's [`Footer`]s, if this is a
470    /// conventional commit.
471    ///
472    /// If this commit is not conventional, the returned iterator will be empty.
473    fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
474        self.conv
475            .iter()
476            .flat_map(|conv| conv.footers().iter().map(Footer::from))
477    }
478}
479
480impl Serialize for Commit<'_> {
481    #[allow(deprecated)]
482    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
483    where
484        S: Serializer,
485    {
486        /// A wrapper to serialize commit footers from an iterator using
487        /// `Serializer::collect_seq` without having to allocate in order to
488        /// `collect` the footers  into a new to `Vec`.
489        struct SerializeFooters<'a>(&'a Commit<'a>);
490        impl Serialize for SerializeFooters<'_> {
491            fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
492            where
493                S: Serializer,
494            {
495                serializer.collect_seq(self.0.footers())
496            }
497        }
498
499        let mut commit = serializer.serialize_struct("Commit", 21)?;
500        commit.serialize_field("id", &self.id)?;
501        if let Some(conv) = &self.conv {
502            commit.serialize_field("message", conv.description())?;
503            commit.serialize_field("body", &conv.body())?;
504            commit.serialize_field("footers", &SerializeFooters(self))?;
505            commit.serialize_field(
506                "group",
507                self.group.as_ref().unwrap_or(&conv.type_().to_string()),
508            )?;
509            commit.serialize_field("breaking_description", &conv.breaking_description())?;
510            commit.serialize_field("breaking", &conv.breaking())?;
511            commit.serialize_field(
512                "scope",
513                &self
514                    .scope
515                    .as_deref()
516                    .or_else(|| conv.scope().map(|v| v.as_str()))
517                    .or(self.default_scope.as_deref()),
518            )?;
519        } else {
520            commit.serialize_field("message", &self.message)?;
521            commit.serialize_field("group", &self.group)?;
522            commit.serialize_field(
523                "scope",
524                &self.scope.as_deref().or(self.default_scope.as_deref()),
525            )?;
526        }
527
528        commit.serialize_field("links", &self.links)?;
529        commit.serialize_field("author", &self.author)?;
530        commit.serialize_field("committer", &self.committer)?;
531        commit.serialize_field("conventional", &self.conv.is_some())?;
532        commit.serialize_field("merge_commit", &self.merge_commit)?;
533        commit.serialize_field("statistics", &self.statistics)?;
534        commit.serialize_field("extra", &self.extra)?;
535        #[cfg(feature = "github")]
536        commit.serialize_field("github", &self.github)?;
537        #[cfg(feature = "gitlab")]
538        commit.serialize_field("gitlab", &self.gitlab)?;
539        #[cfg(feature = "gitea")]
540        commit.serialize_field("gitea", &self.gitea)?;
541        #[cfg(feature = "bitbucket")]
542        commit.serialize_field("bitbucket", &self.bitbucket)?;
543        #[cfg(feature = "azure_devops")]
544        commit.serialize_field("azure_devops", &self.azure_devops)?;
545        if let Some(remote) = &self.remote {
546            commit.serialize_field("remote", remote)?;
547        }
548        commit.serialize_field("raw_message", &self.raw_message())?;
549        commit.end()
550    }
551}
552
553/// Deserialize commits into conventional commits if they are convertible.
554///
555/// Serialized commits cannot be deserialized into commits that have
556/// [`Commit::conv`]. Thus, we need to manually convert them using
557/// [`Commit::into_conventional`].
558///
559/// This function is to be used only in [`crate::release::Release::commits`].
560#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
561pub(crate) fn commits_to_conventional_commits<'de, 'a, D: Deserializer<'de>>(
562    deserializer: D,
563) -> std::result::Result<Vec<Commit<'a>>, D::Error> {
564    crate::set_progress_message!("Converting commits to conventional commits");
565    let commits = Vec::<Commit<'a>>::deserialize(deserializer)?;
566    let commits = commits
567        .into_iter()
568        .map(|commit| commit.clone().into_conventional().unwrap_or(commit))
569        .collect();
570    Ok(commits)
571}
572
573#[cfg(test)]
574mod test {
575    use super::*;
576
577    #[test]
578    fn conventional_commit() -> Result<()> {
579        let test_cases = vec![
580            (
581                Commit::new(
582                    String::from("123123"),
583                    String::from("test(commit): add test"),
584                ),
585                true,
586            ),
587            (
588                Commit::new(String::from("124124"), String::from("xyz")),
589                false,
590            ),
591        ];
592
593        for (commit, is_conventional) in &test_cases {
594            assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
595        }
596
597        let commit = test_cases[0].0.clone().parse(
598            &[CommitParser {
599                sha: None,
600                message: Regex::new("test*").ok(),
601                body: None,
602                footer: None,
603                group: Some(String::from("test_group")),
604                default_scope: Some(String::from("test_scope")),
605                scope: None,
606                skip: None,
607                field: None,
608                pattern: None,
609            }],
610            false,
611            false,
612        )?;
613        assert_eq!(Some(String::from("test_group")), commit.group);
614        assert_eq!(Some(String::from("test_scope")), commit.default_scope);
615
616        Ok(())
617    }
618
619    #[test]
620    fn conventional_footers() {
621        let cfg = crate::config::GitConfig {
622            conventional_commits: true,
623            ..Default::default()
624        };
625        let test_cases = vec![
626            (
627                Commit::new(
628                    String::from("123123"),
629                    String::from(
630                        "test(commit): add test\n\nSigned-off-by: Test User <test@example.com>",
631                    ),
632                ),
633                vec![Footer {
634                    token: "Signed-off-by",
635                    separator: ":",
636                    value: "Test User <test@example.com>",
637                    breaking: false,
638                }],
639            ),
640            (
641                Commit::new(
642                    String::from("123124"),
643                    String::from(
644                        "fix(commit): break stuff\n\nBREAKING CHANGE: This commit breaks \
645                         stuff\nSigned-off-by: Test User <test@example.com>",
646                    ),
647                ),
648                vec![
649                    Footer {
650                        token: "BREAKING CHANGE",
651                        separator: ":",
652                        value: "This commit breaks stuff",
653                        breaking: true,
654                    },
655                    Footer {
656                        token: "Signed-off-by",
657                        separator: ":",
658                        value: "Test User <test@example.com>",
659                        breaking: false,
660                    },
661                ],
662            ),
663        ];
664
665        for (commit, footers) in &test_cases {
666            let commit = commit.process(&cfg).expect("commit should process");
667            assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
668        }
669    }
670
671    #[test]
672    fn parse_link() -> Result<()> {
673        let test_cases = vec![
674            (
675                Commit::new(
676                    String::from("123123"),
677                    String::from("test(commit): add test\n\nBody with issue #123"),
678                ),
679                true,
680            ),
681            (
682                Commit::new(
683                    String::from("123123"),
684                    String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #456"),
685                ),
686                true,
687            ),
688        ];
689
690        for (commit, is_conventional) in &test_cases {
691            assert_eq!(is_conventional, &commit.clone().into_conventional().is_ok());
692        }
693
694        let commit = Commit::new(
695            String::from("123123"),
696            String::from("test(commit): add test\n\nImlement RFC456\n\nFixes: #455"),
697        );
698
699        let commit = commit.parse_links(&[
700            LinkParser {
701                pattern: Regex::new("RFC(\\d+)")?,
702                href: String::from("rfc://$1"),
703                text: None,
704            },
705            LinkParser {
706                pattern: Regex::new("#(\\d+)")?,
707                href: String::from("https://github.com/$1"),
708                text: None,
709            },
710        ]);
711        assert_eq!(
712            vec![
713                Link {
714                    text: String::from("RFC456"),
715                    href: String::from("rfc://456"),
716                },
717                Link {
718                    text: String::from("#455"),
719                    href: String::from("https://github.com/455"),
720                }
721            ],
722            commit.links
723        );
724
725        Ok(())
726    }
727
728    #[test]
729    fn parse_commit() {
730        assert_eq!(
731            Commit::new(String::new(), String::from("test: no sha1 given")),
732            Commit::from(String::from("test: no sha1 given"))
733        );
734
735        assert_eq!(
736            Commit::new(
737                String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
738                String::from("feat: do something")
739            ),
740            Commit::from(String::from(
741                "8f55e69eba6e6ce811ace32bd84cc82215673cb6 feat: do something"
742            ))
743        );
744
745        assert_eq!(
746            Commit::new(
747                String::from("3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853"),
748                String::from("chore: do something")
749            ),
750            Commit::from(String::from(
751                "3bdd0e690c4cd5bd00e5201cc8ef3ce3fb235853 chore: do something"
752            ))
753        );
754
755        assert_eq!(
756            Commit::new(
757                String::new(),
758                String::from("thisisinvalidsha1 style: add formatting")
759            ),
760            Commit::from(String::from("thisisinvalidsha1 style: add formatting"))
761        );
762    }
763
764    #[test]
765    fn parse_body() -> Result<()> {
766        let mut commit = Commit::new(
767            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
768            String::from(
769                "fix: do something
770
771Introduce something great
772
773BREAKING CHANGE: drop support for something else
774Refs: #123
775",
776            ),
777        );
778        commit.author = Signature {
779            name: Some("John Doe".to_string()),
780            email: None,
781            timestamp: 0x0,
782        };
783        commit.remote = Some(crate::contributor::RemoteContributor {
784            username: None,
785            pr_title: Some("feat: do something".to_string()),
786            pr_number: None,
787            pr_labels: vec![String::from("feature"), String::from("deprecation")],
788            is_first_time: true,
789        });
790        let commit = commit.into_conventional()?;
791        let commit = commit.parse_links(&[
792            LinkParser {
793                pattern: Regex::new("RFC(\\d+)")?,
794                href: String::from("rfc://$1"),
795                text: None,
796            },
797            LinkParser {
798                pattern: Regex::new("#(\\d+)")?,
799                href: String::from("https://github.com/$1"),
800                text: None,
801            },
802        ]);
803
804        let parsed_commit = commit.clone().parse(
805            &[CommitParser {
806                sha: None,
807                message: None,
808                body: Regex::new("something great").ok(),
809                footer: None,
810                group: Some(String::from("Test group")),
811                default_scope: None,
812                scope: None,
813                skip: None,
814                field: None,
815                pattern: None,
816            }],
817            false,
818            false,
819        )?;
820        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
821
822        Ok(())
823    }
824
825    #[test]
826    fn parse_commit_field() -> 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_title: Some("feat: do something".to_string()),
847            pr_number: None,
848            pr_labels: vec![String::from("feature"), String::from("deprecation")],
849            is_first_time: true,
850        });
851        let commit = commit.into_conventional()?;
852        let commit = commit.parse_links(&[
853            LinkParser {
854                pattern: Regex::new("RFC(\\d+)")?,
855                href: String::from("rfc://$1"),
856                text: None,
857            },
858            LinkParser {
859                pattern: Regex::new("#(\\d+)")?,
860                href: String::from("https://github.com/$1"),
861                text: None,
862            },
863        ]);
864
865        let parsed_commit = commit.clone().parse(
866            &[CommitParser {
867                sha: None,
868                message: None,
869                body: None,
870                footer: None,
871                group: Some(String::from("Test group")),
872                default_scope: None,
873                scope: None,
874                skip: None,
875                field: Some(String::from("author.name")),
876                pattern: Regex::new("John Doe").ok(),
877            }],
878            false,
879            false,
880        )?;
881        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
882
883        let parsed_commit = commit.clone().parse(
884            &[CommitParser {
885                sha: None,
886                message: None,
887                body: None,
888                footer: None,
889                group: Some(String::from("Test group")),
890                default_scope: None,
891                scope: None,
892                skip: None,
893                field: Some(String::from("remote.pr_title")),
894                pattern: Regex::new("feat: do something").ok(),
895            }],
896            false,
897            false,
898        )?;
899        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
900
901        let parsed_commit = commit.clone().parse(
902            &[CommitParser {
903                sha: None,
904                message: None,
905                body: None,
906                footer: None,
907                group: Some(String::from("Test group")),
908                default_scope: None,
909                scope: None,
910                skip: None,
911                field: Some(String::from("body")),
912                pattern: Regex::new("something great").ok(),
913            }],
914            false,
915            false,
916        )?;
917        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
918
919        let parsed_commit = commit.clone().parse(
920            &[CommitParser {
921                sha: None,
922                message: None,
923                body: None,
924                footer: None,
925                group: Some(String::from("Test group")),
926                default_scope: None,
927                scope: None,
928                skip: None,
929                field: Some(String::from("remote.pr_labels")),
930                pattern: Regex::new("feature|deprecation").ok(),
931            }],
932            false,
933            false,
934        )?;
935        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
936
937        let parsed_commit = commit.clone().parse(
938            &[CommitParser {
939                sha: None,
940                message: None,
941                body: None,
942                footer: None,
943                group: Some(String::from("Test group")),
944                default_scope: None,
945                scope: None,
946                skip: None,
947                field: Some(String::from("links")),
948                pattern: Regex::new(".*").ok(),
949            }],
950            false,
951            false,
952        )?;
953        assert_eq!(None, parsed_commit.group);
954
955        let parse_result = commit.clone().parse(
956            &[CommitParser {
957                sha: None,
958                message: None,
959                body: None,
960                footer: None,
961                group: Some(String::from("Test group")),
962                default_scope: None,
963                scope: None,
964                skip: None,
965                field: Some(String::from("remote")),
966                pattern: Regex::new(".*").ok(),
967            }],
968            false,
969            false,
970        );
971        assert!(
972            parse_result.is_err(),
973            "Expected error when using unsupported field `remote`, but got Ok"
974        );
975
976        Ok(())
977    }
978
979    #[test]
980    fn commit_sha() {
981        let commit = Commit::new(
982            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
983            String::from("feat: do something"),
984        );
985
986        let parsed_commit = commit.clone().parse(
987            &[CommitParser {
988                sha: Some(String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6")),
989                message: None,
990                body: None,
991                footer: None,
992                group: None,
993                default_scope: None,
994                scope: None,
995                skip: Some(true),
996                field: None,
997                pattern: None,
998            }],
999            false,
1000            false,
1001        );
1002        assert!(
1003            parsed_commit.is_err(),
1004            "Expected error when parsing with `skip: Some(true)`, but got Ok"
1005        );
1006    }
1007
1008    #[test]
1009    fn field_name_regex() -> Result<()> {
1010        let mut commit = Commit::new(
1011            String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
1012            String::from("feat: do something"),
1013        );
1014        commit.author = Signature {
1015            name: Some("John Doe".to_string()),
1016            email: None,
1017            timestamp: 0x0,
1018        };
1019        commit.remote = Some(crate::contributor::RemoteContributor {
1020            username: None,
1021            pr_title: Some("feat: do something".to_string()),
1022            pr_number: None,
1023            pr_labels: Vec::new(),
1024            is_first_time: true,
1025        });
1026
1027        let parsed_commit = commit.clone().parse(
1028            &[CommitParser {
1029                sha: None,
1030                message: None,
1031                body: None,
1032                footer: None,
1033                group: Some(String::from("Test group")),
1034                default_scope: None,
1035                scope: None,
1036                skip: None,
1037                field: Some(String::from("author.name")),
1038                pattern: Regex::new("^John Doe$").ok(),
1039            }],
1040            false,
1041            false,
1042        )?;
1043        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1044
1045        let parsed_commit = commit.clone().parse(
1046            &[CommitParser {
1047                sha: None,
1048                message: None,
1049                body: None,
1050                footer: None,
1051                group: Some(String::from("Test group")),
1052                default_scope: None,
1053                scope: None,
1054                skip: None,
1055                field: Some(String::from("remote.pr_title")),
1056                pattern: Regex::new("^feat(\\([^)]+\\))?").ok(),
1057            }],
1058            false,
1059            false,
1060        )?;
1061        assert_eq!(Some(String::from("Test group")), parsed_commit.group);
1062
1063        let parse_result = commit.parse(
1064            &[CommitParser {
1065                sha: None,
1066                message: None,
1067                body: None,
1068                footer: None,
1069                group: Some(String::from("Test group")),
1070                default_scope: None,
1071                scope: None,
1072                skip: None,
1073                field: Some(String::from("author.name")),
1074                pattern: Regex::new("Something else").ok(),
1075            }],
1076            false,
1077            true,
1078        );
1079        assert!(
1080            parse_result.is_err(),
1081            "Expected error because `author.name` did not match the given pattern, but got Ok"
1082        );
1083
1084        Ok(())
1085    }
1086}