use anyhow::bail;
use crate::model::changeset::ChangeType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConventionalCommit {
pub commit_type: String,
pub scope: Option<String>,
pub breaking: bool,
pub description: String,
pub body: Option<String>,
}
impl ConventionalCommit {
pub fn change_type(&self) -> Option<ChangeType> {
if self.breaking {
return Some(ChangeType::Major);
}
match self.commit_type.as_str() {
"feat" => Some(ChangeType::Minor),
"fix" => Some(ChangeType::Patch),
_ => None,
}
}
}
fn parse_header(header: &str) -> anyhow::Result<(String, Option<String>, bool, String)> {
let mut iter = header.char_indices().peekable();
let mut commit_type = String::new();
loop {
match iter.peek() {
Some((_, '(')) | Some((_, '!')) | Some((_, ':')) => break,
Some((_, c)) if c.is_alphanumeric() || *c == '-' => {
commit_type.push(*c);
iter.next();
}
Some((_, c)) => bail!("Unexpected character '{c}' in commit type in: {header}"),
None => bail!("Unexpected end of header while parsing type in: {header}"),
}
}
if commit_type.is_empty() {
bail!("Missing commit type in: {header}");
}
let scope = if iter.peek().map(|(_, c)| *c) == Some('(') {
iter.next();
let mut scope_str = String::new();
loop {
match iter.next() {
Some((_, ')')) => break,
Some((_, c)) => scope_str.push(c),
None => bail!("Unclosed scope parenthesis in: {header}"),
}
}
Some(scope_str)
} else {
None
};
let breaking_bang = if iter.peek().map(|(_, c)| *c) == Some('!') {
iter.next();
true
} else {
false
};
let remaining: String = iter.map(|(_, c)| c).collect();
let description = remaining
.strip_prefix(": ")
.ok_or_else(|| anyhow::anyhow!("Missing ': ' separator in: {header}"))?
.trim()
.to_string();
if description.is_empty() {
bail!("Missing description in: {header}");
}
Ok((commit_type, scope, breaking_bang, description))
}
fn is_trailer_line(line: &str) -> bool {
let line = line.trim_end();
if line.is_empty() {
return false;
}
if line.starts_with("BREAKING CHANGE: ") || line.starts_with("BREAKING-CHANGE: ") {
return true;
}
let token_end = line
.bytes()
.position(|b| !b.is_ascii_alphanumeric() && b != b'-')
.unwrap_or(line.len());
if token_end == 0 {
return false;
}
let after_token = &line[token_end..];
after_token.starts_with(": ") || after_token == ":" || after_token.starts_with(" #")
}
pub(crate) fn strip_trailers(rest: &str) -> Option<String> {
let lines: Vec<&str> = rest.lines().collect();
let mut end = lines.len();
while end > 0 && lines[end - 1].trim().is_empty() {
end -= 1;
}
if end == 0 {
return None;
}
let mut trailer_start = end;
while trailer_start > 0 {
let line = lines[trailer_start - 1];
if line.trim().is_empty() || !is_trailer_line(line) {
break;
}
trailer_start -= 1;
}
if trailer_start == end {
let s = lines[..end].join("\n").trim().to_string();
return if s.is_empty() { None } else { Some(s) };
}
if trailer_start == 0 {
return None;
}
let prose_end = lines[..trailer_start]
.iter()
.rposition(|l| !l.trim().is_empty())
.map(|i| i + 1)
.unwrap_or(0);
if prose_end == 0 {
return None;
}
let prose = lines[..prose_end].join("\n").trim().to_string();
if prose.is_empty() { None } else { Some(prose) }
}
pub fn parse(message: &str) -> anyhow::Result<ConventionalCommit> {
let (header, rest) = match message.split_once("\n\n") {
Some((h, r)) => (h, Some(r)),
None => (message, None),
};
let (commit_type, scope, breaking_bang, description) = parse_header(header)?;
let breaking_footer = rest.is_some_and(|r| {
r.lines().any(|line| {
line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:")
})
});
let body = rest.and_then(strip_trailers);
Ok(ConventionalCommit {
commit_type,
scope,
breaking: breaking_bang || breaking_footer,
description,
body,
})
}