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