use super::{Bump, ParseCommitError};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
kind: String,
scope: Option<String>,
breaking: bool,
description: String,
body: Option<String>,
footers: Vec<(String, String)>,
}
impl Commit {
pub fn parse(message: &str) -> Result<Self, ParseCommitError> {
let message = message.trim_end();
let mut lines = message.lines();
let header = lines.next().unwrap_or("");
if header.trim().is_empty() {
return Err(ParseCommitError::Empty);
}
let rest: Vec<&str> = lines.collect();
let header = parse_header(header)?;
if rest.first().is_some_and(|line| !line.trim().is_empty()) {
return Err(ParseCommitError::MissingBlankLine);
}
let content: Vec<&str> = rest
.iter()
.skip_while(|line| line.trim().is_empty())
.copied()
.collect();
let footer_start = (0..content.len())
.find(|&i| {
(i == 0 || content[i - 1].trim().is_empty()) && split_footer(content[i]).is_some()
})
.unwrap_or(content.len());
let body = content[..footer_start].join("\n").trim_end().to_string();
let footers = parse_footers(&content[footer_start..]);
let breaking = header.breaking || footers.iter().any(|(token, _)| is_breaking_token(token));
Ok(Self {
kind: header.kind,
scope: header.scope,
breaking,
description: header.description,
body: if body.is_empty() { None } else { Some(body) },
footers,
})
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
#[must_use]
pub fn is_breaking(&self) -> bool {
self.breaking
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub fn body(&self) -> Option<&str> {
self.body.as_deref()
}
#[must_use]
pub fn footers(&self) -> &[(String, String)] {
&self.footers
}
#[must_use]
pub fn footer(&self, token: &str) -> Option<&str> {
self.footers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(token))
.map(|(_, value)| value.as_str())
}
#[must_use]
pub fn bump(&self) -> Bump {
if self.breaking {
Bump::Major
} else if self.kind.eq_ignore_ascii_case("feat") {
Bump::Minor
} else if self.kind.eq_ignore_ascii_case("fix") {
Bump::Patch
} else {
Bump::None
}
}
}
impl fmt::Display for Commit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut out = self.kind.clone();
if let Some(scope) = &self.scope {
out.push('(');
out.push_str(scope);
out.push(')');
}
let has_bang_footer = self
.footers
.iter()
.any(|(token, _)| is_breaking_token(token));
if self.breaking && !has_bang_footer {
out.push('!');
}
out.push_str(": ");
out.push_str(&self.description);
if let Some(body) = &self.body {
out.push_str("\n\n");
out.push_str(body);
}
if !self.footers.is_empty() {
out.push('\n');
}
for (token, value) in &self.footers {
out.push('\n');
out.push_str(token);
out.push_str(": ");
out.push_str(value);
}
f.write_str(&out)
}
}
impl FromStr for Commit {
type Err = ParseCommitError;
fn from_str(message: &str) -> Result<Self, Self::Err> {
Self::parse(message)
}
}
struct Header {
kind: String,
scope: Option<String>,
breaking: bool,
description: String,
}
fn parse_header(header: &str) -> Result<Header, ParseCommitError> {
let colon = header.find(':').ok_or(ParseCommitError::MissingSeparator)?;
let (prefix, after) = (&header[..colon], &header[colon + 1..]);
let description = match after.strip_prefix(' ') {
Some(description) => description.trim(),
None if after.is_empty() => "",
None => return Err(ParseCommitError::MissingSeparator),
};
if description.is_empty() {
return Err(ParseCommitError::EmptyDescription);
}
let (prefix, breaking) = match prefix.strip_suffix('!') {
Some(prefix) => (prefix, true),
None => (prefix, false),
};
let (kind, scope) = match prefix.split_once('(') {
Some((kind, rest)) => {
let scope = rest
.strip_suffix(')')
.ok_or(ParseCommitError::InvalidScope)?;
if scope.is_empty() || scope.contains(['(', ')']) {
return Err(ParseCommitError::InvalidScope);
}
(kind, Some(scope.to_string()))
}
None => (prefix, None),
};
if kind.is_empty()
|| !kind
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(ParseCommitError::InvalidType);
}
Ok(Header {
kind: kind.to_string(),
scope,
breaking,
description: description.to_string(),
})
}
fn split_footer(line: &str) -> Option<(&str, &str)> {
for token in ["BREAKING CHANGE", "BREAKING-CHANGE"] {
if let Some(value) = line
.strip_prefix(token)
.and_then(|rest| rest.strip_prefix(": "))
{
return Some((token, value));
}
}
let end = line.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))?;
let (token, rest) = line.split_at(end);
if token.is_empty() {
return None;
}
rest.strip_prefix(": ")
.or_else(|| rest.strip_prefix(" #"))
.map(|value| (token, value))
}
fn parse_footers(lines: &[&str]) -> Vec<(String, String)> {
let starts: Vec<usize> = (0..lines.len())
.filter(|&i| split_footer(lines[i]).is_some())
.collect();
starts
.iter()
.enumerate()
.filter_map(|(n, &start)| {
let end = starts.get(n + 1).copied().unwrap_or(lines.len());
split_footer(lines[start]).map(|(token, first)| {
let mut value = first.to_string();
for line in &lines[start + 1..end] {
value.push('\n');
value.push_str(line);
}
(token.to_string(), value.trim_end().to_string())
})
})
.collect()
}
fn is_breaking_token(token: &str) -> bool {
token == "BREAKING CHANGE" || token == "BREAKING-CHANGE"
}
#[cfg(test)]
#[path = "conventional_commit.test.rs"]
mod tests;
#[cfg(test)]
#[path = "conventional_commit.spec.rs"]
mod spec;