conventional_commit_parser/
commit.rs

1use std::fmt;
2use std::fmt::Formatter;
3
4use pest::iterators::Pair;
5
6use crate::commit::CommitType::*;
7use crate::Rule;
8
9/// A commit type consist of a noun describing the kind of modification made.
10/// In addition to the mandatory `fix` and `feat` type, common commit types taken from
11/// [the angular convention](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines)
12/// as their own enum variant. Other type will be parser as [`CommitType::Custom`]
13#[derive(Hash, Eq, PartialEq, Ord, PartialOrd, Debug, Clone)]
14pub enum CommitType {
15    /// *feat*: a commit of the type `feat` introduces a new feature to the codebase (this correlates with `MINOR` in Semantic Versioning).
16    Feature,
17    /// *fix*: a commit of the type `fix` patches a bug in your codebase (this correlates with `PATCH` in Semantic Versioning).
18    BugFix,
19    /// *chore*: Miscellaneous chores
20    Chore,
21    /// See [How does Conventional Commits handle revert commits?](https://www.conventionalcommits.org/en/v1.0.0/#how-does-conventional-commits-handle-revert-commits)
22    Revert,
23    /// *perf*: A code change that improves performance
24    Performances,
25    /// *docs*: Documentation only changes
26    Documentation,
27    /// *style*: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
28    Style,
29    /// *refactor*: A code change that neither fixes a bug nor adds a feature
30    Refactor,
31    /// *test*: Adding missing tests or correcting existing tests
32    Test,
33    /// *build*: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
34    Build,
35    /// *ci*: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
36    Ci,
37    /// A custom commit type, can be anything
38    Custom(String),
39}
40
41/// One or more footers MAY be provided one blank line after the body. Each footer MUST consist of
42/// a word token, followed by either a :<space> or <space># separator, followed by a string value.
43#[derive(Debug, Eq, PartialEq, Default, Clone)]
44pub struct Footer {
45    /// The footer token, either BREAKING CHANGE or a work token
46    pub token: String,
47    /// A string value holding the footer message
48    pub content: String,
49    /// Footer token separator kind, either "#" or ":"
50    pub token_separator: Separator,
51}
52
53/// Footer token separator the "#" separator is
54/// often use to reference github issues.
55#[derive(Debug, Eq, PartialEq, Clone)]
56pub enum Separator {
57    Colon,
58    ColonWithNewLine,
59    Hash,
60}
61
62impl From<&str> for Separator {
63    fn from(separator: &str) -> Self {
64        match separator {
65            ": " => Separator::Colon,
66            " #" => Separator::Hash,
67            ":\n" | ":\r" | ":\r\n" => Separator::ColonWithNewLine,
68            other => unreachable!("Unexpected footer token separator : `{}`", other),
69        }
70    }
71}
72
73impl Default for Separator {
74    fn default() -> Self {
75        Separator::Colon
76    }
77}
78
79impl Footer {
80    /// Return true if a footer as the breaking change token
81    /// ```rust
82    /// # fn main() {
83    /// use conventional_commit_parser::commit::{Footer, Separator};
84    /// use std::ops::Not;
85    /// let footer = Footer {
86    ///     token: "BREAKING CHANGE".to_string(),
87    ///     content: "some changes were made".to_string(),
88    ///     ..Default::default()
89    /// };
90    ///
91    /// assert!(footer.is_breaking_change());
92    ///
93    /// let footer = Footer {
94    ///     token: "a-token".to_string(),
95    ///     content: "Ref 133".to_string(),
96    ///     ..Default::default()
97    /// };
98    ///
99    /// assert!(footer.is_breaking_change().not());
100    /// # }
101    pub fn is_breaking_change(&self) -> bool {
102        self.token == "BREAKING CHANGE" || self.token == "BREAKING-CHANGE"
103    }
104}
105
106/// A conventional commit compliant commit message produced by the [parse] function
107///
108/// [parse]: crate::ConventionalCommitParser::parse
109#[derive(Debug, Eq, PartialEq, Clone)]
110pub struct ConventionalCommit {
111    /// The commit type, `fix`, `feat` etc.
112    pub commit_type: CommitType,
113    /// An optional scope
114    pub scope: Option<String>,
115    /// Commit description summary
116    pub summary: String,
117    /// An optional commit body
118    pub body: Option<String>,
119    /// A list of commit  footers
120    pub footers: Vec<Footer>,
121    /// A commit that has a footer `BREAKING CHANGE` or a `!` after the commit type and scope
122    pub is_breaking_change: bool,
123}
124
125impl From<Pair<'_, Rule>> for Footer {
126    fn from(pairs: Pair<'_, Rule>) -> Self {
127        let mut pair = pairs.into_inner();
128        let token = pair.next().unwrap().as_str().to_string();
129        let separator = pair.next().unwrap().as_str();
130        let token_separator = Separator::from(separator);
131        let content = pair.next().unwrap().as_str().to_string().trim().to_string();
132
133        Footer {
134            token,
135            content,
136            token_separator,
137        }
138    }
139}
140
141impl Default for ConventionalCommit {
142    fn default() -> Self {
143        ConventionalCommit {
144            commit_type: Feature,
145            scope: None,
146            body: None,
147            footers: vec![],
148            summary: "".to_string(),
149            is_breaking_change: false,
150        }
151    }
152}
153
154impl ConventionalCommit {
155    pub(crate) fn set_summary(&mut self, pair: Pair<Rule>) {
156        for pair in pair.into_inner() {
157            match pair.as_rule() {
158                Rule::commit_type => self.set_commit_type(&pair),
159                Rule::scope => self.set_scope(pair),
160                Rule::summary_content => self.set_summary_content(pair),
161                Rule::breaking_change_mark => self.set_breaking_change(pair),
162                _other => (),
163            }
164        }
165    }
166
167    fn set_breaking_change(&mut self, pair: Pair<Rule>) {
168        if !pair.as_str().is_empty() {
169            self.is_breaking_change = true
170        }
171    }
172
173    fn set_summary_content(&mut self, pair: Pair<Rule>) {
174        let summary = pair.as_str();
175        self.summary = summary.to_string();
176    }
177
178    fn set_scope(&mut self, pair: Pair<Rule>) {
179        if let Some(scope) = pair.into_inner().next() {
180            let scope = scope.as_str();
181            if !scope.is_empty() {
182                self.scope = Some(scope.to_string())
183            }
184        };
185    }
186
187    pub fn set_commit_type(&mut self, pair: &Pair<Rule>) {
188        let commit_type = pair.as_str();
189        let commit_type = CommitType::from(commit_type);
190        self.commit_type = commit_type;
191    }
192
193    pub(crate) fn set_commit_body(&mut self, pair: Pair<Rule>) {
194        let body = pair.as_str().trim();
195        if !body.is_empty() {
196            self.body = Some(body.to_string())
197        }
198    }
199
200    pub(crate) fn set_footers(&mut self, pair: Pair<Rule>) {
201        for footer in pair.into_inner() {
202            self.set_footer(footer);
203        }
204    }
205
206    fn set_footer(&mut self, footer: Pair<Rule>) {
207        let footer = Footer::from(footer);
208
209        if footer.is_breaking_change() {
210            self.is_breaking_change = true;
211        }
212
213        self.footers.push(footer);
214    }
215}
216
217impl From<&str> for CommitType {
218    fn from(commit_type: &str) -> Self {
219        match commit_type.to_ascii_lowercase().as_str() {
220            "feat" => Feature,
221            "fix" => BugFix,
222            "chore" => Chore,
223            "revert" => Revert,
224            "perf" => Performances,
225            "docs" => Documentation,
226            "style" => Style,
227            "refactor" => Refactor,
228            "test" => Test,
229            "build" => Build,
230            "ci" => Ci,
231            other => Custom(other.to_string()),
232        }
233    }
234}
235
236impl Default for CommitType {
237    fn default() -> Self {
238        CommitType::Chore
239    }
240}
241
242impl AsRef<str> for CommitType {
243    fn as_ref(&self) -> &str {
244        match self {
245            Feature => "feat",
246            BugFix => "fix",
247            Chore => "chore",
248            Revert => "revert",
249            Performances => "perf",
250            Documentation => "docs",
251            Style => "style",
252            Refactor => "refactor",
253            Test => "test",
254            Build => "build",
255            Ci => "ci",
256            Custom(key) => key,
257        }
258    }
259}
260
261impl ToString for ConventionalCommit {
262    fn to_string(&self) -> String {
263        let mut message = String::new();
264        message.push_str(self.commit_type.as_ref());
265
266        if let Some(scope) = &self.scope {
267            message.push_str(&format!("({})", scope));
268        }
269
270        let has_breaking_change_footer = self.footers.iter().any(|f| f.is_breaking_change());
271
272        if self.is_breaking_change && !has_breaking_change_footer {
273            message.push('!');
274        }
275
276        message.push_str(&format!(": {}", &self.summary));
277
278        if let Some(body) = &self.body {
279            message.push_str(&format!("\n\n{}", body));
280        }
281
282        if !self.footers.is_empty() {
283            message.push('\n');
284        }
285
286        self.footers
287            .iter()
288            .for_each(|footer| match footer.token_separator {
289                Separator::Colon => {
290                    message.push_str(&format!("\n{}: {}", footer.token, footer.content))
291                }
292                Separator::Hash => {
293                    message.push_str(&format!("\n{} #{}", footer.token, footer.content))
294                }
295                Separator::ColonWithNewLine => {
296                    message.push_str(&format!("\n{}:\n{}", footer.token, footer.content))
297                }
298            });
299
300        message
301    }
302}
303
304impl fmt::Display for CommitType {
305    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
306        write!(f, "{}", self.as_ref())
307    }
308}
309
310#[cfg(test)]
311mod test {
312    use indoc::indoc;
313    use speculoos::assert_that;
314    use speculoos::prelude::ResultAssertions;
315
316    use crate::commit::{CommitType, ConventionalCommit, Footer, Separator};
317    use crate::parse;
318
319    #[test]
320    fn commit_to_string_ok() {
321        let commit = ConventionalCommit {
322            commit_type: CommitType::Feature,
323            scope: None,
324            summary: "a feature".to_string(),
325            body: None,
326            footers: Vec::with_capacity(0),
327            is_breaking_change: false,
328        };
329
330        let expected = "feat: a feature".to_string();
331
332        assert_that(&commit.to_string()).is_equal_to(expected);
333        let parsed = parse(&commit.to_string());
334        assert_that(&parsed).is_ok().is_equal_to(commit);
335    }
336
337    #[test]
338    fn commit_to_with_footer_only_string_ok() {
339        let commit = ConventionalCommit {
340            commit_type: CommitType::Chore,
341            scope: None,
342            summary: "a commit".to_string(),
343            body: None,
344            footers: vec![Footer {
345                token: "BREAKING CHANGE".to_string(),
346                content: "message".to_string(),
347                ..Default::default()
348            }],
349            is_breaking_change: true,
350        };
351
352        let expected = indoc!(
353            "chore: a commit
354
355        BREAKING CHANGE: message"
356        )
357        .to_string();
358
359        assert_that(&commit.to_string()).is_equal_to(expected);
360        let parsed = parse(&commit.to_string());
361        assert_that(&parsed).is_ok().is_equal_to(commit);
362    }
363
364    #[test]
365    fn commit_with_body_only_and_breaking_change() {
366        let commit = ConventionalCommit {
367            commit_type: CommitType::Chore,
368            scope: None,
369            summary: "a commit".to_string(),
370            body: Some("A breaking change body on\nmultiple lines".to_string()),
371            footers: Vec::with_capacity(0),
372            is_breaking_change: true,
373        };
374
375        let expected = indoc!(
376            "chore!: a commit
377
378            A breaking change body on
379            multiple lines"
380        )
381        .to_string();
382
383        assert_that(&commit.to_string()).is_equal_to(expected);
384        let parsed = parse(&commit.to_string());
385        assert_that(&parsed).is_ok().is_equal_to(commit);
386    }
387
388    #[test]
389    fn full_commit_to_string() {
390        let commit = ConventionalCommit {
391            commit_type: CommitType::BugFix,
392            scope: Some("code".to_string()),
393            summary: "correct minor typos in code".to_string(),
394            body: Some(
395                indoc!(
396                    "see the issue for details
397
398        on typos fixed."
399                )
400                .to_string(),
401            ),
402            footers: vec![
403                Footer {
404                    token: "Reviewed-by".to_string(),
405                    content: "Z".to_string(),
406                    ..Default::default()
407                },
408                Footer {
409                    token: "Refs".to_string(),
410                    content: "133".to_string(),
411                    token_separator: Separator::Hash,
412                },
413            ],
414            is_breaking_change: false,
415        };
416
417        let expected = indoc!(
418            "fix(code): correct minor typos in code
419
420        see the issue for details
421
422        on typos fixed.
423
424        Reviewed-by: Z
425        Refs #133"
426        )
427        .to_string();
428
429        assert_that(&commit.to_string()).is_equal_to(expected);
430        let parsed = parse(&commit.to_string());
431
432        assert_that(&parsed).is_ok().is_equal_to(commit);
433    }
434}