conventional_commit/
lib.rs

1//! A parser library for the [Conventional Commit] specification.
2//!
3//! [conventional commit]: https://www.conventionalcommits.org
4//!
5//! # Example
6//!
7//! ```rust
8//! use conventional_commit::{ConventionalCommit, Error};
9//! use std::str::FromStr;
10//!
11//! fn main() -> Result<(), Error> {
12//!     let message = "\
13//!     docs(example): add tested usage example
14//!
15//!     This example is tested using Rust's doctest capabilities. Having this
16//!     example helps people understand how to use the parser.
17//!
18//!     BREAKING CHANGE: Going from nothing to something, meaning anyone doing
19//!     nothing before suddenly has something to do. That sounds like a change
20//!     in your break.
21//!     ";
22//!
23//!     let commit = ConventionalCommit::from_str(message)?;
24//!
25//!     assert_eq!(commit.type_(), "docs");
26//!     assert_eq!(commit.scope(), Some("example"));
27//!     assert_eq!(commit.description(), "add tested usage example");
28//!     assert!(commit.body().unwrap().contains("helps people understand"));
29//!     assert!(commit.breaking_change().unwrap().contains("That sounds like a change"));
30//!     # Ok(())
31//! }
32//! ```
33
34#![deny(
35    clippy::all,
36    clippy::cargo,
37    clippy::clone_on_ref_ptr,
38    clippy::dbg_macro,
39    clippy::indexing_slicing,
40    clippy::mem_forget,
41    clippy::multiple_inherent_impl,
42    clippy::nursery,
43    clippy::option_unwrap_used,
44    clippy::pedantic,
45    clippy::print_stdout,
46    clippy::result_unwrap_used,
47    clippy::unimplemented,
48    clippy::use_debug,
49    clippy::wildcard_enum_match_arm,
50    clippy::wrong_pub_self_convention,
51    deprecated_in_future,
52    future_incompatible,
53    missing_copy_implementations,
54    missing_debug_implementations,
55    missing_docs,
56    nonstandard_style,
57    rust_2018_idioms,
58    rustdoc,
59    trivial_casts,
60    trivial_numeric_casts,
61    unreachable_pub,
62    unsafe_code,
63    unused_import_braces,
64    unused_lifetimes,
65    unused_qualifications,
66    unused_results,
67    variant_size_differences,
68    warnings
69)]
70#![doc(html_root_url = "https://docs.rs/conventional-commit")]
71
72use itertools::Itertools;
73use std::fmt;
74use std::str::FromStr;
75use unicode_segmentation::UnicodeSegmentation;
76
77/// A conventional commit.
78#[derive(Debug)]
79pub struct ConventionalCommit {
80    ty: String,
81    scope: Option<String>,
82    description: String,
83    body: Option<String>,
84    breaking_change: Option<String>,
85}
86
87impl ConventionalCommit {
88    /// The type of the commit.
89    pub fn type_(&self) -> &str {
90        self.ty.trim()
91    }
92
93    /// The optional scope of the commit.
94    pub fn scope(&self) -> Option<&str> {
95        self.scope.as_ref().map(String::as_str).map(str::trim)
96    }
97
98    /// The commit description.
99    pub fn description(&self) -> &str {
100        self.description.trim()
101    }
102
103    /// The commit body, containing a more detailed explanation of the commit
104    /// changes.
105    pub fn body(&self) -> Option<&str> {
106        self.body.as_ref().map(String::as_str).map(str::trim)
107    }
108
109    /// The text discussing any breaking changes.
110    pub fn breaking_change(&self) -> Option<&str> {
111        self.breaking_change
112            .as_ref()
113            .map(String::as_str)
114            .map(str::trim)
115    }
116}
117
118impl fmt::Display for ConventionalCommit {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(self.type_())?;
121
122        if let Some(scope) = &self.scope() {
123            f.write_fmt(format_args!("({})", scope))?;
124        }
125
126        f.write_fmt(format_args!(": {}", self.description))?;
127
128        if let Some(body) = &self.body() {
129            f.write_fmt(format_args!("\n\n{}", body))?;
130        }
131
132        if let Some(breaking_change) = &self.breaking_change() {
133            f.write_fmt(format_args!("\n\nBREAKING CHANGE: {}", breaking_change))?;
134        }
135
136        Ok(())
137    }
138}
139
140impl FromStr for ConventionalCommit {
141    type Err = Error;
142
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        use Error::*;
145
146        // Example:
147        //
148        // chore(changelog): improve changelog readability
149        //
150        // Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny
151        // bit easier to parse while reading.
152        let mut chars = UnicodeSegmentation::graphemes(s, true).peekable();
153
154        // ex: "chore"
155        let ty: String = chars
156            .peeking_take_while(|&c| c != "(" && c != ":")
157            .collect();
158        if ty.is_empty() {
159            return Err(MissingType);
160        }
161
162        // ex: "changelog"
163        let mut scope: Option<String> = None;
164        if chars.peek() == Some(&"(") {
165            let _ = scope.replace(chars.peeking_take_while(|&c| c != ")").skip(1).collect());
166            chars = chars.dropping(1);
167        }
168
169        if chars.by_ref().take(2).collect::<String>() != ": " {
170            return Err(InvalidFormat);
171        }
172
173        // ex: "improve changelog readability"
174        let description: String = chars.peeking_take_while(|&c| c != "\n").collect();
175        if description.is_empty() {
176            return Err(MissingDescription);
177        }
178
179        let other: String = chars.collect::<String>().trim().to_owned();
180
181        // ex: "Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a
182        //      tiny bit easier to parse while reading."
183        let (body, breaking_change) = if other.is_empty() {
184            (None, None)
185        } else {
186            let mut data = other
187                .splitn(2, "BREAKING CHANGE:")
188                .map(|s| s.trim().to_owned());
189
190            (data.next(), data.next())
191        };
192
193        Ok(Self {
194            ty,
195            scope,
196            description,
197            body,
198            breaking_change,
199        })
200    }
201}
202
203/// All possible errors returned when parsing a conventional commit.
204#[derive(Copy, Clone, Debug, Eq, PartialEq)]
205pub enum Error {
206    /// The commit type is missing from the commit message.
207    MissingType,
208
209    /// The scope has an invalid format.
210    InvalidScope,
211
212    /// The description of the commit is missing.
213    MissingDescription,
214
215    /// The body of the commit has an invalid format.
216    InvalidBody,
217
218    /// Any other part of the commit does not conform to the conventional commit
219    /// spec.
220    InvalidFormat,
221}
222
223impl fmt::Display for Error {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        use Error::*;
226
227        match self {
228            MissingType => f.write_str("missing type definition"),
229            InvalidScope => f.write_str("invalid scope format"),
230            MissingDescription => f.write_str("missing commit description"),
231            InvalidBody => f.write_str("invalid body format"),
232            InvalidFormat => f.write_str("invalid commit format"),
233        }
234    }
235}
236
237impl std::error::Error for Error {
238    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
239        None
240    }
241}
242
243#[cfg(test)]
244#[allow(clippy::result_unwrap_used)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_valid_simple_commit() {
250        let commit = ConventionalCommit::from_str("my type(my scope): hello world").unwrap();
251
252        assert_eq!("my type", commit.type_());
253        assert_eq!(Some("my scope"), commit.scope());
254        assert_eq!("hello world", commit.description());
255    }
256
257    #[test]
258    fn test_valid_complex_commit() {
259        let commit = "chore: improve changelog readability\n
260                      \n
261                      Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit \
262                      easier to parse while reading.\n
263                      \n
264                      BREAKING CHANGE: Just kidding!";
265
266        let commit = ConventionalCommit::from_str(commit).unwrap();
267
268        assert_eq!("chore", commit.type_());
269        assert_eq!(None, commit.scope());
270        assert_eq!("improve changelog readability", commit.description());
271        assert_eq!(
272            Some(
273                "Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit \
274                 easier to parse while reading."
275            ),
276            commit.body()
277        );
278        assert_eq!(Some("Just kidding!"), commit.breaking_change());
279    }
280
281    #[test]
282    fn test_missing_type() {
283        let err = ConventionalCommit::from_str("").unwrap_err();
284
285        assert_eq!(Error::MissingType, err);
286    }
287}