use std::fmt;
use std::fmt::Formatter;
use pest::iterators::Pair;
use crate::commit::CommitType::*;
use crate::Rule;
#[derive(Hash, Eq, PartialEq, Ord, PartialOrd, Debug, Clone)]
pub enum CommitType {
Feature,
BugFix,
Chore,
Revert,
Performances,
Documentation,
Style,
Refactor,
Test,
Build,
Ci,
Custom(String),
}
#[derive(Debug, Eq, PartialEq, Default, Clone)]
pub struct Footer {
pub token: String,
pub content: String,
pub token_separator: Separator,
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum Separator {
Colon,
ColonWithNewLine,
Hash,
}
impl From<&str> for Separator {
fn from(separator: &str) -> Self {
match separator {
": " => Separator::Colon,
" #" => Separator::Hash,
":\n" | ":\r" | ":\r\n" => Separator::ColonWithNewLine,
other => unreachable!("Unexpected footer token separator : `{}`", other),
}
}
}
impl Default for Separator {
fn default() -> Self {
Separator::Colon
}
}
impl Footer {
pub fn is_breaking_change(&self) -> bool {
self.token == "BREAKING CHANGE" || self.token == "BREAKING-CHANGE"
}
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct ConventionalCommit {
pub commit_type: CommitType,
pub scope: Option<String>,
pub summary: String,
pub body: Option<String>,
pub footers: Vec<Footer>,
pub is_breaking_change: bool,
}
impl From<Pair<'_, Rule>> for Footer {
fn from(pairs: Pair<'_, Rule>) -> Self {
let mut pair = pairs.into_inner();
let token = pair.next().unwrap().as_str().to_string();
let separator = pair.next().unwrap().as_str();
let token_separator = Separator::from(separator);
let content = pair.next().unwrap().as_str().to_string().trim().to_string();
Footer {
token,
content,
token_separator,
}
}
}
impl Default for ConventionalCommit {
fn default() -> Self {
ConventionalCommit {
commit_type: Feature,
scope: None,
body: None,
footers: vec![],
summary: "".to_string(),
is_breaking_change: false,
}
}
}
impl ConventionalCommit {
pub(crate) fn set_summary(&mut self, pair: Pair<Rule>) {
for pair in pair.into_inner() {
match pair.as_rule() {
Rule::commit_type => self.set_commit_type(&pair),
Rule::scope => self.set_scope(pair),
Rule::summary_content => self.set_summary_content(pair),
Rule::breaking_change_mark => self.set_breaking_change(pair),
_other => (),
}
}
}
fn set_breaking_change(&mut self, pair: Pair<Rule>) {
if !pair.as_str().is_empty() {
self.is_breaking_change = true
}
}
fn set_summary_content(&mut self, pair: Pair<Rule>) {
let summary = pair.as_str();
self.summary = summary.to_string();
}
fn set_scope(&mut self, pair: Pair<Rule>) {
if let Some(scope) = pair.into_inner().next() {
let scope = scope.as_str();
if !scope.is_empty() {
self.scope = Some(scope.to_string())
}
};
}
pub fn set_commit_type(&mut self, pair: &Pair<Rule>) {
let commit_type = pair.as_str();
let commit_type = CommitType::from(commit_type);
self.commit_type = commit_type;
}
pub(crate) fn set_commit_body(&mut self, pair: Pair<Rule>) {
let body = pair.as_str().trim();
if !body.is_empty() {
self.body = Some(body.to_string())
}
}
pub(crate) fn set_footers(&mut self, pair: Pair<Rule>) {
for footer in pair.into_inner() {
self.set_footer(footer);
}
}
fn set_footer(&mut self, footer: Pair<Rule>) {
let footer = Footer::from(footer);
if footer.is_breaking_change() {
self.is_breaking_change = true;
}
self.footers.push(footer);
}
}
impl From<&str> for CommitType {
fn from(commit_type: &str) -> Self {
match commit_type.to_ascii_lowercase().as_str() {
"feat" => Feature,
"fix" => BugFix,
"chore" => Chore,
"revert" => Revert,
"perf" => Performances,
"docs" => Documentation,
"style" => Style,
"refactor" => Refactor,
"test" => Test,
"build" => Build,
"ci" => Ci,
other => Custom(other.to_string()),
}
}
}
impl Default for CommitType {
fn default() -> Self {
CommitType::Chore
}
}
impl AsRef<str> for CommitType {
fn as_ref(&self) -> &str {
match self {
Feature => "feat",
BugFix => "fix",
Chore => "chore",
Revert => "revert",
Performances => "perf",
Documentation => "docs",
Style => "style",
Refactor => "refactor",
Test => "test",
Build => "build",
Ci => "ci",
Custom(key) => key,
}
}
}
impl ToString for ConventionalCommit {
fn to_string(&self) -> String {
let mut message = String::new();
message.push_str(self.commit_type.as_ref());
if let Some(scope) = &self.scope {
message.push_str(&format!("({})", scope));
}
let has_breaking_change_footer = self.footers.iter().any(|f| f.is_breaking_change());
if self.is_breaking_change && !has_breaking_change_footer {
message.push('!');
}
message.push_str(&format!(": {}", &self.summary));
if let Some(body) = &self.body {
message.push_str(&format!("\n\n{}", body));
}
if !self.footers.is_empty() {
message.push('\n');
}
self.footers
.iter()
.for_each(|footer| match footer.token_separator {
Separator::Colon => {
message.push_str(&format!("\n{}: {}", footer.token, footer.content))
}
Separator::Hash => {
message.push_str(&format!("\n{} #{}", footer.token, footer.content))
}
Separator::ColonWithNewLine => {
message.push_str(&format!("\n{}:\n{}", footer.token, footer.content))
}
});
message
}
}
impl fmt::Display for CommitType {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_ref())
}
}
#[cfg(test)]
mod test {
use indoc::indoc;
use speculoos::assert_that;
use speculoos::prelude::ResultAssertions;
use crate::commit::{CommitType, ConventionalCommit, Footer, Separator};
use crate::parse;
#[test]
fn commit_to_string_ok() {
let commit = ConventionalCommit {
commit_type: CommitType::Feature,
scope: None,
summary: "a feature".to_string(),
body: None,
footers: Vec::with_capacity(0),
is_breaking_change: false,
};
let expected = "feat: a feature".to_string();
assert_that(&commit.to_string()).is_equal_to(expected);
let parsed = parse(&commit.to_string());
assert_that(&parsed).is_ok().is_equal_to(commit);
}
#[test]
fn commit_to_with_footer_only_string_ok() {
let commit = ConventionalCommit {
commit_type: CommitType::Chore,
scope: None,
summary: "a commit".to_string(),
body: None,
footers: vec![Footer {
token: "BREAKING CHANGE".to_string(),
content: "message".to_string(),
..Default::default()
}],
is_breaking_change: true,
};
let expected = indoc!(
"chore: a commit
BREAKING CHANGE: message"
)
.to_string();
assert_that(&commit.to_string()).is_equal_to(expected);
let parsed = parse(&commit.to_string());
assert_that(&parsed).is_ok().is_equal_to(commit);
}
#[test]
fn commit_with_body_only_and_breaking_change() {
let commit = ConventionalCommit {
commit_type: CommitType::Chore,
scope: None,
summary: "a commit".to_string(),
body: Some("A breaking change body on\nmultiple lines".to_string()),
footers: Vec::with_capacity(0),
is_breaking_change: true,
};
let expected = indoc!(
"chore!: a commit
A breaking change body on
multiple lines"
)
.to_string();
assert_that(&commit.to_string()).is_equal_to(expected);
let parsed = parse(&commit.to_string());
assert_that(&parsed).is_ok().is_equal_to(commit);
}
#[test]
fn full_commit_to_string() {
let commit = ConventionalCommit {
commit_type: CommitType::BugFix,
scope: Some("code".to_string()),
summary: "correct minor typos in code".to_string(),
body: Some(
indoc!(
"see the issue for details
on typos fixed."
)
.to_string(),
),
footers: vec![
Footer {
token: "Reviewed-by".to_string(),
content: "Z".to_string(),
..Default::default()
},
Footer {
token: "Refs".to_string(),
content: "133".to_string(),
token_separator: Separator::Hash,
},
],
is_breaking_change: false,
};
let expected = indoc!(
"fix(code): correct minor typos in code
see the issue for details
on typos fixed.
Reviewed-by: Z
Refs #133"
)
.to_string();
assert_that(&commit.to_string()).is_equal_to(expected);
let parsed = parse(&commit.to_string());
assert_that(&parsed).is_ok().is_equal_to(commit);
}
}