- fix(get_tags): 使用 find_object+peel_to_commit 支持 annotated tag - fix(changelog): insert_changelog_entry 替换覆盖逻辑,保留已有章节 - feat: parse_changelog_versions 按 semver 降序提取版本 - feat: sort_tags_by_semver 取代纯时间排序 - feat(tag --auto): 优先读 Cargo.toml/pyproject.toml,回退 commit 分析 - feat(changelog): 无 --from 时自动检测 changelog 最高已有版本 - refactor: TagInfo::version_name(), GitRepo::find_tag_by_version()
613 lines
19 KiB
Rust
613 lines
19 KiB
Rust
use super::{CommitInfo, GitRepo};
|
|
use anyhow::{Context, Result};
|
|
use chrono::{DateTime, Utc};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
pub const CHANGELOG_HEADER: &str = r#"# Changelog
|
|
|
|
All notable changes to this project will be documented in this file.
|
|
|
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
|
"#;
|
|
|
|
/// Changelog generator
|
|
pub struct ChangelogGenerator {
|
|
format: ChangelogFormat,
|
|
include_hashes: bool,
|
|
include_authors: bool,
|
|
group_by_type: bool,
|
|
custom_categories: Vec<ChangelogCategory>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ChangelogFormat {
|
|
KeepAChangelog,
|
|
GitHubReleases,
|
|
Custom,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChangelogCategory {
|
|
pub title: String,
|
|
pub types: Vec<String>,
|
|
}
|
|
|
|
impl ChangelogGenerator {
|
|
/// Create new changelog generator
|
|
pub fn new() -> Self {
|
|
Self {
|
|
format: ChangelogFormat::KeepAChangelog,
|
|
include_hashes: false,
|
|
include_authors: false,
|
|
group_by_type: true,
|
|
custom_categories: vec![],
|
|
}
|
|
}
|
|
|
|
/// Set format
|
|
pub fn format(mut self, format: ChangelogFormat) -> Self {
|
|
self.format = format;
|
|
self
|
|
}
|
|
|
|
/// Include commit hashes
|
|
pub fn include_hashes(mut self, include: bool) -> Self {
|
|
self.include_hashes = include;
|
|
self
|
|
}
|
|
|
|
/// Include authors
|
|
pub fn include_authors(mut self, include: bool) -> Self {
|
|
self.include_authors = include;
|
|
self
|
|
}
|
|
|
|
/// Group by type
|
|
pub fn group_by_type(mut self, group: bool) -> Self {
|
|
self.group_by_type = group;
|
|
self
|
|
}
|
|
|
|
/// Add custom category
|
|
pub fn add_category(mut self, title: impl Into<String>, types: Vec<String>) -> Self {
|
|
self.custom_categories.push(ChangelogCategory {
|
|
title: title.into(),
|
|
types,
|
|
});
|
|
self
|
|
}
|
|
|
|
/// Generate changelog for version
|
|
pub fn generate(
|
|
&self,
|
|
version: &str,
|
|
date: DateTime<Utc>,
|
|
commits: &[CommitInfo],
|
|
) -> Result<String> {
|
|
match self.format {
|
|
ChangelogFormat::KeepAChangelog => {
|
|
self.generate_keep_a_changelog(version, date, commits)
|
|
}
|
|
ChangelogFormat::GitHubReleases => {
|
|
self.generate_github_releases(version, date, commits)
|
|
}
|
|
ChangelogFormat::Custom => self.generate_custom(version, date, commits),
|
|
}
|
|
}
|
|
|
|
/// Generate changelog entry and prepend to file
|
|
pub fn generate_and_prepend(
|
|
&self,
|
|
changelog_path: &Path,
|
|
version: &str,
|
|
date: DateTime<Utc>,
|
|
commits: &[CommitInfo],
|
|
) -> Result<()> {
|
|
let entry = self.generate(version, date, commits)?;
|
|
|
|
let existing = if changelog_path.exists() {
|
|
fs::read_to_string(changelog_path)?
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let new_content = if existing.is_empty() {
|
|
format!("{}{}", CHANGELOG_HEADER, entry)
|
|
} else if existing.starts_with(CHANGELOG_HEADER) {
|
|
format!("{}{}", CHANGELOG_HEADER, entry)
|
|
} else if existing.starts_with("# Changelog") {
|
|
let lines: Vec<&str> = existing.lines().collect();
|
|
let mut header_end = 0;
|
|
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if i == 0 && line.starts_with('#') {
|
|
header_end = i + 1;
|
|
} else if line.trim().is_empty() {
|
|
header_end = i + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let header = lines[..header_end].join("\n");
|
|
let rest = lines[header_end..].join("\n");
|
|
|
|
format!("{}\n{}\n{}", header, entry, rest)
|
|
} else {
|
|
format!("{}{}", CHANGELOG_HEADER, entry)
|
|
};
|
|
|
|
fs::write(changelog_path, new_content)
|
|
.with_context(|| format!("Failed to write changelog: {:?}", changelog_path))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn generate_keep_a_changelog(
|
|
&self,
|
|
version: &str,
|
|
date: DateTime<Utc>,
|
|
commits: &[CommitInfo],
|
|
) -> Result<String> {
|
|
let date_str = date.format("%Y-%m-%d").to_string();
|
|
let mut output = format!("## [{}] - {}\n\n", version, date_str);
|
|
|
|
if self.group_by_type {
|
|
let _grouped = self.group_commits(commits);
|
|
|
|
// Standard categories
|
|
let categories = vec![
|
|
("Added", vec!["feat"]),
|
|
("Changed", vec!["refactor", "perf"]),
|
|
("Deprecated", vec![]),
|
|
("Removed", vec!["remove"]),
|
|
("Fixed", vec!["fix"]),
|
|
("Security", vec!["security"]),
|
|
];
|
|
|
|
for (title, types) in &categories {
|
|
let items: Vec<&CommitInfo> = commits
|
|
.iter()
|
|
.filter(|c| {
|
|
if let Some(ref t) = c.commit_type() {
|
|
types.contains(&t.as_str())
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !items.is_empty() {
|
|
output.push_str(&format!("### {}\n\n", title));
|
|
for commit in items {
|
|
output.push_str(&self.format_commit(commit));
|
|
output.push('\n');
|
|
}
|
|
output.push('\n');
|
|
}
|
|
}
|
|
|
|
// Other changes
|
|
let categorized: Vec<String> = categories
|
|
.iter()
|
|
.flat_map(|(_, types)| types.iter().map(|s| s.to_string()))
|
|
.collect();
|
|
|
|
let other: Vec<&CommitInfo> = commits
|
|
.iter()
|
|
.filter(|c| {
|
|
if let Some(ref t) = c.commit_type() {
|
|
!categorized.contains(t)
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !other.is_empty() {
|
|
output.push_str("### Other\n\n");
|
|
for commit in other {
|
|
output.push_str(&self.format_commit(commit));
|
|
output.push('\n');
|
|
}
|
|
output.push('\n');
|
|
}
|
|
} else {
|
|
for commit in commits {
|
|
output.push_str(&self.format_commit(commit));
|
|
output.push('\n');
|
|
}
|
|
}
|
|
|
|
Ok(output)
|
|
}
|
|
|
|
fn generate_github_releases(
|
|
&self,
|
|
_version: &str,
|
|
_date: DateTime<Utc>,
|
|
commits: &[CommitInfo],
|
|
) -> Result<String> {
|
|
let mut output = "## What's Changed\n\n".to_string();
|
|
|
|
// Group by type
|
|
let mut features = vec![];
|
|
let mut fixes = vec![];
|
|
let mut docs = vec![];
|
|
let mut other = vec![];
|
|
let mut breaking = vec![];
|
|
|
|
for commit in commits {
|
|
if commit.message.contains("BREAKING CHANGE") {
|
|
breaking.push(commit);
|
|
}
|
|
|
|
if let Some(ref t) = commit.commit_type() {
|
|
match t.as_str() {
|
|
"feat" => features.push(commit),
|
|
"fix" => fixes.push(commit),
|
|
"docs" => docs.push(commit),
|
|
_ => other.push(commit),
|
|
}
|
|
} else {
|
|
other.push(commit);
|
|
}
|
|
}
|
|
|
|
if !breaking.is_empty() {
|
|
output.push_str("### ⚠ Breaking Changes\n\n");
|
|
for commit in breaking {
|
|
output.push_str(&self.format_commit_github(commit));
|
|
}
|
|
output.push('\n');
|
|
}
|
|
|
|
if !features.is_empty() {
|
|
output.push_str("### 🚀 Features\n\n");
|
|
for commit in features {
|
|
output.push_str(&self.format_commit_github(commit));
|
|
}
|
|
output.push('\n');
|
|
}
|
|
|
|
if !fixes.is_empty() {
|
|
output.push_str("### 🐛 Bug Fixes\n\n");
|
|
for commit in fixes {
|
|
output.push_str(&self.format_commit_github(commit));
|
|
}
|
|
output.push('\n');
|
|
}
|
|
|
|
if !docs.is_empty() {
|
|
output.push_str("### 📚 Documentation\n\n");
|
|
for commit in docs {
|
|
output.push_str(&self.format_commit_github(commit));
|
|
}
|
|
output.push('\n');
|
|
}
|
|
|
|
if !other.is_empty() {
|
|
output.push_str("### Other Changes\n\n");
|
|
for commit in other {
|
|
output.push_str(&self.format_commit_github(commit));
|
|
}
|
|
}
|
|
|
|
Ok(output)
|
|
}
|
|
|
|
fn generate_custom(
|
|
&self,
|
|
version: &str,
|
|
date: DateTime<Utc>,
|
|
commits: &[CommitInfo],
|
|
) -> Result<String> {
|
|
// Use custom categories if defined
|
|
if !self.custom_categories.is_empty() {
|
|
let date_str = date.format("%Y-%m-%d").to_string();
|
|
let mut output = format!("## [{}] - {}\n\n", version, date_str);
|
|
|
|
for category in &self.custom_categories {
|
|
let items: Vec<&CommitInfo> = commits
|
|
.iter()
|
|
.filter(|c| {
|
|
if let Some(ref t) = c.commit_type() {
|
|
category.types.contains(t)
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
if !items.is_empty() {
|
|
output.push_str(&format!("### {}\n\n", category.title));
|
|
for commit in items {
|
|
output.push_str(&self.format_commit(commit));
|
|
output.push('\n');
|
|
}
|
|
output.push('\n');
|
|
}
|
|
}
|
|
|
|
Ok(output)
|
|
} else {
|
|
// Fall back to keep-a-changelog
|
|
self.generate_keep_a_changelog(version, date, commits)
|
|
}
|
|
}
|
|
|
|
fn format_commit(&self, commit: &CommitInfo) -> String {
|
|
let mut line = format!("- {}", commit.subject());
|
|
|
|
if self.include_hashes {
|
|
line.push_str(&format!(" ({})", &commit.short_id));
|
|
}
|
|
|
|
if self.include_authors {
|
|
line.push_str(&format!(" - @{}", commit.author));
|
|
}
|
|
|
|
line
|
|
}
|
|
|
|
fn format_commit_github(&self, commit: &CommitInfo) -> String {
|
|
format!(
|
|
"- {} by @{} in {}\n",
|
|
commit.subject(),
|
|
commit.author,
|
|
&commit.short_id
|
|
)
|
|
}
|
|
|
|
fn group_commits<'a>(&self, commits: &'a [CommitInfo]) -> HashMap<String, Vec<&'a CommitInfo>> {
|
|
let mut groups: HashMap<String, Vec<&'a CommitInfo>> = HashMap::new();
|
|
|
|
for commit in commits {
|
|
let commit_type = commit.commit_type().unwrap_or_else(|| "other".to_string());
|
|
groups.entry(commit_type).or_default().push(commit);
|
|
}
|
|
|
|
groups
|
|
}
|
|
}
|
|
|
|
impl Default for ChangelogGenerator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Read existing changelog
|
|
pub fn read_changelog(path: &Path) -> Result<String> {
|
|
fs::read_to_string(path).with_context(|| format!("Failed to read changelog: {:?}", path))
|
|
}
|
|
|
|
/// Initialize new changelog file
|
|
pub fn init_changelog(path: &Path) -> Result<()> {
|
|
if path.exists() {
|
|
anyhow::bail!("Changelog already exists at {:?}", path);
|
|
}
|
|
|
|
fs::write(path, CHANGELOG_HEADER)
|
|
.with_context(|| format!("Failed to create changelog: {:?}", path))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Generate changelog from git history
|
|
pub fn generate_from_history(
|
|
repo: &GitRepo,
|
|
from_tag: Option<&str>,
|
|
to_ref: Option<&str>,
|
|
) -> Result<Vec<CommitInfo>> {
|
|
let to_ref = to_ref.unwrap_or("HEAD");
|
|
|
|
if let Some(from) = from_tag {
|
|
repo.get_commits_between(from, to_ref)
|
|
} else {
|
|
// Get all commits from the beginning (no from_tag = initial changelog)
|
|
repo.get_commits(usize::MAX)
|
|
}
|
|
}
|
|
|
|
/// Update version links in changelog
|
|
pub fn update_version_links(changelog: &str, version: &str, compare_url: &str) -> String {
|
|
// Add version link at the end of changelog
|
|
format!("{}\n[{}]: {}\n", changelog, version, compare_url)
|
|
}
|
|
|
|
/// Parse changelog to extract versions
|
|
pub fn parse_versions(changelog: &str) -> Vec<(String, String)> {
|
|
let mut versions = vec![];
|
|
|
|
for line in changelog.lines() {
|
|
if line.starts_with("## [")
|
|
&& let Some(start) = line.find('[')
|
|
&& let Some(end) = line.find(']')
|
|
{
|
|
let version = &line[start + 1..end];
|
|
if version != "Unreleased"
|
|
&& let Some(date_start) = line.find(" - ")
|
|
{
|
|
let date = &line[date_start + 3..].trim();
|
|
versions.push((version.to_string(), date.to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
versions
|
|
}
|
|
|
|
/// Get unreleased changes
|
|
pub fn get_unreleased_changes(repo: &GitRepo) -> Result<Vec<CommitInfo>> {
|
|
let tags = repo.get_tags()?;
|
|
|
|
if let Some(latest_tag) = tags.first() {
|
|
repo.get_commits_between(&latest_tag.name, "HEAD")
|
|
} else {
|
|
repo.get_commits(50)
|
|
}
|
|
}
|
|
|
|
/// Changelog entry for a specific version
|
|
pub struct ChangelogEntry {
|
|
pub version: String,
|
|
pub date: DateTime<Utc>,
|
|
pub commits: Vec<CommitInfo>,
|
|
}
|
|
|
|
impl ChangelogEntry {
|
|
/// Create new entry
|
|
pub fn new(version: impl Into<String>, commits: Vec<CommitInfo>) -> Self {
|
|
Self {
|
|
version: version.into(),
|
|
date: Utc::now(),
|
|
commits,
|
|
}
|
|
}
|
|
|
|
/// Set date
|
|
pub fn with_date(mut self, date: DateTime<Utc>) -> Self {
|
|
self.date = date;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Extract version strings from Keep a Changelog content.
|
|
/// Returns versions sorted by semver descending (highest first).
|
|
/// Excludes "Unreleased".
|
|
pub fn parse_changelog_versions(content: &str) -> Vec<String> {
|
|
use semver::Version;
|
|
|
|
let mut versions: Vec<String> = vec![];
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with("## [") {
|
|
if let Some(end) = trimmed.find(']') {
|
|
let version = &trimmed[4..end];
|
|
if version != "Unreleased" && !version.is_empty() {
|
|
versions.push(version.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Sort by semver descending so highest version is first
|
|
versions.sort_by(|a, b| {
|
|
match (Version::parse(a), Version::parse(b)) {
|
|
(Ok(va), Ok(vb)) => vb.cmp(&va),
|
|
(Ok(_), Err(_)) => std::cmp::Ordering::Less,
|
|
(Err(_), Ok(_)) => std::cmp::Ordering::Greater,
|
|
(Err(_), Err(_)) => std::cmp::Ordering::Equal,
|
|
}
|
|
});
|
|
versions
|
|
}
|
|
|
|
/// Insert a new changelog entry into existing changelog content.
|
|
/// The new entry is placed after the header and before the first existing version section.
|
|
/// If no existing version sections are found, the new entry is appended after the header.
|
|
pub fn insert_changelog_entry(existing: &str, new_entry: &str) -> String {
|
|
// Find the first version section (## [x.y.z])
|
|
if let Some(first_ver_pos) = existing.find("\n## [") {
|
|
let (header, rest) = existing.split_at(first_ver_pos);
|
|
// Ensure new_entry ends with a blank line before the next section
|
|
let entry = new_entry.trim_end();
|
|
format!("{}\n{}\n{}", header.trim_end(), entry, rest)
|
|
} else {
|
|
// No existing version sections — append after header
|
|
format!("{}\n{}", existing.trim_end(), new_entry.trim_end())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_changelog_versions_extracts_versions() {
|
|
let content = "# Changelog\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
|
let versions = parse_changelog_versions(content);
|
|
assert_eq!(versions, vec!["0.2.0", "0.1.0"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_changelog_versions_sorts_highest_first() {
|
|
// Versions in reversed order — should still return highest first
|
|
let content = "# Changelog\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n\n## [0.3.0] - 2024-04-01\n### Added\n- feature C\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
|
|
let versions = parse_changelog_versions(content);
|
|
assert_eq!(versions, vec!["0.3.0", "0.2.0", "0.1.0"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_changelog_versions_excludes_unreleased() {
|
|
let content = "# Changelog\n\n## [Unreleased]\n### Added\n- wip\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
|
let versions = parse_changelog_versions(content);
|
|
assert_eq!(versions, vec!["0.1.0"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_changelog_versions_empty_content() {
|
|
let versions = parse_changelog_versions("");
|
|
assert!(versions.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_changelog_versions_no_versions() {
|
|
let content = "# Changelog\n\nSome description text.\n";
|
|
let versions = parse_changelog_versions(content);
|
|
assert!(versions.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_changelog_entry_between_header_and_existing() {
|
|
let existing = "# Changelog\n\nAll notable changes...\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
|
let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
|
|
let result = insert_changelog_entry(existing, new_entry);
|
|
|
|
// New entry should appear after header, before 0.1.0
|
|
assert!(result.contains("## [0.2.0]"));
|
|
assert!(result.contains("## [0.1.0]"));
|
|
let pos_new = result.find("## [0.2.0]").unwrap();
|
|
let pos_old = result.find("## [0.1.0]").unwrap();
|
|
assert!(pos_new < pos_old, "new version should be before old version");
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_changelog_entry_preserves_all_existing_content() {
|
|
let existing = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
|
let new_entry = "## [0.3.0] - 2024-04-01\n### Added\n- feature C\n";
|
|
let result = insert_changelog_entry(existing, new_entry);
|
|
|
|
assert!(result.contains("## [0.3.0]"));
|
|
assert!(result.contains("## [0.2.0]"));
|
|
assert!(result.contains("## [0.1.0]"));
|
|
assert!(result.contains("feature A"), "oldest content preserved");
|
|
assert!(result.contains("feature B"), "middle content preserved");
|
|
assert!(result.contains("feature C"), "new content present");
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_changelog_entry_empty_existing() {
|
|
let existing = "# Changelog\n\nAll notable changes...\n\n";
|
|
let new_entry = "## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
|
let result = insert_changelog_entry(existing, new_entry);
|
|
assert!(result.contains("## [0.1.0]"));
|
|
assert!(result.contains("# Changelog"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_changelog_entry_extra_blank_lines_in_header() {
|
|
let existing = "# Changelog\n\n\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
|
let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
|
|
let result = insert_changelog_entry(existing, new_entry);
|
|
|
|
let pos_new = result.find("## [0.2.0]").unwrap();
|
|
let pos_old = result.find("## [0.1.0]").unwrap();
|
|
assert!(pos_new < pos_old, "new version should be before old version");
|
|
}
|
|
}
|