git_conventional/
commit.rs

1//! The conventional commit type and its simple, and typed implementations.
2
3use std::fmt;
4use std::ops::Deref;
5use std::str::FromStr;
6
7use winnow::error::ContextError;
8use winnow::Parser;
9
10use crate::parser::parse;
11use crate::{Error, ErrorKind};
12
13const BREAKING_PHRASE: &str = "BREAKING CHANGE";
14const BREAKING_ARROW: &str = "BREAKING-CHANGE";
15
16/// A conventional commit.
17#[cfg_attr(feature = "serde", derive(serde::Serialize))]
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Commit<'a> {
20    ty: Type<'a>,
21    scope: Option<Scope<'a>>,
22    description: &'a str,
23    body: Option<&'a str>,
24    breaking: bool,
25    #[cfg_attr(feature = "serde", serde(skip))]
26    breaking_description: Option<&'a str>,
27    footers: Vec<Footer<'a>>,
28}
29
30impl<'a> Commit<'a> {
31    /// Create a new Conventional Commit based on the provided commit message
32    /// string.
33    ///
34    /// # Errors
35    ///
36    /// This function returns an error if the commit does not conform to the
37    /// Conventional Commit specification.
38    pub fn parse(string: &'a str) -> Result<Self, Error> {
39        let (ty, scope, breaking, description, body, footers) = parse::<ContextError>
40            .parse(string)
41            .map_err(|err| Error::with_nom(string, err))?;
42
43        let breaking_description = footers
44            .iter()
45            .find_map(|(k, _, v)| (k == &BREAKING_PHRASE || k == &BREAKING_ARROW).then_some(*v))
46            .or_else(|| breaking.then_some(description));
47        let breaking = breaking_description.is_some();
48        let footers: Result<Vec<_>, Error> = footers
49            .into_iter()
50            .map(|(k, s, v)| Ok(Footer::new(FooterToken::new_unchecked(k), s.parse()?, v)))
51            .collect();
52        let footers = footers?;
53
54        Ok(Self {
55            ty: Type::new_unchecked(ty),
56            scope: scope.map(Scope::new_unchecked),
57            description,
58            body,
59            breaking,
60            breaking_description,
61            footers,
62        })
63    }
64
65    /// The type of the commit.
66    pub fn type_(&self) -> Type<'a> {
67        self.ty
68    }
69
70    /// The optional scope of the commit.
71    pub fn scope(&self) -> Option<Scope<'a>> {
72        self.scope
73    }
74
75    /// The commit description.
76    pub fn description(&self) -> &'a str {
77        self.description
78    }
79
80    /// The commit body, containing a more detailed explanation of the commit
81    /// changes.
82    pub fn body(&self) -> Option<&'a str> {
83        self.body
84    }
85
86    /// A flag to signal that the commit contains breaking changes.
87    ///
88    /// This flag is set either when the commit has an exclamation mark after
89    /// the message type and scope, e.g.:
90    /// ```text
91    /// feat(scope)!: this is a breaking change
92    /// ```
93    ///
94    /// Or when the `BREAKING CHANGE: ` footer is defined:
95    /// ```text
96    /// feat: my commit description
97    ///
98    /// BREAKING CHANGE: this is a breaking change
99    /// ```
100    pub fn breaking(&self) -> bool {
101        self.breaking
102    }
103
104    /// Explanation for the breaking change.
105    ///
106    /// Note: if no `BREAKING CHANGE` footer is provided, the `description` is expected to describe
107    /// the breaking change.
108    pub fn breaking_description(&self) -> Option<&'a str> {
109        self.breaking_description
110    }
111
112    /// Any footer.
113    ///
114    /// A footer is similar to a Git trailer, with the exception of not
115    /// requiring whitespace before newlines.
116    ///
117    /// See: <https://git-scm.com/docs/git-interpret-trailers>
118    pub fn footers(&self) -> &[Footer<'a>] {
119        &self.footers
120    }
121}
122
123impl fmt::Display for Commit<'_> {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.write_str(self.type_().as_str())?;
126
127        if let Some(scope) = &self.scope() {
128            f.write_fmt(format_args!("({scope})"))?;
129        }
130
131        f.write_fmt(format_args!(": {}", &self.description()))?;
132
133        if let Some(body) = &self.body() {
134            f.write_fmt(format_args!("\n\n{body}"))?;
135        }
136
137        for footer in self.footers() {
138            write!(f, "\n\n{footer}")?;
139        }
140
141        Ok(())
142    }
143}
144
145/// A single footer.
146///
147/// A footer is similar to a Git trailer, with the exception of not requiring
148/// whitespace before newlines.
149///
150/// See: <https://git-scm.com/docs/git-interpret-trailers>
151#[cfg_attr(feature = "serde", derive(serde::Serialize))]
152#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
153pub struct Footer<'a> {
154    token: FooterToken<'a>,
155    sep: FooterSeparator,
156    value: &'a str,
157}
158
159impl<'a> Footer<'a> {
160    /// Piece together a footer.
161    pub const fn new(token: FooterToken<'a>, sep: FooterSeparator, value: &'a str) -> Self {
162        Self { token, sep, value }
163    }
164
165    /// The token of the footer.
166    pub const fn token(&self) -> FooterToken<'a> {
167        self.token
168    }
169
170    /// The separator between the footer token and its value.
171    pub const fn separator(&self) -> FooterSeparator {
172        self.sep
173    }
174
175    /// The value of the footer.
176    pub const fn value(&self) -> &'a str {
177        self.value
178    }
179
180    /// A flag to signal that the footer describes a breaking change.
181    pub fn breaking(&self) -> bool {
182        self.token.breaking()
183    }
184}
185
186impl fmt::Display for Footer<'_> {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        let Self { token, sep, value } = self;
189        write!(f, "{token}{sep}{value}")
190    }
191}
192
193/// The type of separator between the footer token and value.
194#[cfg_attr(feature = "serde", derive(serde::Serialize))]
195#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
196#[non_exhaustive]
197pub enum FooterSeparator {
198    /// ":"
199    Value,
200
201    /// " #"
202    Ref,
203}
204
205impl FooterSeparator {
206    /// Access `str` representation of `FooterSeparator`
207    pub fn as_str(self) -> &'static str {
208        match self {
209            FooterSeparator::Value => ":",
210            FooterSeparator::Ref => " #",
211        }
212    }
213}
214
215impl Deref for FooterSeparator {
216    type Target = str;
217
218    fn deref(&self) -> &Self::Target {
219        self.as_str()
220    }
221}
222
223impl PartialEq<&'_ str> for FooterSeparator {
224    fn eq(&self, other: &&str) -> bool {
225        self.as_str() == *other
226    }
227}
228
229impl fmt::Display for FooterSeparator {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.write_str(self)
232    }
233}
234
235impl FromStr for FooterSeparator {
236    type Err = Error;
237
238    fn from_str(sep: &str) -> Result<Self, Self::Err> {
239        match sep {
240            ":" => Ok(FooterSeparator::Value),
241            " #" => Ok(FooterSeparator::Ref),
242            _ => {
243                Err(Error::new(ErrorKind::InvalidFooter).set_context(Box::new(format!("{sep:?}"))))
244            }
245        }
246    }
247}
248
249macro_rules! unicase_components {
250    ($($ty:ident),+) => (
251        $(
252            /// A component of the conventional commit.
253            #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
254            pub struct $ty<'a>(unicase::UniCase<&'a str>);
255
256            impl<'a> $ty<'a> {
257                /// See `parse` for ensuring the data is valid.
258                pub const fn new_unchecked(value: &'a str) -> Self {
259                    $ty(unicase::UniCase::unicode(value))
260                }
261
262                /// Access `str` representation
263                pub fn as_str(&self) -> &'a str {
264                    &self.0.into_inner()
265                }
266            }
267
268            impl Deref for $ty<'_> {
269                type Target = str;
270
271                fn deref(&self) -> &Self::Target {
272                    self.as_str()
273                }
274            }
275
276            impl PartialEq<&'_ str> for $ty<'_> {
277                fn eq(&self, other: &&str) -> bool {
278                    *self == $ty::new_unchecked(*other)
279                }
280            }
281
282            impl fmt::Display for $ty<'_> {
283                fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284                    self.0.fmt(f)
285                }
286            }
287
288            #[cfg(feature = "serde")]
289            impl serde::Serialize for $ty<'_> {
290                fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
291                where
292                    S: serde::Serializer,
293                {
294                    serializer.serialize_str(self)
295                }
296            }
297        )+
298    )
299}
300
301unicase_components![Type, Scope, FooterToken];
302
303impl<'a> Type<'a> {
304    /// Parse a `str` into a `Type`.
305    pub fn parse(sep: &'a str) -> Result<Self, Error> {
306        let t = crate::parser::type_::<ContextError>
307            .parse(sep)
308            .map_err(|err| Error::with_nom(sep, err))?;
309        Ok(Type::new_unchecked(t))
310    }
311}
312
313/// Common commit types
314impl Type<'static> {
315    /// Commit type when introducing new features (correlates with `minor` in semver)
316    pub const FEAT: Type<'static> = Type::new_unchecked("feat");
317    /// Commit type when patching a bug (correlates with `patch` in semver)
318    pub const FIX: Type<'static> = Type::new_unchecked("fix");
319    /// Possible commit type when reverting changes.
320    pub const REVERT: Type<'static> = Type::new_unchecked("revert");
321    /// Possible commit type for changing documentation.
322    pub const DOCS: Type<'static> = Type::new_unchecked("docs");
323    /// Possible commit type for changing code style.
324    pub const STYLE: Type<'static> = Type::new_unchecked("style");
325    /// Possible commit type for refactoring code structure.
326    pub const REFACTOR: Type<'static> = Type::new_unchecked("refactor");
327    /// Possible commit type for performance optimizations.
328    pub const PERF: Type<'static> = Type::new_unchecked("perf");
329    /// Possible commit type for addressing tests.
330    pub const TEST: Type<'static> = Type::new_unchecked("test");
331    /// Possible commit type for other things.
332    pub const CHORE: Type<'static> = Type::new_unchecked("chore");
333}
334
335impl<'a> Scope<'a> {
336    /// Parse a `str` into a `Scope`.
337    pub fn parse(sep: &'a str) -> Result<Self, Error> {
338        let t = crate::parser::scope::<ContextError>
339            .parse(sep)
340            .map_err(|err| Error::with_nom(sep, err))?;
341        Ok(Scope::new_unchecked(t))
342    }
343}
344
345impl<'a> FooterToken<'a> {
346    /// Parse a `str` into a `FooterToken`.
347    pub fn parse(sep: &'a str) -> Result<Self, Error> {
348        let t = crate::parser::token::<ContextError>
349            .parse(sep)
350            .map_err(|err| Error::with_nom(sep, err))?;
351        Ok(FooterToken::new_unchecked(t))
352    }
353
354    /// A flag to signal that the footer describes a breaking change.
355    pub fn breaking(&self) -> bool {
356        self == &BREAKING_PHRASE || self == &BREAKING_ARROW
357    }
358}
359
360#[cfg(test)]
361mod test {
362    use super::*;
363    use crate::ErrorKind;
364    use indoc::indoc;
365    #[cfg(feature = "serde")]
366    use serde_test::Token;
367
368    #[test]
369    fn test_valid_simple_commit() {
370        let commit = Commit::parse("type(my scope): hello world").unwrap();
371
372        assert_eq!(commit.type_(), "type");
373        assert_eq!(commit.scope().unwrap(), "my scope");
374        assert_eq!(commit.description(), "hello world");
375    }
376
377    #[test]
378    fn test_trailing_whitespace_without_body() {
379        let commit = Commit::parse("type(my scope): hello world\n\n\n").unwrap();
380
381        assert_eq!(commit.type_(), "type");
382        assert_eq!(commit.scope().unwrap(), "my scope");
383        assert_eq!(commit.description(), "hello world");
384    }
385
386    #[test]
387    fn test_trailing_1_nl() {
388        let commit = Commit::parse("type: hello world\n").unwrap();
389
390        assert_eq!(commit.type_(), "type");
391        assert_eq!(commit.scope(), None);
392        assert_eq!(commit.description(), "hello world");
393    }
394
395    #[test]
396    fn test_trailing_2_nl() {
397        let commit = Commit::parse("type: hello world\n\n").unwrap();
398
399        assert_eq!(commit.type_(), "type");
400        assert_eq!(commit.scope(), None);
401        assert_eq!(commit.description(), "hello world");
402    }
403
404    #[test]
405    fn test_trailing_3_nl() {
406        let commit = Commit::parse("type: hello world\n\n\n").unwrap();
407
408        assert_eq!(commit.type_(), "type");
409        assert_eq!(commit.scope(), None);
410        assert_eq!(commit.description(), "hello world");
411    }
412
413    #[test]
414    fn test_parenthetical_statement() {
415        let commit = Commit::parse("type: hello world (#1)").unwrap();
416
417        assert_eq!(commit.type_(), "type");
418        assert_eq!(commit.scope(), None);
419        assert_eq!(commit.description(), "hello world (#1)");
420    }
421
422    #[test]
423    fn test_multiline_description() {
424        let err = Commit::parse(
425            "chore: Automate fastlane when a file in the fastlane directory is\nchanged (hopefully)",
426        ).unwrap_err();
427
428        assert_eq!(ErrorKind::InvalidBody, err.kind());
429    }
430
431    #[test]
432    fn test_issue_12_case_1() {
433        // Looks like it was test_trailing_2_nl that triggered this to fail originally
434        let commit = Commit::parse("chore: add .hello.txt (#1)\n\n").unwrap();
435
436        assert_eq!(commit.type_(), "chore");
437        assert_eq!(commit.scope(), None);
438        assert_eq!(commit.description(), "add .hello.txt (#1)");
439    }
440
441    #[test]
442    fn test_issue_12_case_2() {
443        // Looks like it was test_trailing_2_nl that triggered this to fail originally
444        let commit = Commit::parse("refactor: use fewer lines (#3)\n\n").unwrap();
445
446        assert_eq!(commit.type_(), "refactor");
447        assert_eq!(commit.scope(), None);
448        assert_eq!(commit.description(), "use fewer lines (#3)");
449    }
450
451    #[test]
452    fn test_breaking_change() {
453        let commit = Commit::parse("feat!: this is a breaking change").unwrap();
454        assert_eq!(Type::FEAT, commit.type_());
455        assert!(commit.breaking());
456        assert_eq!(
457            commit.breaking_description(),
458            Some("this is a breaking change")
459        );
460
461        let commit = Commit::parse(indoc!(
462            "feat: message
463
464            BREAKING CHANGE: breaking change"
465        ))
466        .unwrap();
467        assert_eq!(Type::FEAT, commit.type_());
468        assert_eq!("breaking change", commit.footers().first().unwrap().value());
469        assert!(commit.breaking());
470        assert_eq!(commit.breaking_description(), Some("breaking change"));
471
472        let commit = Commit::parse(indoc!(
473            "fix: message
474
475            BREAKING-CHANGE: it's broken"
476        ))
477        .unwrap();
478        assert_eq!(Type::FIX, commit.type_());
479        assert_eq!("it's broken", commit.footers().first().unwrap().value());
480        assert!(commit.breaking());
481        assert_eq!(commit.breaking_description(), Some("it's broken"));
482    }
483
484    #[test]
485    fn test_conjoined_footer() {
486        let commit = Commit::parse(
487            "fix(example): fix keepachangelog config example
488
489Fixes: #123, #124, #125",
490        )
491        .unwrap();
492        assert_eq!(Type::FIX, commit.type_());
493        assert_eq!(commit.body(), None);
494        assert_eq!(
495            commit.footers(),
496            [Footer::new(
497                FooterToken("Fixes".into()),
498                FooterSeparator::Value,
499                "#123, #124, #125"
500            ),]
501        );
502    }
503
504    #[test]
505    fn test_windows_line_endings() {
506        let commit =
507            Commit::parse("feat: thing\r\n\r\nbody\r\n\r\ncloses #1234\r\n\r\n\r\nBREAKING CHANGE: something broke\r\n\r\n")
508                .unwrap();
509        assert_eq!(commit.body(), Some("body"));
510        assert_eq!(
511            commit.footers(),
512            [
513                Footer::new(FooterToken("closes".into()), FooterSeparator::Ref, "1234"),
514                Footer::new(
515                    FooterToken("BREAKING CHANGE".into()),
516                    FooterSeparator::Value,
517                    "something broke"
518                ),
519            ]
520        );
521        assert_eq!(commit.breaking_description(), Some("something broke"));
522    }
523
524    #[test]
525    fn test_extra_line_endings() {
526        let commit =
527            Commit::parse("feat: thing\n\n\n\n\nbody\n\n\n\n\ncloses #1234\n\n\n\n\n\nBREAKING CHANGE: something broke\n\n\n\n")
528                .unwrap();
529        assert_eq!(commit.body(), Some("body"));
530        assert_eq!(
531            commit.footers(),
532            [
533                Footer::new(FooterToken("closes".into()), FooterSeparator::Ref, "1234"),
534                Footer::new(
535                    FooterToken("BREAKING CHANGE".into()),
536                    FooterSeparator::Value,
537                    "something broke"
538                ),
539            ]
540        );
541        assert_eq!(commit.breaking_description(), Some("something broke"));
542    }
543
544    #[test]
545    fn test_fake_footer() {
546        let commit = indoc! {"
547            fix: something something
548
549            First line of the body
550            IMPORTANT: Please see something else for details.
551            Another line here.
552        "};
553
554        let commit = Commit::parse(commit).unwrap();
555
556        assert_eq!(Type::FIX, commit.type_());
557        assert_eq!(None, commit.scope());
558        assert_eq!("something something", commit.description());
559        assert_eq!(
560            Some(indoc!(
561                "
562                First line of the body
563                IMPORTANT: Please see something else for details.
564                Another line here."
565            )),
566            commit.body()
567        );
568        let empty_footer: &[Footer<'_>] = &[];
569        assert_eq!(empty_footer, commit.footers());
570    }
571
572    #[test]
573    fn test_valid_complex_commit() {
574        let commit = indoc! {"
575            chore: improve changelog readability
576
577            Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit
578            easier to parse while reading.
579
580            BREAKING CHANGE: Just kidding!
581        "};
582
583        let commit = Commit::parse(commit).unwrap();
584
585        assert_eq!(Type::CHORE, commit.type_());
586        assert_eq!(None, commit.scope());
587        assert_eq!("improve changelog readability", commit.description());
588        assert_eq!(
589            Some(indoc!(
590                "Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit
591                 easier to parse while reading."
592            )),
593            commit.body()
594        );
595        assert_eq!("Just kidding!", commit.footers().first().unwrap().value());
596    }
597
598    #[test]
599    fn test_missing_type() {
600        let err = Commit::parse("").unwrap_err();
601
602        assert_eq!(ErrorKind::MissingType, err.kind());
603    }
604
605    #[cfg(feature = "serde")]
606    #[test]
607    fn test_commit_serialize() {
608        let commit = Commit::parse("type(my scope): hello world").unwrap();
609        serde_test::assert_ser_tokens(
610            &commit,
611            &[
612                Token::Struct {
613                    name: "Commit",
614                    len: 6,
615                },
616                Token::Str("ty"),
617                Token::Str("type"),
618                Token::Str("scope"),
619                Token::Some,
620                Token::Str("my scope"),
621                Token::Str("description"),
622                Token::Str("hello world"),
623                Token::Str("body"),
624                Token::None,
625                Token::Str("breaking"),
626                Token::Bool(false),
627                Token::Str("footers"),
628                Token::Seq { len: Some(0) },
629                Token::SeqEnd,
630                Token::StructEnd,
631            ],
632        );
633    }
634}