use std::collections::BTreeMap;
use chrono::Local;
use regex::Regex;
use crate::git::Commit;
#[derive(Debug, Clone)]
pub struct ConventionalCommit {
pub commit_type: String,
pub scope: Option<String>,
pub description: String,
pub breaking: bool,
}
pub fn parse_conventional_commit(message: &str) -> Option<ConventionalCommit> {
if message.starts_with("Merge ") {
return None;
}
let re = Regex::new(r"^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$").expect("valid regex");
let caps = re.captures(message)?;
Some(ConventionalCommit {
commit_type: caps[1].to_string(),
scope: caps.get(2).map(|m| m.as_str().to_string()),
breaking: caps.get(3).is_some(),
description: caps[4].to_string(),
})
}
fn type_to_section(commit_type: &str) -> Option<&'static str> {
match commit_type {
"feat" => Some("Added"),
"fix" => Some("Fixed"),
"perf" => Some("Performance"),
"change" => Some("Changed"),
_ => None,
}
}
pub fn generate_changelog(
commits: &[Commit],
version: &str,
previous_tag: Option<&str>,
remote_url: Option<&str>,
) -> String {
let mut breaking: Vec<String> = Vec::new();
let mut sections: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for commit in commits {
let Some(cc) = parse_conventional_commit(&commit.message) else {
continue;
};
let entry = format_entry(&cc, commit, remote_url);
if cc.breaking {
breaking.push(entry.clone());
}
if let Some(section) = type_to_section(&cc.commit_type) {
sections.entry(section).or_default().push(entry);
}
}
let date = Local::now().format("%Y-%m-%d");
let mut output = String::new();
match (remote_url, previous_tag) {
(Some(url), Some(prev)) => {
let prev_tag = if prev.starts_with('v') {
prev.to_string()
} else {
format!("v{prev}")
};
output.push_str(&format!(
"## [{version}]({url}/compare/{prev_tag}...v{version}) - {date}\n",
));
}
_ => {
output.push_str(&format!("## [{version}] - {date}\n"));
}
}
let section_order = [
"Breaking Changes",
"Added",
"Changed",
"Fixed",
"Performance",
];
if !breaking.is_empty() {
output.push_str("\n### Breaking Changes\n\n");
for entry in &breaking {
output.push_str(&format!("- {entry}\n"));
}
}
for section_name in §ion_order {
if *section_name == "Breaking Changes" {
continue; }
if let Some(entries) = sections.get(section_name) {
output.push_str(&format!("\n### {section_name}\n\n"));
for entry in entries {
output.push_str(&format!("- {entry}\n"));
}
}
}
output
}
fn format_entry(cc: &ConventionalCommit, commit: &Commit, remote_url: Option<&str>) -> String {
let scope_prefix = cc
.scope
.as_ref()
.map(|s| format!("**{s}**: "))
.unwrap_or_default();
let hash_suffix = match remote_url {
Some(url) => {
let short_hash = &commit.hash[..7.min(commit.hash.len())];
format!(" ([{short_hash}]({url}/commit/{}))", commit.hash)
}
None => String::new(),
};
format!("{scope_prefix}{}{hash_suffix}", cc.description)
}
pub fn generate_changelog_with_mode(
commits: &[Commit],
version: &str,
previous_tag: Option<&str>,
remote_url: Option<&str>,
unconventional_mode: &str,
) -> std::result::Result<String, String> {
if unconventional_mode == "strict" {
for commit in commits {
if commit.message.starts_with("Merge ") {
continue;
}
if parse_conventional_commit(&commit.message).is_none() {
return Err(format!(
"non-conventional commit found (strict mode): {} {}",
&commit.hash[..7.min(commit.hash.len())],
commit.message
));
}
}
}
let mut output = generate_changelog(commits, version, previous_tag, remote_url);
if unconventional_mode == "include" {
let unconventional: Vec<&Commit> = commits
.iter()
.filter(|c| !c.message.starts_with("Merge "))
.filter(|c| parse_conventional_commit(&c.message).is_none())
.collect();
if !unconventional.is_empty() {
output.push_str("\n### Other\n\n");
for commit in unconventional {
let short_hash = &commit.hash[..7.min(commit.hash.len())];
match remote_url {
Some(url) => output.push_str(&format!(
"- {} ([{short_hash}]({url}/commit/{}))\n",
commit.message, commit.hash
)),
None => output.push_str(&format!("- {}\n", commit.message)),
}
}
}
}
Ok(output)
}
pub fn prepend_to_changelog(existing: Option<&str>, new_section: &str) -> String {
let header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/).\n";
match existing {
Some(content) => {
if let Some(pos) = content.find("\n## ") {
let (before, after) = content.split_at(pos + 1);
format!("{before}\n{new_section}\n{after}")
} else {
format!("{content}\n{new_section}")
}
}
None => {
format!("{header}\n{new_section}")
}
}
}
pub fn version_exists_in_changelog(content: &str, version: &str) -> bool {
content.contains(&format!("## [{version}]"))
}