use std::collections::HashMap;
use anyhow::{
Context,
Result,
};
use bstr::{
BString,
ByteSlice,
};
use cargo_plugin_utils::common::get_owner_repo;
use clap::Parser;
use regex::Regex;
use crate::version::parse_version;
#[derive(Parser, Debug)]
pub struct ChangelogArgs {
#[arg(long)]
pub at: Option<String>,
#[arg(long)]
pub range: Option<String>,
#[arg(long)]
pub for_version: Option<String>,
#[arg(short, long)]
pub output: Option<String>,
#[arg(long)]
pub owner: Option<String>,
#[arg(long)]
pub repo: Option<String>,
}
#[derive(Debug, Clone)]
struct Commit {
sha: String,
short_sha: String,
commit_type: String,
scope: Option<String>,
breaking: bool,
subject: String,
body: Option<String>,
}
fn parse_conventional_commit(message: &str) -> Option<Commit> {
let re = Regex::new(
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s*(?P<subject>.+)$",
)
.ok()?;
let first_line = message.lines().next()?;
let caps = re.captures(first_line)?;
let commit_type = caps.name("type")?.as_str().to_string();
let scope = caps.name("scope").map(|m| m.as_str().to_string());
let breaking = caps.name("breaking").is_some();
let subject = caps.name("subject")?.as_str().to_string();
let body = message
.lines()
.skip(1)
.skip_while(|l| l.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
let body = if body.is_empty() { None } else { Some(body) };
Some(Commit {
sha: String::new(), short_sha: String::new(), commit_type,
scope,
breaking,
subject,
body,
})
}
fn commit_type_title(commit_type: &str) -> &str {
match commit_type {
"feat" => "Features",
"fix" => "Bug Fixes",
"docs" => "Documentation",
"style" => "Styling",
"refactor" => "Refactoring",
"perf" => "Performance",
"test" => "Tests",
"build" => "Build",
"ci" => "CI/CD",
"chore" => "Chores",
"revert" => "Reverts",
_ => "Other Changes",
}
}
fn include_in_changelog(commit_type: &str) -> bool {
matches!(
commit_type,
"feat" | "fix" | "docs" | "refactor" | "perf" | "revert"
)
}
fn format_commit_entry(commit: &Commit, owner: &str, repo: &str) -> String {
let breaking_marker = if commit.breaking { " **BREAKING**" } else { "" };
let commit_link = format!(
"[{}](https://github.com/{}/{}/commit/{})",
commit.short_sha, owner, repo, commit.sha
);
let mut output = format!("- {}{}: {}\n", commit_link, breaking_marker, commit.subject);
if let Some(body) = &commit.body {
let body_lines: Vec<&str> = body.lines().collect();
if !body_lines.is_empty() {
for line in body_lines {
output.push_str(&format!(" {}\n", line));
}
}
}
output
}
fn resolve_to_commit_oid<'a>(
git_repo: &'a gix::Repository,
reference: &str,
) -> Result<gix::Id<'a>> {
let ref_with_suffix = format!("{}^{{commit}}", reference);
let ref_bstr: BString = ref_with_suffix.into();
if let Ok(spec) = git_repo.rev_parse(ref_bstr.as_bstr())
&& let Some(oid) = spec.single()
&& let Ok(obj) = git_repo.find_object(oid)
&& obj.try_into_commit().is_ok()
{
return Ok(oid);
}
let ref_bstr: BString = reference.into();
let spec = git_repo
.rev_parse(ref_bstr.as_bstr())
.context("Failed to resolve reference")?;
let oid = spec
.single()
.context("Reference resolved to multiple objects")?;
let obj = git_repo.find_object(oid).context("Failed to find object")?;
let obj_kind = obj.kind;
match obj_kind {
gix::object::Kind::Commit => {
obj.try_into_commit()
.context("Object kind is Commit but conversion failed")?;
return Ok(oid);
}
gix::object::Kind::Tag => {
let tag_ref_name = format!("refs/tags/{}", reference);
if let Ok(mut tag_ref) = git_repo.find_reference(tag_ref_name.as_str()) {
let peeled_oid = tag_ref
.peel_to_id()
.context("Failed to peel tag to commit")?;
let peeled_obj = git_repo
.find_object(peeled_oid)
.context("Failed to find peeled commit object")?;
peeled_obj
.try_into_commit()
.context("Tag does not point to a commit")?;
return Ok(peeled_oid);
}
}
_ => {
}
}
anyhow::bail!("Reference '{}' does not point to a commit", reference);
}
pub fn generate_changelog_to_writer(
writer: &mut dyn std::io::Write,
args: ChangelogArgs,
) -> Result<()> {
let (owner, repo) = get_owner_repo(args.owner.clone(), args.repo.clone())?;
let git_repo = gix::discover(".").context("Failed to discover git repository")?;
let (start_oid, end_oid) = if let Some(range) = &args.range {
let parts: Vec<&str> = range.split("..").collect();
if parts.len() != 2 {
anyhow::bail!("Invalid range format. Expected: <start>..<end>");
}
let start_ref = parts[0].trim();
let end_ref = parts[1].trim();
let start_oid = match resolve_to_commit_oid(&git_repo, start_ref) {
Ok(oid) => Some(oid),
Err(_) => {
eprintln!(
"Warning: Start reference '{}' not found in repository, \
generating changelog from beginning",
start_ref
);
None
}
};
let end_oid = resolve_to_commit_oid(&git_repo, end_ref)
.with_context(|| format!("Failed to resolve end reference: {}", end_ref))?;
(start_oid, end_oid)
} else if let Some(tag) = &args.at {
let tag_oid = resolve_to_commit_oid(&git_repo, tag)
.with_context(|| format!("Failed to resolve tag: {}", tag))?;
let head = git_repo.head().context("Failed to read HEAD")?;
let head_oid = head.id().context("HEAD does not point to a commit")?;
(Some(tag_oid), head_oid)
} else {
let mut version_tags: Vec<(gix::Id, String, (u32, u32, u32))> = Vec::new();
let refs = git_repo
.references()
.context("Failed to read git references")?;
for reference_result in refs.all()? {
let Ok(reference) = reference_result else {
continue;
};
let name_str = reference.name().as_bstr().to_string();
let Some(name) = name_str.strip_prefix("refs/tags/") else {
continue;
};
let version_str = name
.strip_prefix('v')
.or_else(|| name.strip_prefix('V'))
.unwrap_or(name);
let Ok((major, minor, patch)) = parse_version(version_str) else {
continue;
};
let Ok(commit_oid) = resolve_to_commit_oid(&git_repo, name) else {
continue;
};
version_tags.push((commit_oid, name.to_string(), (major, minor, patch)));
}
version_tags.sort_by_key(|a| a.2);
let latest_tag_oid = version_tags.last().map(|(oid, _tag_name, _version)| *oid);
let head = git_repo.head().context("Failed to read HEAD")?;
let head_oid = head.id().context("HEAD does not point to a commit")?;
(latest_tag_oid, head_oid)
};
let walk = git_repo.rev_walk([end_oid]);
let walk_iter = walk.all()?;
let mut commits: Vec<Commit> = Vec::new();
for info_result in walk_iter {
let info = info_result?;
let oid = info.id();
if let Some(start) = start_oid
&& oid == start
{
break;
}
let commit_obj = git_repo
.find_object(oid)
.context("Failed to find commit object")?;
let commit = commit_obj
.try_into_commit()
.context("Object is not a commit")?;
let message_raw = commit
.message_raw()
.context("Failed to read raw commit message")?;
let message_str = String::from_utf8_lossy(message_raw.as_ref()).into_owned();
if let Some(mut parsed) = parse_conventional_commit(&message_str) {
if include_in_changelog(&parsed.commit_type) {
let short_sha = oid.shorten().context("Failed to shorten commit SHA")?;
parsed.sha = oid.to_string();
parsed.short_sha = short_sha.to_string();
let body_lines: Vec<&str> = message_str.lines().skip(1).collect();
let body_text: String = body_lines.join("\n").trim().to_string();
parsed.body = if body_text.is_empty() {
None
} else {
Some(body_text)
};
commits.push(parsed);
}
}
}
let mut by_type: HashMap<String, HashMap<Option<String>, Vec<Commit>>> = HashMap::new();
for commit in commits {
by_type
.entry(commit.commit_type.clone())
.or_default()
.entry(commit.scope.clone())
.or_default()
.push(commit);
}
let mut output = String::new();
if let Some(version) = &args.for_version {
let version_display = if version.starts_with('v') || version.starts_with('V') {
version.clone()
} else {
format!("v{}", version)
};
output.push_str(&format!("# Changelog - {}\n\n", version_display));
} else if let Some(tag) = &args.at {
output.push_str(&format!("# Changelog - {}\n\n", tag));
} else {
output.push_str("# Changelog\n\n");
}
let type_order = [
"feat", "fix", "perf", "refactor", "docs", "revert", "build", "ci", "test", "style",
"chore",
];
for commit_type in type_order {
if let Some(by_scope) = by_type.get(commit_type) {
output.push_str(&format!("## {}\n\n", commit_type_title(commit_type)));
let mut scopes: Vec<_> = by_scope.keys().collect();
scopes.sort();
for scope in scopes {
let scope_commits = &by_scope[scope];
if let Some(scope_name) = scope {
output.push_str(&format!("### {}\n\n", scope_name));
}
for commit in scope_commits {
output.push_str(&format_commit_entry(commit, &owner, &repo));
}
output.push('\n');
}
}
}
if output.trim().ends_with("# Changelog\n\n") {
output.push_str("No changes found.\n");
}
write!(writer, "{}", output)?;
Ok(())
}
pub fn changelog(args: ChangelogArgs) -> Result<()> {
let output_path = args.output.clone();
if let Some(ref path) = output_path {
let mut file = std::fs::File::create(path)
.with_context(|| format!("Failed to create file {}", path))?;
generate_changelog_to_writer(&mut file, args)?;
} else {
let mut stdout = std::io::stdout();
generate_changelog_to_writer(&mut stdout, args)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::process::Command;
use tempfile::TempDir;
use super::*;
fn create_test_git_repo_with_tags_and_commits(tags: &[&str], commits: &[&str]) -> TempDir {
let dir = tempfile::tempdir().unwrap();
Command::new("git")
.arg("init")
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("README.md"), "# Test\n").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir.path())
.output()
.unwrap();
for commit_msg in commits {
let file_name = format!("file_{}.txt", commit_msg.replace([' ', ':'], "_"));
std::fs::write(dir.path().join(&file_name), commit_msg).unwrap();
Command::new("git")
.args(["add", &file_name])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", commit_msg])
.current_dir(dir.path())
.output()
.unwrap();
}
for tag in tags {
Command::new("git")
.args(["tag", "-a", tag, "-m", &format!("Release {}", tag)])
.current_dir(dir.path())
.output()
.unwrap();
}
dir
}
#[test]
fn test_changelog_finds_latest_tag_not_first() {
let _dir = create_test_git_repo_with_tags_and_commits(
&["v0.1.0", "v0.1.5", "v0.2.0"], &[
"feat(test): add feature for v0.1.0",
"fix(test): fix bug for v0.1.5",
"feat(test): add feature for v0.2.0",
],
);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let args = ChangelogArgs {
at: None,
range: None,
for_version: None,
output: None,
owner: Some("test".to_string()),
repo: Some("repo".to_string()),
};
let mut output = Vec::new();
let result = generate_changelog_to_writer(&mut output, args);
std::env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok(), "Changelog generation should succeed");
}
#[test]
fn test_changelog_with_for_version() {
let _dir =
create_test_git_repo_with_tags_and_commits(&["v0.1.0"], &["feat(test): add feature"]);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let args = ChangelogArgs {
at: None,
range: None,
for_version: Some("v0.2.0".to_string()),
output: None,
owner: Some("test".to_string()),
repo: Some("repo".to_string()),
};
let mut output = Vec::new();
let result = generate_changelog_to_writer(&mut output, args);
std::env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok());
let output_str = String::from_utf8(output).unwrap();
assert!(
output_str.contains("Changelog - v0.2.0"),
"Header should include for-version"
);
}
#[test]
fn test_changelog_with_for_version_no_v_prefix() {
let _dir = create_test_git_repo_with_tags_and_commits(&["v0.1.0"], &[]);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let args = ChangelogArgs {
at: None,
range: None,
for_version: Some("0.2.0".to_string()), output: None,
owner: Some("test".to_string()),
repo: Some("repo".to_string()),
};
let mut output = Vec::new();
let result = generate_changelog_to_writer(&mut output, args);
std::env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok());
let output_str = String::from_utf8(output).unwrap();
assert!(
output_str.contains("Changelog - v0.2.0"),
"Header should normalize version with v prefix"
);
}
#[test]
fn test_changelog_no_tags() {
let _dir = create_test_git_repo_with_tags_and_commits(
&[],
&["feat(test): add feature", "fix(test): fix bug"],
);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let args = ChangelogArgs {
at: None,
range: None,
for_version: None,
output: None,
owner: Some("test".to_string()),
repo: Some("repo".to_string()),
};
let mut output = Vec::new();
let result = generate_changelog_to_writer(&mut output, args);
std::env::set_current_dir(original_dir).unwrap();
assert!(result.is_ok(), "Should succeed even with no tags");
let output_str = String::from_utf8(output).unwrap();
assert!(
output_str.contains("Changelog"),
"Should have changelog header"
);
}
#[test]
fn test_changelog_with_range() {
let _dir = create_test_git_repo_with_tags_and_commits(
&["v0.1.0", "v0.2.0"],
&["feat(test): add feature"],
);
let dir_path = _dir.path().to_path_buf();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir_path).unwrap();
let args = ChangelogArgs {
at: None,
range: Some("v0.1.0..v0.2.0".to_string()),
for_version: None,
output: None,
owner: Some("test".to_string()),
repo: Some("repo".to_string()),
};
let mut output = Vec::new();
let result = generate_changelog_to_writer(&mut output, args);
std::env::set_current_dir(original_dir).unwrap();
if let Err(e) = &result {
eprintln!("Changelog generation failed: {}", e);
}
assert!(result.is_ok(), "Changelog with explicit range should work");
}
}