use crate::error::Result;
use crate::integrations::git::{CommitInfo, GitRepo};
use crate::steps::{Step, StepContext, StepOutput};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use std::fs;
use std::path::PathBuf;
pub struct ChangelogStep {
version: String,
previous_tag: Option<String>,
}
impl ChangelogStep {
pub fn new(version: String, previous_tag: Option<String>) -> Self {
Self {
version,
previous_tag,
}
}
fn format_changelog(
version: &str,
commits: &[CommitInfo],
previous_tag: Option<&str>,
) -> String {
let mut output = String::new();
let now: DateTime<Utc> = Utc::now();
output.push_str(&format!(
"## [{}] - {}\n\n",
version,
now.format("%Y-%m-%d")
));
if commits.is_empty() {
output.push_str("No changes recorded.\n\n");
return output;
}
let mut features = Vec::new();
let mut fixes = Vec::new();
let mut other = Vec::new();
for commit in commits {
let msg = commit.message.lines().next().unwrap_or("").trim();
if msg.is_empty() {
continue;
}
if msg.starts_with("feat:") || msg.starts_with("feature:") {
features.push(
msg.trim_start_matches("feat:")
.trim_start_matches("feature:")
.trim(),
);
} else if msg.starts_with("fix:") {
fixes.push(msg.trim_start_matches("fix:").trim());
} else {
other.push(msg);
}
}
if !features.is_empty() {
output.push_str("### Features\n");
for feature in features {
output.push_str(&format!("- {}\n", feature));
}
output.push('\n');
}
if !fixes.is_empty() {
output.push_str("### Bug Fixes\n");
for fix in fixes {
output.push_str(&format!("- {}\n", fix));
}
output.push('\n');
}
if !other.is_empty() {
output.push_str("### Other Changes\n");
for change in other {
output.push_str(&format!("- {}\n", change));
}
output.push('\n');
}
if let Some(prev) = previous_tag {
output.push_str(&format!("**Full Changelog**: {}...{}\n\n", prev, version));
}
output
}
fn get_changelog_path(&self) -> Result<PathBuf> {
let repo = GitRepo::open()?;
Ok(repo.root_path().join("CHANGELOG.md"))
}
fn prepend_to_changelog(&self, path: &PathBuf, new_content: &str) -> Result<()> {
let existing = if path.exists() {
fs::read_to_string(path)?
} else {
"# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n".to_string()
};
let updated = if existing.starts_with("## [") {
format!("{}{}", new_content, existing)
} else if let Some(pos) = existing.find("\n## [") {
let (header, rest) = existing.split_at(pos + 1);
format!("{}{}{}", header, new_content, rest)
} else {
format!("{}\n\n{}", existing.trim_end(), new_content)
};
fs::write(path, updated)?;
Ok(())
}
fn categorize_commits(commits: &[String]) -> (usize, usize, usize) {
let mut features = 0;
let mut fixes = 0;
let mut other = 0;
for commit in commits {
let msg = commit.trim();
if msg.is_empty() {
continue;
}
if msg.starts_with("feat:") || msg.starts_with("feature:") {
features += 1;
} else if msg.starts_with("fix:") {
fixes += 1;
} else {
other += 1;
}
}
(features, fixes, other)
}
}
#[async_trait]
impl Step for ChangelogStep {
fn name(&self) -> &str {
"changelog"
}
fn description(&self) -> &str {
"Generate changelog from commits"
}
async fn validate(&self, _ctx: &StepContext) -> Result<()> {
GitRepo::open()?;
Ok(())
}
async fn execute(&self, _ctx: &StepContext) -> Result<StepOutput> {
let repo = GitRepo::open()?;
let commits = if let Some(ref prev_tag) = self.previous_tag {
repo.get_commits_between(prev_tag, "HEAD")?
} else {
repo.get_commits_up_to("HEAD")?
};
let changelog_content =
Self::format_changelog(&self.version, &commits, self.previous_tag.as_deref());
let path = self.get_changelog_path()?;
self.prepend_to_changelog(&path, &changelog_content)?;
Ok(StepOutput::ok(format!(
"Generated changelog with {} commits",
commits.len()
)))
}
async fn dry_run(&self, _ctx: &StepContext) -> Result<StepOutput> {
let repo = GitRepo::open()?;
let commits = if let Some(ref prev_tag) = self.previous_tag {
repo.get_commits_between(prev_tag, "HEAD")?
} else {
repo.get_commits_up_to("HEAD")?
};
let preview_content =
Self::format_changelog(&self.version, &commits, self.previous_tag.as_deref());
let commit_messages: Vec<String> = commits
.iter()
.map(|c| c.message.lines().next().unwrap_or("").to_string())
.collect();
let (features, fixes, other) = Self::categorize_commits(&commit_messages);
let details = crate::steps::DryRunDetails {
file_changes: vec![crate::steps::FileChange {
path: "CHANGELOG.md".to_string(),
operation: crate::steps::FileOperation::Modify,
diff: None,
}],
docker_preview: None,
notes: vec![
format!("Found {} new commits", commits.len()),
format!(
" - {} features, {} fixes, {} other",
features, fixes, other
),
if commits.is_empty() {
"⚠ No commits since last tag - changelog will be empty".to_string()
} else {
format!(
"Preview:\n{}",
preview_content
.lines()
.take(20)
.collect::<Vec<_>>()
.join("\n")
)
},
],
};
Ok(StepOutput::ok(format!(
"Would generate changelog with {} commits",
commits.len()
))
.with_dry_run_details(details))
}
async fn rollback(&self, _ctx: &StepContext) -> Result<()> {
let repo = GitRepo::open()?;
let path = self.get_changelog_path()?;
let rel_path = path.strip_prefix(repo.root_path()).map_err(|_| {
crate::error::ApiForgeError::Config("Invalid changelog path".to_string())
})?;
repo.checkout_file(rel_path)?;
tracing::info!("Rolled back changelog changes");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prepend_keeps_header_intact_and_orders_sections() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("CHANGELOG.md");
std::fs::write(
&path,
"# Changelog\n\nAll notable changes documented here.\n\n## [1.0.0] - 2026-01-01\n\n- old\n",
)
.unwrap();
let step = ChangelogStep::new("1.1.0".to_string(), Some("v1.0.0".to_string()));
step.prepend_to_changelog(&path, "## [1.1.0] - 2026-06-10\n\n- new\n\n")
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let header_pos = content.find("All notable").unwrap();
let new_pos = content.find("## [1.1.0]").unwrap();
let old_pos = content.find("## [1.0.0]").unwrap();
assert!(header_pos < new_pos, "header must stay above new section");
assert!(new_pos < old_pos, "newest section must come first");
}
#[test]
fn test_prepend_creates_new_file_with_header_then_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("CHANGELOG.md");
let step = ChangelogStep::new("1.0.0".to_string(), None);
step.prepend_to_changelog(&path, "## [1.0.0] - 2026-06-10\n\n- first\n\n")
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("# Changelog"));
assert!(
content.find("documented in this file").unwrap() < content.find("## [1.0.0]").unwrap(),
"description paragraph must precede release sections"
);
}
#[test]
fn test_prepend_handles_file_starting_with_release_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("CHANGELOG.md");
std::fs::write(&path, "## [1.0.0] - 2026-01-01\n\n- old\n").unwrap();
let step = ChangelogStep::new("1.1.0".to_string(), Some("v1.0.0".to_string()));
step.prepend_to_changelog(&path, "## [1.1.0] - 2026-06-10\n\n- new\n\n")
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("## [1.1.0]"));
}
}