use regex::Regex;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::error::{ChangelogError, ChangelogResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConventionalCommit {
pub(crate) commit_type: String,
pub(crate) scope: Option<String>,
pub(crate) breaking: bool,
pub(crate) description: String,
pub(crate) body: Option<String>,
pub(crate) footers: Vec<CommitFooter>,
}
impl ConventionalCommit {
pub fn parse(message: &str) -> ChangelogResult<Self> {
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() || lines[0].trim().is_empty() {
return Err(ChangelogError::ConventionalCommitParseError {
commit: message.to_string(),
reason: "Empty commit message".to_string(),
});
}
let first_line = lines[0];
let (commit_type, scope, breaking_indicator, description) =
Self::parse_subject(first_line, message)?;
let (body, footers) = Self::parse_body_and_footers(&lines[1..]);
let breaking = breaking_indicator
|| footers.iter().any(|f| f.key == "BREAKING CHANGE" || f.key == "BREAKING-CHANGE");
Ok(Self { commit_type, scope, breaking, description, body, footers })
}
pub fn commit_type(&self) -> &str {
&self.commit_type
}
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
pub fn is_breaking(&self) -> bool {
self.breaking
}
pub fn description(&self) -> &str {
&self.description
}
pub fn body(&self) -> Option<&str> {
self.body.as_deref()
}
pub fn footers(&self) -> &[CommitFooter] {
&self.footers
}
pub fn section_type(&self) -> SectionType {
if self.breaking {
return SectionType::Breaking;
}
match self.commit_type.as_str() {
"feat" => SectionType::Features,
"fix" => SectionType::Fixes,
"perf" => SectionType::Performance,
"docs" => SectionType::Documentation,
"refactor" => SectionType::Refactoring,
"build" => SectionType::Build,
"ci" => SectionType::CI,
"test" => SectionType::Tests,
_ => SectionType::Other,
}
}
pub fn extract_references(&self) -> ChangelogResult<Vec<String>> {
let mut refs = Vec::new();
refs.extend(Self::find_refs_in_text(&self.description)?);
if let Some(body) = &self.body {
refs.extend(Self::find_refs_in_text(body)?);
}
for footer in &self.footers {
let key_lower = footer.key.to_lowercase();
if key_lower.contains("ref")
|| key_lower.contains("close")
|| key_lower.contains("fix")
|| key_lower.contains("resolve")
{
refs.extend(Self::find_refs_in_text(&footer.value)?);
}
}
let mut seen = std::collections::HashSet::new();
Ok(refs.into_iter().filter(|r| seen.insert(r.clone())).collect())
}
fn parse_subject(
line: &str,
full_message: &str,
) -> ChangelogResult<(String, Option<String>, bool, String)> {
let re = Regex::new(r"^(\w+)(\(([^)]+)\))?(!)?:\s*(.+)$").map_err(|e| {
ChangelogError::ConventionalCommitParseError {
commit: full_message.to_string(),
reason: format!("Regex compilation failed: {}", e),
}
})?;
let caps = re.captures(line).ok_or_else(|| {
ChangelogError::ConventionalCommitParseError {
commit: full_message.to_string(),
reason: format!(
"Subject line '{}' does not match conventional format: <type>[scope]: <description>",
line
),
}
})?;
let commit_type = caps.get(1).map(|m| m.as_str().to_string()).ok_or_else(|| {
ChangelogError::ConventionalCommitParseError {
commit: full_message.to_string(),
reason: "Failed to extract commit type".to_string(),
}
})?;
let scope = caps.get(3).map(|m| m.as_str().to_string());
let breaking = caps.get(4).is_some();
let description = caps.get(5).map(|m| m.as_str().trim().to_string()).ok_or_else(|| {
ChangelogError::ConventionalCommitParseError {
commit: full_message.to_string(),
reason: "Failed to extract description".to_string(),
}
})?;
Ok((commit_type, scope, breaking, description))
}
fn parse_body_and_footers(lines: &[&str]) -> (Option<String>, Vec<CommitFooter>) {
let lines: Vec<&str> = lines.iter().skip_while(|l| l.trim().is_empty()).copied().collect();
if lines.is_empty() {
return (None, vec![]);
}
let footer_start =
lines.iter().position(|l| Self::is_footer_line(l)).unwrap_or(lines.len());
let body = if footer_start > 0 {
let body_text = lines[..footer_start].join("\n").trim().to_string();
if body_text.is_empty() { None } else { Some(body_text) }
} else {
None
};
let footers = if footer_start < lines.len() {
Self::parse_footers(&lines[footer_start..])
} else {
vec![]
};
(body, footers)
}
fn is_footer_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
if let Some(colon_pos) = trimmed.find(':') {
let key = trimmed[..colon_pos].trim();
if key.is_empty() {
return false;
}
if key == "BREAKING CHANGE" || key == "BREAKING-CHANGE" {
return true;
}
!key.contains(' ') && key.chars().any(|c| c.is_alphabetic())
} else {
false
}
}
fn parse_footers(lines: &[&str]) -> Vec<CommitFooter> {
let mut footers = Vec::new();
let mut current: Option<CommitFooter> = None;
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() && current.is_none() {
continue;
}
if let Some(colon_pos) = trimmed.find(':') {
let key = trimmed[..colon_pos].trim();
let is_valid_footer_key = if key == "BREAKING CHANGE" || key == "BREAKING-CHANGE" {
true
} else {
!key.is_empty() && !key.contains(' ') && key.chars().any(|c| c.is_alphabetic())
};
if is_valid_footer_key {
if let Some(footer) = current.take() {
footers.push(footer);
}
let value = trimmed[colon_pos + 1..].trim().to_string();
current = Some(CommitFooter { key: key.to_string(), value });
continue;
}
}
if let Some(ref mut footer) = current
&& !trimmed.is_empty()
{
if !footer.value.is_empty() {
footer.value.push(' ');
}
footer.value.push_str(trimmed);
}
}
if let Some(footer) = current {
footers.push(footer);
}
footers
}
fn find_refs_in_text(text: &str) -> ChangelogResult<Vec<String>> {
let re =
Regex::new(r"#(\d+)").map_err(|e| ChangelogError::ConventionalCommitParseError {
commit: text.to_string(),
reason: format!("Failed to compile reference regex: {}", e),
})?;
Ok(re
.captures_iter(text)
.filter_map(|cap| cap.get(0).map(|m| m.as_str().to_string()))
.collect())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommitFooter {
pub(crate) key: String,
pub(crate) value: String,
}
impl CommitFooter {
pub fn key(&self) -> &str {
&self.key
}
pub fn value(&self) -> &str {
&self.value
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SectionType {
Breaking,
Features,
Fixes,
Performance,
Deprecations,
Documentation,
Refactoring,
Build,
CI,
Tests,
Other,
}
impl SectionType {
pub fn title(&self) -> &str {
match self {
SectionType::Breaking => "Breaking Changes",
SectionType::Features => "Features",
SectionType::Fixes => "Bug Fixes",
SectionType::Performance => "Performance Improvements",
SectionType::Deprecations => "Deprecations",
SectionType::Documentation => "Documentation",
SectionType::Refactoring => "Code Refactoring",
SectionType::Build => "Build System",
SectionType::CI => "Continuous Integration",
SectionType::Tests => "Tests",
SectionType::Other => "Other Changes",
}
}
pub fn priority(&self) -> u8 {
match self {
SectionType::Breaking => 0,
SectionType::Features => 1,
SectionType::Fixes => 2,
SectionType::Performance => 3,
SectionType::Deprecations => 4,
SectionType::Documentation => 5,
SectionType::Refactoring => 6,
SectionType::Build => 7,
SectionType::CI => 8,
SectionType::Tests => 9,
SectionType::Other => 10,
}
}
}
impl fmt::Display for SectionType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.title())
}
}
impl PartialOrd for SectionType {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SectionType {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.priority().cmp(&other.priority())
}
}