feat:(first commit)created repository and complete 0.1.0
This commit is contained in:
480
src/git/changelog.rs
Normal file
480
src/git/changelog.rs
Normal file
@@ -0,0 +1,480 @@
|
||||
use super::{CommitInfo, GitRepo};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 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\n\n{}", entry)
|
||||
} else {
|
||||
// Find position after header
|
||||
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)
|
||||
};
|
||||
|
||||
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 = format!("## What's Changed\n\n");
|
||||
|
||||
// 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 {
|
||||
let msg = commit.subject();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let content = 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).
|
||||
|
||||
"#;
|
||||
|
||||
fs::write(path, content)
|
||||
.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 last 50 commits if no tag specified
|
||||
repo.get_commits(50)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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("## [") {
|
||||
if let Some(start) = line.find('[') {
|
||||
if let Some(end) = line.find(']') {
|
||||
let version = &line[start + 1..end];
|
||||
if version != "Unreleased" {
|
||||
if 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user