#![deny(
clippy::all,
clippy::cargo,
clippy::clone_on_ref_ptr,
clippy::dbg_macro,
clippy::indexing_slicing,
clippy::mem_forget,
clippy::multiple_inherent_impl,
clippy::nursery,
clippy::option_unwrap_used,
clippy::pedantic,
clippy::print_stdout,
clippy::result_unwrap_used,
clippy::unimplemented,
clippy::use_debug,
clippy::wildcard_enum_match_arm,
clippy::wrong_pub_self_convention,
deprecated_in_future,
future_incompatible,
missing_copy_implementations,
missing_debug_implementations,
missing_docs,
nonstandard_style,
rust_2018_idioms,
rustdoc,
trivial_casts,
trivial_numeric_casts,
unreachable_pub,
unsafe_code,
unused_import_braces,
unused_lifetimes,
unused_qualifications,
unused_results,
variant_size_differences,
warnings
)]
#![doc(html_root_url = "https://docs.rs/conventional-commit")]
use itertools::Itertools;
use std::fmt;
use std::str::FromStr;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct ConventionalCommit {
ty: String,
scope: Option<String>,
description: String,
body: Option<String>,
breaking_change: Option<String>,
}
impl ConventionalCommit {
pub fn type_(&self) -> &str {
self.ty.trim()
}
pub fn scope(&self) -> Option<&str> {
self.scope.as_ref().map(String::as_str).map(str::trim)
}
pub fn description(&self) -> &str {
self.description.trim()
}
pub fn body(&self) -> Option<&str> {
self.body.as_ref().map(String::as_str).map(str::trim)
}
pub fn breaking_change(&self) -> Option<&str> {
self.breaking_change
.as_ref()
.map(String::as_str)
.map(str::trim)
}
}
impl fmt::Display for ConventionalCommit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.type_())?;
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))?;
}
if let Some(breaking_change) = &self.breaking_change() {
f.write_fmt(format_args!("\n\nBREAKING CHANGE: {}", breaking_change))?;
}
Ok(())
}
}
impl FromStr for ConventionalCommit {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
use Error::*;
let mut chars = UnicodeSegmentation::graphemes(s, true).peekable();
let ty: String = chars
.peeking_take_while(|&c| c != "(" && c != ":")
.collect();
if ty.is_empty() {
return Err(MissingType);
}
let mut scope: Option<String> = None;
if chars.peek() == Some(&"(") {
let _ = scope.replace(chars.peeking_take_while(|&c| c != ")").skip(1).collect());
chars = chars.dropping(1);
}
if chars.by_ref().take(2).collect::<String>() != ": " {
return Err(InvalidFormat);
}
let description: String = chars.peeking_take_while(|&c| c != "\n").collect();
if description.is_empty() {
return Err(MissingDescription);
}
let other: String = chars.collect::<String>().trim().to_owned();
let (body, breaking_change) = if other.is_empty() {
(None, None)
} else {
let mut data = other
.splitn(2, "BREAKING CHANGE:")
.map(|s| s.trim().to_owned());
(data.next(), data.next())
};
Ok(Self {
ty,
scope,
description,
body,
breaking_change,
})
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Error {
MissingType,
InvalidScope,
MissingDescription,
InvalidBody,
InvalidFormat,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use Error::*;
match self {
MissingType => f.write_str("missing type definition"),
InvalidScope => f.write_str("invalid scope format"),
MissingDescription => f.write_str("missing commit description"),
InvalidBody => f.write_str("invalid body format"),
InvalidFormat => f.write_str("invalid commit format"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None
}
}
#[cfg(test)]
#[allow(clippy::result_unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_valid_simple_commit() {
let commit = ConventionalCommit::from_str("my type(my scope): hello world").unwrap();
assert_eq!("my type", commit.type_());
assert_eq!(Some("my scope"), commit.scope());
assert_eq!("hello world", commit.description());
}
#[test]
fn test_valid_complex_commit() {
let commit = "chore: improve changelog readability\n
\n
Change date notation from YYYY-MM-DD to YYYY.MM.DD to make it a tiny bit \
easier to parse while reading.\n
\n
BREAKING CHANGE: Just kidding!";
let commit = ConventionalCommit::from_str(commit).unwrap();
assert_eq!("chore", commit.type_());
assert_eq!(None, commit.scope());
assert_eq!("improve changelog readability", commit.description());
assert_eq!(
Some(
"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!(Some("Just kidding!"), commit.breaking_change());
}
#[test]
fn test_missing_type() {
let err = ConventionalCommit::from_str("").unwrap_err();
assert_eq!(Error::MissingType, err);
}
}