feat:(first commit)created repository and complete 0.1.0

This commit is contained in:
2026-01-30 14:18:32 +08:00
commit 5d4156e5e0
36 changed files with 8686 additions and 0 deletions

480
src/git/changelog.rs Normal file
View 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
}
}