use crate::analyser::BumpType;
use crate::error::BumperResult;
use crate::versioner::Version;
use chrono::Local;
use regex::Regex;
use std::fs;
use std::path::Path;
const CHANGELOG_FILE: &str = "CHANGELOG.md";
#[derive(Debug)]
#[allow(dead_code)]
struct ChangelogEntry {
version: String,
date: String,
changes: Vec<Change>,
}
#[derive(Debug)]
struct Change {
category: ChangeCategory,
description: String,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
#[allow(dead_code)]
enum ChangeCategory {
Added,
Changed,
Deprecated,
Removed,
Fixed,
Security,
}
impl ChangeCategory {
fn as_str(&self) -> &'static str {
match self {
ChangeCategory::Added => "### Added",
ChangeCategory::Changed => "### Changed",
ChangeCategory::Deprecated => "### Deprecated",
ChangeCategory::Removed => "### Removed",
ChangeCategory::Fixed => "### Fixed",
ChangeCategory::Security => "### Security",
}
}
fn from_commit_type(commit_type: &str) -> Self {
match commit_type {
"feat" => ChangeCategory::Added,
"fix" => ChangeCategory::Fixed,
"perf" => ChangeCategory::Changed,
"refactor" => ChangeCategory::Changed,
"revert" => ChangeCategory::Removed,
"security" => ChangeCategory::Security,
_ => ChangeCategory::Changed,
}
}
}
pub fn generate_changelog_entry(
version: &Version,
commits: &[String],
_bump_type: BumpType,
) -> BumperResult<()> {
generate_changelog_entry_at_path(version, commits, Path::new(CHANGELOG_FILE))
}
fn generate_changelog_entry_at_path(
version: &Version,
commits: &[String],
changelog_path: &Path,
) -> BumperResult<()> {
let date = Local::now().format("%Y-%m-%d").to_string();
let mut changes: Vec<Change> = Vec::new();
let commit_regex = Regex::new(r"^([a-z]+)(?:\([^)]+\))?(!?): (.+)$").unwrap();
for commit in commits {
if commit.starts_with("chore: bump version") || commit.starts_with("chore: sync package") {
continue;
}
if let Some(captures) = commit_regex.captures(commit) {
let commit_type = captures.get(1).map(|m| m.as_str()).unwrap_or("");
let has_breaking = captures.get(2).map(|m| m.as_str()).unwrap_or("") == "!";
let description = captures.get(3).map(|m| m.as_str()).unwrap_or(commit);
let category = if has_breaking {
ChangeCategory::Changed
} else {
ChangeCategory::from_commit_type(commit_type)
};
let mut desc = description.to_string();
if has_breaking {
desc = format!("**BREAKING:** {}", desc);
}
changes.push(Change {
category,
description: desc,
});
} else {
changes.push(Change {
category: ChangeCategory::Changed,
description: commit.clone(),
});
}
}
changes.sort_by(|a, b| a.category.cmp(&b.category));
let mut entry = format!("## [{}] - {}\n\n", version, date);
let mut current_category: Option<ChangeCategory> = None;
for change in changes {
if current_category.as_ref() != Some(&change.category) {
if current_category.is_some() {
entry.push('\n');
}
entry.push_str(&format!("{}\n\n", change.category.as_str()));
current_category = Some(change.category);
}
entry.push_str(&format!("- {}\n", change.description));
}
let mut content = if changelog_path.exists() {
fs::read_to_string(changelog_path)?
} else {
String::from("# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n")
};
let insertion_point = if let Some(pos) = content.find("\n## [") {
entry.push('\n');
pos + 1
} else {
content.len()
};
content.insert_str(insertion_point, &entry);
fs::write(changelog_path, content)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_change_category_from_commit_type() {
assert_eq!(
ChangeCategory::from_commit_type("feat"),
ChangeCategory::Added
);
assert_eq!(
ChangeCategory::from_commit_type("fix"),
ChangeCategory::Fixed
);
assert_eq!(
ChangeCategory::from_commit_type("refactor"),
ChangeCategory::Changed
);
assert_eq!(
ChangeCategory::from_commit_type("perf"),
ChangeCategory::Changed
);
assert_eq!(
ChangeCategory::from_commit_type("revert"),
ChangeCategory::Removed
);
assert_eq!(
ChangeCategory::from_commit_type("security"),
ChangeCategory::Security
);
}
#[test]
fn test_change_category_ordering() {
assert!(ChangeCategory::Added < ChangeCategory::Changed);
assert!(ChangeCategory::Fixed < ChangeCategory::Security);
assert!(ChangeCategory::Added < ChangeCategory::Fixed);
}
#[test]
fn test_generate_changelog_entry_creates_new_file() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec![
"feat: add new feature".to_string(),
"fix: resolve bug".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
assert!(changelog_path.exists());
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("# Changelog"));
assert!(content.contains("## [1.0.0]"));
assert!(content.contains("### Added"));
assert!(content.contains("- add new feature"));
assert!(content.contains("### Fixed"));
assert!(content.contains("- resolve bug"));
assert!(
!content.contains("\n\n\n"),
"New changelog should not have double blank lines"
);
assert!(
!content.ends_with("\n\n\n"),
"New changelog should not end with double blank lines"
);
assert!(
!content.ends_with("\n\n"),
"New changelog should not end with a blank line (should end with single newline after last list item)"
);
}
#[test]
fn test_new_changelog_file_has_no_double_blank_lines_at_end() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec!["feat: first feature".to_string()];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(
content.ends_with("- first feature\n"),
"New changelog should end with list item + single newline, but got: {:?}",
&content[content.len().saturating_sub(30)..]
);
assert!(
!content.contains("\n\n\n"),
"New changelog should not contain double blank lines"
);
let trailing_newlines = content.chars().rev().take_while(|&c| c == '\n').count();
assert_eq!(
trailing_newlines, 1,
"New changelog should end with exactly 1 newline, found {}",
trailing_newlines
);
}
#[test]
fn test_generate_changelog_entry_updates_existing_file() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version1 = Version::parse("1.0.0").unwrap();
let commits1 = vec!["feat: initial feature".to_string()];
generate_changelog_entry_at_path(&version1, &commits1, &changelog_path).unwrap();
let version2 = Version::parse("1.1.0").unwrap();
let commits2 = vec!["feat: another feature".to_string()];
generate_changelog_entry_at_path(&version2, &commits2, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
let v1_pos = content.find("## [1.0.0]").unwrap();
let v2_pos = content.find("## [1.1.0]").unwrap();
assert!(v2_pos < v1_pos, "Newer version should appear first");
assert!(content.contains("- initial feature"));
assert!(content.contains("- another feature"));
}
#[test]
fn test_generate_changelog_entry_with_breaking_changes() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("2.0.0").unwrap();
let commits = vec![
"feat!: breaking change".to_string(),
"fix: normal fix".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("### Changed"));
assert!(content.contains("**BREAKING:** breaking change"));
assert!(content.contains("### Fixed"));
assert!(content.contains("- normal fix"));
}
#[test]
fn test_generate_changelog_entry_with_scopes() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec![
"feat(api): add new endpoint".to_string(),
"fix(ui): correct button alignment".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("- add new endpoint"));
assert!(content.contains("- correct button alignment"));
}
#[test]
fn test_generate_changelog_entry_skips_version_bump_commits() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec![
"feat: add feature".to_string(),
"chore: bump version to 0.9.0".to_string(),
"chore: sync package version".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("- add feature"));
assert!(!content.contains("bump version"));
assert!(!content.contains("sync package"));
}
#[test]
fn test_generate_changelog_entry_groups_by_category() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec![
"fix: bug 1".to_string(),
"feat: feature 1".to_string(),
"fix: bug 2".to_string(),
"feat: feature 2".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
let added_pos = content.find("### Added").unwrap();
let feature1_pos = content.find("- feature 1").unwrap();
let feature2_pos = content.find("- feature 2").unwrap();
assert!(added_pos < feature1_pos);
assert!(added_pos < feature2_pos);
let fixed_pos = content.find("### Fixed").unwrap();
let bug1_pos = content.find("- bug 1").unwrap();
let bug2_pos = content.find("- bug 2").unwrap();
assert!(fixed_pos < bug1_pos);
assert!(fixed_pos < bug2_pos);
}
#[test]
fn test_generate_changelog_entry_with_non_conventional_commits() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec![
"feat: proper feature".to_string(),
"Some random commit message".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("### Added"));
assert!(content.contains("- proper feature"));
assert!(content.contains("### Changed"));
assert!(content.contains("Some random commit message"));
}
#[test]
fn test_generate_changelog_entry_multiple_categories() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec![
"feat: new feature".to_string(),
"fix: bug fix".to_string(),
"perf: performance improvement".to_string(),
"refactor: code refactor".to_string(),
"revert: revert change".to_string(),
"security: security fix".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("### Added"));
assert!(content.contains("### Changed"));
assert!(content.contains("### Removed"));
assert!(content.contains("### Fixed"));
assert!(content.contains("### Security"));
let added_pos = content.find("### Added").unwrap();
let changed_pos = content.find("### Changed").unwrap();
let removed_pos = content.find("### Removed").unwrap();
let fixed_pos = content.find("### Fixed").unwrap();
let security_pos = content.find("### Security").unwrap();
assert!(added_pos < changed_pos);
assert!(changed_pos < removed_pos);
assert!(removed_pos < fixed_pos);
assert!(fixed_pos < security_pos);
}
#[test]
fn test_generate_changelog_entry_markdown_lint_compliance() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec![
"feat: add new feature".to_string(),
"fix: resolve bug".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("### Added\n\n- add new feature\n\n"));
assert!(content.contains("\n\n### Fixed\n\n"));
assert!(content.contains("- resolve bug\n"));
assert!(
!content.contains("\n\n\n"),
"Found triple newlines (double blank lines)"
);
for line in content.lines() {
assert!(
!line.ends_with(' '),
"Found trailing whitespace on line: {}",
line
);
}
}
#[test]
fn test_generate_changelog_entry_proper_spacing_between_releases() {
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version1 = Version::parse("1.0.0").unwrap();
let commits1 = vec!["feat: initial feature".to_string()];
generate_changelog_entry_at_path(&version1, &commits1, &changelog_path).unwrap();
let version2 = Version::parse("1.1.0").unwrap();
let commits2 = vec!["fix: bug fix".to_string()];
generate_changelog_entry_at_path(&version2, &commits2, &changelog_path).unwrap();
let version3 = Version::parse("1.2.0").unwrap();
let commits3 = vec!["feat: another feature".to_string()];
generate_changelog_entry_at_path(&version3, &commits3, &changelog_path).unwrap();
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(
content.contains("- another feature\n\n## [1.1.0]"),
"Missing blank line between [1.2.0] and [1.1.0]"
);
assert!(
content.contains("- bug fix\n\n## [1.0.0]"),
"Missing blank line between [1.1.0] and [1.0.0]"
);
assert!(
!content.contains("\n\n\n"),
"Found double blank line (triple newlines) in changelog"
);
let v3_pos = content.find("## [1.2.0]").unwrap();
let v2_pos = content.find("## [1.1.0]").unwrap();
let v1_pos = content.find("## [1.0.0]").unwrap();
assert!(
v3_pos < v2_pos && v2_pos < v1_pos,
"Versions not in descending order"
);
}
#[test]
fn test_markdown_linter_if_available() {
use std::process::Command;
if std::env::var("CI").is_err() && std::env::var("RUN_MARKDOWN_LINT").is_err() {
println!("⚠ Skipping markdown linter test (run with RUN_MARKDOWN_LINT=1 to enable)");
return;
}
let temp_dir = TempDir::new().unwrap();
let changelog_path = temp_dir.path().join("CHANGELOG.md");
let version = Version::parse("1.0.0").unwrap();
let commits = vec![
"feat: add new feature".to_string(),
"fix: resolve bug".to_string(),
"refactor: improve code".to_string(),
];
generate_changelog_entry_at_path(&version, &commits, &changelog_path).unwrap();
let result = Command::new("npx")
.args(["markdownlint-cli", changelog_path.to_str().unwrap()])
.output();
match result {
Ok(output) => {
if output.status.success() {
println!("✓ Markdown linter passed!");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
panic!(
"Markdown linter failed!\nstdout: {}\nstderr: {}",
stdout, stderr
);
}
}
Err(e) => {
panic!("markdownlint-cli not available but required in CI: {}", e);
}
}
}
}