use std::fmt;
use std::ops::Deref;
use std::str::FromStr;
use winnow::error::ContextError;
use winnow::Parser;
use crate::parser::parse;
use crate::{Error, ErrorKind};
const BREAKING_PHRASE: &str = "BREAKING CHANGE";
const BREAKING_ARROW: &str = "BREAKING-CHANGE";
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Commit<'a> {
ty: Type<'a>,
scope: Option<Scope<'a>>,
description: &'a str,
body: Option<&'a str>,
breaking: bool,
#[cfg_attr(feature = "serde", serde(skip))]
breaking_description: Option<&'a str>,
footers: Vec<Footer<'a>>,
}
impl<'a> Commit<'a> {
pub fn parse(string: &'a str) -> Result<Self, Error> {
let (ty, scope, breaking, description, body, footers) = parse::<ContextError>
.parse(string)
.map_err(|err| Error::with_nom(string, err))?;
let breaking_description = footers
.iter()
.filter_map(|(k, _, v)| (k == &BREAKING_PHRASE || k == &BREAKING_ARROW).then_some(*v))
.next()
.or_else(|| breaking.then_some(description));
let breaking = breaking_description.is_some();
let footers: Result<Vec<_>, Error> = footers
.into_iter()
.map(|(k, s, v)| Ok(Footer::new(FooterToken::new_unchecked(k), s.parse()?, v)))
.collect();
let footers = footers?;
Ok(Self {
ty: Type::new_unchecked(ty),
scope: scope.map(Scope::new_unchecked),
description,
body,
breaking,
breaking_description,
footers,
})
}
pub fn type_(&self) -> Type<'a> {
self.ty
}
pub fn scope(&self) -> Option<Scope<'a>> {
self.scope
}
pub fn description(&self) -> &'a str {
self.description
}
pub fn body(&self) -> Option<&'a str> {
self.body
}
pub fn breaking(&self) -> bool {
self.breaking
}
pub fn breaking_description(&self) -> Option<&'a str> {
self.breaking_description
}
pub fn footers(&self) -> &[Footer<'a>] {
&self.footers
}
}
impl fmt::Display for Commit<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.type_().as_str())?;
if let Some(scope) = &self.scope() {
f.write_fmt(format_args!("({})", scope))?;
}
f.write_fmt(format_args!(": {}", &self.description()))?;
if let Some(body) = &self.body() {
f.write_fmt(format_args!("\n\n{}", body))?;
}
for footer in self.footers() {
write!(f, "\n\n{footer}")?;
}
Ok(())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub struct Footer<'a> {
token: FooterToken<'a>,
sep: FooterSeparator,
value: &'a str,
}
impl<'a> Footer<'a> {
pub const fn new(token: FooterToken<'a>, sep: FooterSeparator, value: &'a str) -> Self {
Self { token, sep, value }
}
pub const fn token(&self) -> FooterToken<'a> {
self.token
}
pub const fn separator(&self) -> FooterSeparator {
self.sep
}
pub const fn value(&self) -> &'a str {
self.value
}
pub fn breaking(&self) -> bool {
self.token.breaking()
}
}
impl<'a> fmt::Display for Footer<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self { token, sep, value } = self;
write!(f, "{token}{sep}{value}")
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum FooterSeparator {
Value,
Ref,
}
impl FooterSeparator {
pub fn as_str(self) -> &'static str {
match self {
FooterSeparator::Value => ":",
FooterSeparator::Ref => " #",
}
}
}
impl Deref for FooterSeparator {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
impl PartialEq<&'_ str> for FooterSeparator {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
impl fmt::Display for FooterSeparator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self)
}
}
impl FromStr for FooterSeparator {
type Err = Error;
fn from_str(sep: &str) -> Result<Self, Self::Err> {
match sep {
":" => Ok(FooterSeparator::Value),
" #" => Ok(FooterSeparator::Ref),
_ => {
Err(Error::new(ErrorKind::InvalidFooter)
.set_context(Box::new(format!("{:?}", sep))))
}
}
}
}
macro_rules! unicase_components {
($($ty:ident),+) => (
$(
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct $ty<'a>(unicase::UniCase<&'a str>);
impl<'a> $ty<'a> {
pub const fn new_unchecked(value: &'a str) -> Self {
$ty(unicase::UniCase::unicode(value))
}
pub fn as_str(&self) -> &'a str {
&self.0.into_inner()
}
}
impl Deref for $ty<'_> {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
impl PartialEq<&'_ str> for $ty<'_> {
fn eq(&self, other: &&str) -> bool {
*self == $ty::new_unchecked(*other)
}
}
impl fmt::Display for $ty<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for $ty<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self)
}
}
)+
)
}
unicase_components![Type, Scope, FooterToken];
impl<'a> Type<'a> {
pub fn parse(sep: &'a str) -> Result<Self, Error> {
let t = crate::parser::type_::<ContextError>
.parse(sep)
.map_err(|err| Error::with_nom(sep, err))?;
Ok(Type::new_unchecked(t))
}
}
impl Type<'static> {
pub const FEAT: Type<'static> = Type::new_unchecked("feat");
pub const FIX: Type<'static> = Type::new_unchecked("fix");
pub const REVERT: Type<'static> = Type::new_unchecked("revert");
pub const DOCS: Type<'static> = Type::new_unchecked("docs");
pub const STYLE: Type<'static> = Type::new_unchecked("style");
pub const REFACTOR: Type<'static> = Type::new_unchecked("refactor");
pub const PERF: Type<'static> = Type::new_unchecked("perf");
pub const TEST: Type<'static> = Type::new_unchecked("test");
pub const CHORE: Type<'static> = Type::new_unchecked("chore");
}
impl<'a> Scope<'a> {
pub fn parse(sep: &'a str) -> Result<Self, Error> {
let t = crate::parser::scope::<ContextError>
.parse(sep)
.map_err(|err| Error::with_nom(sep, err))?;
Ok(Scope::new_unchecked(t))
}
}
impl<'a> FooterToken<'a> {
pub fn parse(sep: &'a str) -> Result<Self, Error> {
let t = crate::parser::token::<ContextError>
.parse(sep)
.map_err(|err| Error::with_nom(sep, err))?;
Ok(FooterToken::new_unchecked(t))
}
pub fn breaking(&self) -> bool {
self == &BREAKING_PHRASE || self == &BREAKING_ARROW
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::ErrorKind;
use indoc::indoc;
#[cfg(feature = "serde")]
use serde_test::Token;
#[test]
fn test_valid_simple_commit() {
let commit = Commit::parse("type(my scope): hello world").unwrap();
assert_eq!(commit.type_(), "type");
assert_eq!(commit.scope().unwrap(), "my scope");
assert_eq!(commit.description(), "hello world");
}
#[test]
fn test_trailing_whitespace_without_body() {
let commit = Commit::parse("type(my scope): hello world\n\n\n").unwrap();
assert_eq!(commit.type_(), "type");
assert_eq!(commit.scope().unwrap(), "my scope");
assert_eq!(commit.description(), "hello world");
}
#[test]
fn test_trailing_1_nl() {
let commit = Commit::parse("type: hello world\n").unwrap();
assert_eq!(commit.type_(), "type");
assert_eq!(commit.scope(), None);
assert_eq!(commit.description(), "hello world");
}
#[test]
fn test_trailing_2_nl() {
let commit = Commit::parse("type: hello world\n\n").unwrap();
assert_eq!(commit.type_(), "type");
assert_eq!(commit.scope(), None);
assert_eq!(commit.description(), "hello world");
}
#[test]
fn test_trailing_3_nl() {
let commit = Commit::parse("type: hello world\n\n\n").unwrap();
assert_eq!(commit.type_(), "type");
assert_eq!(commit.scope(), None);
assert_eq!(commit.description(), "hello world");
}
#[test]
fn test_parenthetical_statement() {
let commit = Commit::parse("type: hello world (#1)").unwrap();
assert_eq!(commit.type_(), "type");
assert_eq!(commit.scope(), None);
assert_eq!(commit.description(), "hello world (#1)");
}
#[test]
fn test_multiline_description() {
let err = Commit::parse(
"chore: Automate fastlane when a file in the fastlane directory is\nchanged (hopefully)",
).unwrap_err();
assert_eq!(ErrorKind::InvalidBody, err.kind());
}
#[test]
fn test_issue_12_case_1() {
let commit = Commit::parse("chore: add .hello.txt (#1)\n\n").unwrap();
assert_eq!(commit.type_(), "chore");
assert_eq!(commit.scope(), None);
assert_eq!(commit.description(), "add .hello.txt (#1)");
}
#[test]
fn test_issue_12_case_2() {
let commit = Commit::parse("refactor: use fewer lines (#3)\n\n").unwrap();
assert_eq!(commit.type_(), "refactor");
assert_eq!(commit.scope(), None);
assert_eq!(commit.description(), "use fewer lines (#3)");
}
#[test]
fn test_breaking_change() {
let commit = Commit::parse("feat!: this is a breaking change").unwrap();
assert_eq!(Type::FEAT, commit.type_());
assert!(commit.breaking());
assert_eq!(
commit.breaking_description(),
Some("this is a breaking change")
);
let commit = Commit::parse(indoc!(
"feat: message
BREAKING CHANGE: breaking change"
))
.unwrap();
assert_eq!(Type::FEAT, commit.type_());
assert_eq!("breaking change", commit.footers().get(0).unwrap().value());
assert!(commit.breaking());
assert_eq!(commit.breaking_description(), Some("breaking change"));
let commit = Commit::parse(indoc!(
"fix: message
BREAKING-CHANGE: it's broken"
))
.unwrap();
assert_eq!(Type::FIX, commit.type_());
assert_eq!("it's broken", commit.footers().get(0).unwrap().value());
assert!(commit.breaking());
assert_eq!(commit.breaking_description(), Some("it's broken"));
}
#[test]
fn test_conjoined_footer() {
let commit = Commit::parse(
"fix(example): fix keepachangelog config example
Fixes: #123, #124, #125",
)
.unwrap();
assert_eq!(Type::FIX, commit.type_());
assert_eq!(commit.body(), None);
assert_eq!(
commit.footers(),
[Footer::new(
FooterToken("Fixes".into()),
FooterSeparator::Value,
"#123, #124, #125"
),]
);
}
#[test]
fn test_windows_line_endings() {
let commit =
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")
.unwrap();
assert_eq!(commit.body(), Some("body"));
assert_eq!(
commit.footers(),
[
Footer::new(FooterToken("closes".into()), FooterSeparator::Ref, "1234"),
Footer::new(
FooterToken("BREAKING CHANGE".into()),
FooterSeparator::Value,
"something broke"
),
]
);
assert_eq!(commit.breaking_description(), Some("something broke"));
}
#[test]
fn test_extra_line_endings() {
let commit =
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")
.unwrap();
assert_eq!(commit.body(), Some("body"));
assert_eq!(
commit.footers(),
[
Footer::new(FooterToken("closes".into()), FooterSeparator::Ref, "1234"),
Footer::new(
FooterToken("BREAKING CHANGE".into()),
FooterSeparator::Value,
"something broke"
),
]
);
assert_eq!(commit.breaking_description(), Some("something broke"));
}
#[test]
fn test_fake_footer() {
let commit = indoc! {"
fix: something something
First line of the body
IMPORTANT: Please see something else for details.
Another line here.
"};
let commit = Commit::parse(commit).unwrap();
assert_eq!(Type::FIX, commit.type_());
assert_eq!(None, commit.scope());
assert_eq!("something something", commit.description());
assert_eq!(
Some(indoc!(
"
First line of the body
IMPORTANT: Please see something else for details.
Another line here."
)),
commit.body()
);
let empty_footer: &[Footer<'_>] = &[];
assert_eq!(empty_footer, commit.footers());
}
#[test]
fn test_valid_complex_commit() {
let commit = indoc! {"
chore: improve changelog readability
Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit
easier to parse while reading.
BREAKING CHANGE: Just kidding!
"};
let commit = Commit::parse(commit).unwrap();
assert_eq!(Type::CHORE, commit.type_());
assert_eq!(None, commit.scope());
assert_eq!("improve changelog readability", commit.description());
assert_eq!(
Some(indoc!(
"Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit
easier to parse while reading."
)),
commit.body()
);
assert_eq!("Just kidding!", commit.footers().get(0).unwrap().value());
}
#[test]
fn test_missing_type() {
let err = Commit::parse("").unwrap_err();
assert_eq!(ErrorKind::MissingType, err.kind());
}
#[cfg(feature = "serde")]
#[test]
fn test_commit_serialize() {
let commit = Commit::parse("type(my scope): hello world").unwrap();
serde_test::assert_ser_tokens(
&commit,
&[
Token::Struct {
name: "Commit",
len: 6,
},
Token::Str("ty"),
Token::Str("type"),
Token::Str("scope"),
Token::Some,
Token::Str("my scope"),
Token::Str("description"),
Token::Str("hello world"),
Token::Str("body"),
Token::None,
Token::Str("breaking"),
Token::Bool(false),
Token::Str("footers"),
Token::Seq { len: Some(0) },
Token::SeqEnd,
Token::StructEnd,
],
);
}
}