use crate::error::{RailError, RailResult};
use std::path::Path;
use std::process::Command;
use winnow::Parser;
use winnow::ascii::{space0, till_line_ending};
use winnow::combinator::{opt, preceded, terminated};
use winnow::token::{take_till, take_until};
type PResult<T> = winnow::error::Result<T>;
#[derive(Debug, Clone, PartialEq)]
pub struct ConventionalCommit<'a> {
pub commit_type: CommitType,
pub scope: Option<&'a str>,
pub breaking: bool,
pub description: &'a str,
pub body: Option<&'a str>,
pub sha: &'a str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CommitType {
Feature,
Fix,
Breaking,
Chore,
Docs,
Style,
Refactor,
Perf,
Test,
Build,
Ci,
Other,
}
impl CommitType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Feature => "feat",
Self::Fix => "fix",
Self::Breaking => "breaking",
Self::Chore => "chore",
Self::Docs => "docs",
Self::Style => "style",
Self::Refactor => "refactor",
Self::Perf => "perf",
Self::Test => "test",
Self::Build => "build",
Self::Ci => "ci",
Self::Other => "other",
}
}
pub fn emoji(&self) -> &'static str {
match self {
Self::Feature => "✨",
Self::Fix => "🐛",
Self::Breaking => "⚠️",
Self::Chore => "🔧",
Self::Docs => "📝",
Self::Style => "💄",
Self::Refactor => "♻️",
Self::Perf => "⚡",
Self::Test => "✅",
Self::Build => "🏗️",
Self::Ci => "👷",
Self::Other => "📦",
}
}
pub fn section_title(&self) -> &'static str {
match self {
Self::Feature => "Features",
Self::Fix => "Bug Fixes",
Self::Breaking => "BREAKING CHANGES",
Self::Chore => "Chores",
Self::Docs => "Documentation",
Self::Style => "Styling",
Self::Refactor => "Refactoring",
Self::Perf => "Performance",
Self::Test => "Testing",
Self::Build => "Build",
Self::Ci => "CI",
Self::Other => "Other Changes",
}
}
}
fn parse_github_remote(url: &str) -> Option<(String, String)> {
let trimmed = url.trim().trim_end_matches(".git").trim_end_matches('/');
let repo_part = if let Some(ssh) = trimmed.strip_prefix("git@github.com:") {
ssh
} else if let Some(ssh) = trimmed.strip_prefix("ssh://git@github.com/") {
ssh
} else {
trimmed.strip_prefix("https://github.com/")?
};
let mut parts = repo_part.split('/');
let org = parts.next()?;
let repo = parts.next()?;
Some((org.to_string(), repo.to_string()))
}
fn git_command(workspace_root: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(workspace_root);
cmd
}
pub fn detect_github_repo(workspace_root: &Path) -> Option<(String, String)> {
let output = git_command(workspace_root)
.args(["config", "--get", "remote.origin.url"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout);
parse_github_remote(&url)
}
fn parse_commit_type(input: &mut &str) -> PResult<CommitType> {
take_till(1.., ['(', '!', ':'])
.verify_map(|value: &str| match value {
"feat" => Some(CommitType::Feature),
"fix" => Some(CommitType::Fix),
"chore" => Some(CommitType::Chore),
"docs" => Some(CommitType::Docs),
"style" => Some(CommitType::Style),
"refactor" => Some(CommitType::Refactor),
"perf" => Some(CommitType::Perf),
"test" => Some(CommitType::Test),
"build" => Some(CommitType::Build),
"ci" => Some(CommitType::Ci),
_ => None,
})
.parse_next(input)
}
fn parse_scope<'a>(input: &mut &'a str) -> PResult<Option<&'a str>> {
opt(preceded('(', terminated(take_until(0.., ')'), ')'))).parse_next(input)
}
fn parse_breaking(input: &mut &str) -> PResult<bool> {
opt('!').map(|o| o.is_some()).parse_next(input)
}
fn parse_description<'a>(input: &mut &'a str) -> PResult<&'a str> {
preceded((':', space0), till_line_ending).parse_next(input)
}
fn extract_pr_numbers(text: &str) -> Vec<u32> {
let mut prs = Vec::new();
for word in text.split_whitespace() {
if let Some(num_str) = word.strip_prefix("(#").and_then(|s| s.strip_suffix(')'))
&& let Ok(num) = num_str.parse::<u32>()
{
prs.push(num);
continue;
}
if let Some(num_str) = word.strip_prefix('#') {
let numeric = num_str.trim_end_matches(|c: char| !c.is_ascii_digit());
if let Ok(num) = numeric.parse::<u32>() {
prs.push(num);
}
}
}
prs.sort_unstable();
prs.dedup();
prs
}
pub fn parse_conventional_commit<'a>(sha: &'a str, subject: &'a str, body: Option<&'a str>) -> ConventionalCommit<'a> {
let mut input = subject;
let result = (parse_commit_type, parse_scope, parse_breaking, parse_description).parse_next(&mut input);
match result {
Ok((commit_type, scope, breaking_marker, description)) => {
let has_breaking_body = body
.map(|b| b.contains("BREAKING CHANGE:") || b.contains("BREAKING-CHANGE:"))
.unwrap_or(false);
let breaking = breaking_marker || has_breaking_body;
let final_type = if breaking && commit_type != CommitType::Breaking {
CommitType::Breaking
} else {
commit_type
};
ConventionalCommit {
commit_type: final_type,
scope,
breaking,
description,
body,
sha,
}
}
Err(_) => {
ConventionalCommit {
commit_type: CommitType::Other,
scope: None,
breaking: false,
description: subject,
body,
sha,
}
}
}
}
pub struct ChangelogGenerator {
workspace_root: std::path::PathBuf,
github_repo: Option<(String, String)>,
}
impl ChangelogGenerator {
pub fn new(workspace_root: &Path) -> Self {
Self {
workspace_root: workspace_root.to_path_buf(),
github_repo: detect_github_repo(workspace_root),
}
}
pub fn github_repo(&self) -> Option<&(String, String)> {
self.github_repo.as_ref()
}
fn short_sha<'a>(&self, sha: &'a str) -> &'a str {
sha.get(..7).unwrap_or(sha)
}
fn format_sha(&self, sha: &str) -> String {
if let Some((org, repo)) = &self.github_repo {
return format!(
"[{}](https://github.com/{}/{}/commit/{})",
self.short_sha(sha),
org,
repo,
sha
);
}
self.short_sha(sha).to_string()
}
fn format_pr_links(&self, commit: &ConventionalCommit) -> Option<String> {
let mut text = commit.description.to_string();
if let Some(body) = commit.body {
text.push(' ');
text.push_str(body);
}
let prs = extract_pr_numbers(&text);
if prs.is_empty() {
return None;
}
if let Some((org, repo)) = &self.github_repo {
Some(format_pr_links(&prs, |pr| {
format!("[#{}](https://github.com/{}/{}/pull/{})", pr, org, repo, pr)
}))
} else {
Some(format_pr_links(&prs, |pr| format!("#{}", pr)))
}
}
fn format_description(&self, commit: &ConventionalCommit) -> String {
let mut desc = commit.description.trim().to_string();
if commit.breaking {
desc = format!("[**breaking**] {}", desc);
}
desc
}
fn format_entry(&self, commit: &ConventionalCommit) -> String {
let scope_prefix = commit.scope.map(|s| format!("**{}**: ", s)).unwrap_or_default();
let sha = self.format_sha(commit.sha);
let desc = self.format_description(commit);
if let Some(prs) = self.format_pr_links(commit) {
format!("- {}{} {} ({})\n", scope_prefix, desc, prs, sha)
} else {
format!("- {}{} ({})\n", scope_prefix, desc, sha)
}
}
pub fn generate(&self, from_tag: Option<&str>, to_ref: &str, paths: Option<&[&Path]>) -> RailResult<String> {
let commits = self.get_commits(from_tag, to_ref, paths)?;
let parsed: Vec<ConventionalCommit> = commits
.iter()
.map(|(sha, subject, body)| parse_conventional_commit(sha, subject, body.as_deref()))
.collect();
let commit_count = parsed.len();
let mut breaking = Vec::with_capacity(commit_count / 10); let mut features = Vec::with_capacity(commit_count / 3); let mut fixes = Vec::with_capacity(commit_count / 3); let mut other_groups: std::collections::HashMap<CommitType, Vec<&ConventionalCommit>> =
std::collections::HashMap::new();
for commit in &parsed {
match commit.commit_type {
CommitType::Breaking => breaking.push(commit),
CommitType::Feature => features.push(commit),
CommitType::Fix => fixes.push(commit),
_ => {
other_groups.entry(commit.commit_type).or_default().push(commit);
}
}
}
let mut changelog = String::new();
if !breaking.is_empty() {
changelog.push_str(&format!(
"### {} {}\n\n",
CommitType::Breaking.emoji(),
CommitType::Breaking.section_title()
));
for commit in breaking {
changelog.push_str(&self.format_entry(commit));
}
changelog.push('\n');
}
if !features.is_empty() {
changelog.push_str(&format!(
"### {} {}\n\n",
CommitType::Feature.emoji(),
CommitType::Feature.section_title()
));
for commit in features {
changelog.push_str(&self.format_entry(commit));
}
changelog.push('\n');
}
if !fixes.is_empty() {
changelog.push_str(&format!(
"### {} {}\n\n",
CommitType::Fix.emoji(),
CommitType::Fix.section_title()
));
for commit in fixes {
changelog.push_str(&self.format_entry(commit));
}
changelog.push('\n');
}
let mut types: Vec<_> = other_groups.keys().collect();
types.sort_by_key(|t| t.as_str());
for commit_type in types {
let commits = &other_groups[commit_type];
changelog.push_str(&format!(
"### {} {}\n\n",
commit_type.emoji(),
commit_type.section_title()
));
for commit in commits {
changelog.push_str(&self.format_entry(commit));
}
changelog.push('\n');
}
Ok(changelog)
}
fn get_commits(
&self,
from_tag: Option<&str>,
to_ref: &str,
paths: Option<&[&Path]>,
) -> RailResult<Vec<(String, String, Option<String>)>> {
let range = if let Some(from) = from_tag {
format!("{}..{}", from, to_ref)
} else {
to_ref.to_string()
};
let mut cmd = git_command(&self.workspace_root);
cmd.args([
"log",
&range,
"--format=%H%x00%s%x00%b%x00",
"--no-merges", ]);
if let Some(paths) = paths
&& !paths.is_empty()
{
cmd.arg("--");
for path in paths {
cmd.arg(path);
}
}
let output = cmd
.output()
.map_err(|e| RailError::message(format!("Failed to execute git log: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::message(format!("git log failed: {}", stderr)));
}
let log = String::from_utf8_lossy(&output.stdout);
let mut commits = Vec::new();
for commit_block in log.split("\0\0") {
if commit_block.trim().is_empty() {
continue;
}
let mut parts = commit_block.splitn(3, '\0');
let Some(sha) = parts.next() else {
continue;
};
let Some(subject) = parts.next() else {
continue;
};
let body_raw = parts.next().unwrap_or_default();
let sha = sha.trim().to_string();
let subject = subject.trim().to_string();
let body = if !body_raw.trim().is_empty() {
Some(body_raw.trim().to_string())
} else {
None
};
commits.push((sha, subject, body));
}
Ok(commits)
}
}
fn format_pr_links<F>(prs: &[u32], mut render: F) -> String
where
F: FnMut(u32) -> String,
{
let mut out = String::new();
for (idx, pr) in prs.iter().copied().enumerate() {
if idx > 0 {
out.push(' ');
}
out.push_str(&render(pr));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_conventional_commit_feature() {
let commit = parse_conventional_commit("abc123", "feat: add new feature", None);
assert_eq!(commit.commit_type, CommitType::Feature);
assert_eq!(commit.scope, None);
assert!(!commit.breaking);
assert_eq!(commit.description, "add new feature");
}
#[test]
fn test_parse_conventional_commit_with_scope() {
let commit = parse_conventional_commit("abc123", "fix(parser): fix parsing bug", None);
assert_eq!(commit.commit_type, CommitType::Fix);
assert_eq!(commit.scope, Some("parser"));
assert!(!commit.breaking);
assert_eq!(commit.description, "fix parsing bug");
}
#[test]
fn test_parse_conventional_commit_breaking() {
let commit = parse_conventional_commit("abc123", "feat!: breaking change", None);
assert_eq!(commit.commit_type, CommitType::Breaking);
assert!(commit.breaking);
assert_eq!(commit.description, "breaking change");
}
#[test]
fn test_parse_conventional_commit_breaking_in_body() {
let commit = parse_conventional_commit(
"abc123",
"feat: some feature",
Some("BREAKING CHANGE: this breaks stuff"),
);
assert_eq!(commit.commit_type, CommitType::Breaking);
assert!(commit.breaking);
}
#[test]
fn test_parse_non_conventional_commit() {
let commit = parse_conventional_commit("abc123", "Update README", None);
assert_eq!(commit.commit_type, CommitType::Other);
assert_eq!(commit.description, "Update README");
}
#[test]
fn test_parse_conventional_commit_ci() {
let commit = parse_conventional_commit("abc123", "ci: update release workflow", None);
assert_eq!(commit.commit_type, CommitType::Ci);
assert_eq!(commit.scope, None);
assert!(!commit.breaking);
assert_eq!(commit.description, "update release workflow");
}
#[test]
fn parse_github_remote_supports_common_patterns() {
assert_eq!(
parse_github_remote("git@github.com:org/repo.git"),
Some(("org".to_string(), "repo".to_string()))
);
assert_eq!(
parse_github_remote("https://github.com/org/repo"),
Some(("org".to_string(), "repo".to_string()))
);
assert_eq!(
parse_github_remote("ssh://git@github.com/org/repo"),
Some(("org".to_string(), "repo".to_string()))
);
}
#[test]
fn parse_github_remote_non_github_returns_none() {
assert_eq!(parse_github_remote("git@gitlab.com:org/repo.git"), None);
}
#[test]
fn extract_pr_numbers_supports_common_patterns() {
let prs = extract_pr_numbers("feat(auth): add login (#12) closes #34 and refs #34");
assert_eq!(prs, vec![12, 34]);
}
#[test]
fn format_entry_includes_pr_and_links_when_available() {
let commit = ConventionalCommit {
commit_type: CommitType::Feature,
scope: Some("api"),
breaking: false,
description: "redesign REST endpoints (#123)",
body: Some("closes #456"),
sha: "abcdef1234567890",
};
let generator = ChangelogGenerator {
workspace_root: std::path::PathBuf::new(),
github_repo: Some(("org".to_string(), "repo".to_string())),
};
let line = generator.format_entry(&commit);
assert!(line.contains("[#123](https://github.com/org/repo/pull/123)"));
assert!(line.contains("[#456](https://github.com/org/repo/pull/456)"));
assert!(line.contains("**api**: redesign REST endpoints (#123)"));
assert!(line.contains("[abcdef1](https://github.com/org/repo/commit/abcdef1234567890)"));
}
#[test]
fn format_entry_marks_breaking_inline() {
let commit = ConventionalCommit {
commit_type: CommitType::Breaking,
scope: None,
breaking: true,
description: "change API",
body: None,
sha: "1234567",
};
let generator = ChangelogGenerator {
workspace_root: std::path::PathBuf::new(),
github_repo: None,
};
let line = generator.format_entry(&commit);
assert!(line.contains("[**breaking**] change API"));
}
#[test]
fn format_entry_without_github_keeps_plain_pr_refs() {
let commit = ConventionalCommit {
commit_type: CommitType::Feature,
scope: None,
breaking: false,
description: "add feature (#12)",
body: Some("follow-up #34"),
sha: "abcdef1234567890",
};
let generator = ChangelogGenerator {
workspace_root: std::path::PathBuf::new(),
github_repo: None,
};
let line = generator.format_entry(&commit);
assert!(line.contains("#12 #34"));
assert!(line.contains("(abcdef1)"));
}
}