Files
QuiCommit/src/git/changelog.rs
SidneyZhang 206bde0786 fix: 修复 tag/changelog 生成范围,增加 --auto 模式
- 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()
2026-07-24 17:41:18 +08:00

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");
}
}