use crate::error::Result;
use governor_core::domain::changelog::{
Changelog, ChangelogEntry, ChangelogSection, ChangelogSectionConfig,
};
use governor_core::domain::version::SemanticVersion;
use std::fs;
use std::path::Path;
fn default_sections() -> Vec<ChangelogSectionConfig> {
vec![
ChangelogSectionConfig {
section: ChangelogSection::Breaking,
title: "Breaking Changes".to_string(),
order: 0,
commit_types: vec!["feat!".to_string()],
},
ChangelogSectionConfig {
section: ChangelogSection::Added,
title: "Added".to_string(),
order: 1,
commit_types: vec!["feat".to_string()],
},
ChangelogSectionConfig {
section: ChangelogSection::Fixed,
title: "Fixed".to_string(),
order: 2,
commit_types: vec!["fix".to_string(), "perf".to_string()],
},
ChangelogSectionConfig {
section: ChangelogSection::Changed,
title: "Changed".to_string(),
order: 3,
commit_types: vec!["refactor".to_string(), "revert".to_string()],
},
]
}
fn excluded_types() -> Vec<&'static str> {
vec!["docs", "test", "chore", "style", "ci", "build", "release"]
}
#[allow(clippy::manual_let_else)]
#[allow(clippy::option_if_let_else)]
fn generate_changelog_from_commits(
workspace_path: &str,
version: &SemanticVersion,
) -> Result<Changelog> {
use std::process::Command;
let tag_output = Command::new("git")
.args(["tag", "-l", "--merged", "HEAD"])
.current_dir(workspace_path)
.output()
.map_err(|e| crate::error::Error::Io(format!("Failed to get git tags: {e}")))?;
let last_tag = String::from_utf8_lossy(&tag_output.stdout)
.lines()
.rfind(|line| line.starts_with('v'))
.map(std::string::ToString::to_string);
let mut git_args_vec = vec![
"log".to_string(),
"--pretty=format:%H|%s|%an <%ae>".to_string(),
"--date=short".to_string(),
];
if let Some(tag) = &last_tag {
git_args_vec.push(format!("{tag}..HEAD"));
}
let git_args: Vec<&str> = git_args_vec
.iter()
.map(std::string::String::as_str)
.collect();
let log_output = Command::new("git")
.args(&git_args)
.current_dir(workspace_path)
.output()
.map_err(|e| crate::error::Error::Io(format!("Failed to get git log: {e}")))?;
let mut changelog = Changelog::new(version.clone());
for line in String::from_utf8_lossy(&log_output.stdout).lines() {
let parts: Vec<&str> = line.splitn(3, '|').collect();
if parts.len() < 3 {
continue;
}
let hash = parts[0].to_string();
let message = parts[1].to_string();
let parsed = parse_conventional_commit(&message);
let (commit_type, scope, breaking, short_message) = match parsed {
Some(p) => p,
None => continue,
};
if excluded_types().contains(&commit_type.as_str()) {
continue;
}
let section = ChangelogSection::from_commit_type(&commit_type, breaking);
let section = match section {
Some(s) => s,
None => continue,
};
let mut entry = ChangelogEntry::new(section, short_message);
entry.scope = scope;
entry.commit_hash = Some(hash.chars().take(7).collect());
changelog.add_entry(entry);
}
Ok(changelog)
}
#[allow(clippy::or_fun_call)]
fn parse_conventional_commit(message: &str) -> Option<(String, Option<String>, bool, String)> {
let message = message.trim();
let breaking = message.contains('!') || message.contains("BREAKING CHANGE:");
let colon_pos = message.find(':')?;
let prefix = &message[..colon_pos];
let rest = &message[colon_pos + 1..];
let parts: Vec<&str> = prefix.split(['(', ')', '!']).collect();
let commit_type = if breaking && prefix.ends_with('!') {
prefix[..prefix.len() - 1].to_string()
} else {
parts.first()?.to_string()
};
let scope = if parts.len() > 2 && prefix.contains('(') {
Some(parts[1].to_string())
} else {
None
};
let short_message = rest
.trim()
.strip_prefix("** ")
.unwrap_or(rest.trim())
.to_string();
Some((commit_type, scope, breaking, short_message))
}
#[allow(clippy::format_push_string)]
fn format_changelog_entries(changelog: &Changelog) -> String {
let sections = default_sections();
let mut output = String::new();
for section_config in §ions {
let entries: Vec<_> = changelog
.entries
.iter()
.filter(|e| e.section == section_config.section)
.collect();
if entries.is_empty() {
continue;
}
output.push_str(&format!("\n### {}\n\n", section_config.title));
for entry in entries {
output.push_str("- ");
if let Some(scope) = &entry.scope {
output.push_str(&format!("**{scope}**: "));
}
output.push_str(&entry.message);
if let Some(hash) = &entry.commit_hash {
output.push_str(&format!(" ({hash})"));
}
output.push('\n');
}
}
output
}
pub fn update_changelog(
path: &Path,
version: &str,
workspace_path: &str,
dry_run: bool,
) -> Result<bool> {
let version = SemanticVersion::parse(version)
.map_err(|e| crate::error::Error::Version(format!("Failed to parse version: {e}")))?;
let changelog = generate_changelog_from_commits(workspace_path, &version)?;
if changelog.entries.is_empty() {
}
let current_content = if path.exists() {
fs::read_to_string(path)
.map_err(|e| crate::error::Error::Io(format!("Failed to read CHANGELOG.md: {e}")))?
} else {
String::new()
};
let date = chrono::Utc::now().format("%Y-%m-%d");
let formatted_entries = format_changelog_entries(&changelog);
let new_entry = format!(
"## [{version}] - {date}{}\n",
if formatted_entries.is_empty() {
""
} else {
&formatted_entries
}
);
let updated = if current_content.contains("## [Unreleased]") {
current_content.replace("## [Unreleased]", new_entry.trim())
} else if current_content.is_empty() {
format!(
"# 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/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n{}\n",
new_entry.trim()
)
} else {
#[allow(clippy::option_if_let_else)]
let updated = if let Some(pos) = current_content.find("\n## [") {
format!(
"{}\n{}\n{}",
¤t_content[..pos],
new_entry.trim(),
¤t_content[pos..]
)
} else {
format!("{}{}\n", current_content.trim_end(), new_entry.trim())
};
updated
};
if !dry_run {
fs::write(path, updated)
.map_err(|e| crate::error::Error::Io(format!("Failed to write CHANGELOG.md: {e}")))?;
}
Ok(true)
}
#[allow(dead_code)]
pub fn update_changelog_legacy(path: &Path, version: &str, dry_run: bool) -> Result<bool> {
let current_content = if path.exists() {
fs::read_to_string(path)
.map_err(|e| crate::error::Error::Io(format!("Failed to read CHANGELOG.md: {e}")))?
} else {
String::new()
};
let date = chrono::Utc::now().format("%Y-%m-%d");
let new_entry = format!("## [{version}] - {date}\n\n");
let updated = if current_content.contains("## [Unreleased]") {
current_content.replace(
"## [Unreleased]",
&format!("## [Unreleased]\n\n{}", new_entry.trim()),
)
} else if current_content.is_empty() {
format!("# Changelog\n\n## [Unreleased]\n\n{}\n", new_entry.trim())
} else {
format!("{}{}\n", current_content.trim_end(), new_entry)
};
if !dry_run {
fs::write(path, updated)
.map_err(|e| crate::error::Error::Io(format!("Failed to write CHANGELOG.md: {e}")))?;
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_conventional_commit() {
let msg = "feat(api): add new endpoint";
let (t, s, b, m) = parse_conventional_commit(msg).unwrap();
assert_eq!(t, "feat");
assert_eq!(s, Some("api".to_string()));
assert!(!b);
assert_eq!(m, "add new endpoint");
let msg = "fix!: critical bug fix";
let (t, s, b, m) = parse_conventional_commit(msg).unwrap();
assert_eq!(t, "fix");
assert!(s.is_none());
assert!(b);
assert_eq!(m, "critical bug fix");
}
}