#![cfg_attr(coverage_nightly, coverage(off))]
use anyhow::{Context, Result};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq)]
pub enum ChangeCategory {
Added,
Changed,
Deprecated,
Removed,
Fixed,
Security,
}
impl ChangeCategory {
pub fn from_labels(labels: &[String]) -> Option<Self> {
for label in labels {
let lower = label.to_lowercase();
if lower.contains("feature") || lower.contains("enhancement") {
return Some(ChangeCategory::Added);
}
if lower.contains("bug") || lower.contains("fix") {
return Some(ChangeCategory::Fixed);
}
if lower.contains("security") {
return Some(ChangeCategory::Security);
}
if lower.contains("breaking") || lower.contains("change") {
return Some(ChangeCategory::Changed);
}
if lower.contains("deprecat") {
return Some(ChangeCategory::Deprecated);
}
if lower.contains("removal") {
return Some(ChangeCategory::Removed);
}
}
None
}
pub fn section_header(&self) -> &'static str {
match self {
ChangeCategory::Added => "### Added",
ChangeCategory::Changed => "### Changed",
ChangeCategory::Deprecated => "### Deprecated",
ChangeCategory::Removed => "### Removed",
ChangeCategory::Fixed => "### Fixed",
ChangeCategory::Security => "### Security",
}
}
}
#[derive(Debug, Clone)]
pub struct ChangelogEntry {
pub category: ChangeCategory,
pub description: String,
pub issue_number: Option<u64>,
}
impl ChangelogEntry {
pub fn new(category: ChangeCategory, description: String, issue_number: Option<u64>) -> Self {
Self {
category,
description,
issue_number,
}
}
pub fn to_markdown(&self) -> String {
if let Some(issue) = self.issue_number {
format!("- {} (#{})", self.description, issue)
} else {
format!("- {}", self.description)
}
}
}
pub fn add_to_changelog(project_path: &PathBuf, entry: ChangelogEntry) -> Result<()> {
let changelog_path = project_path.join("CHANGELOG.md");
if !changelog_path.exists() {
create_changelog(&changelog_path)?;
}
let content = fs::read_to_string(&changelog_path).context("Failed to read CHANGELOG.md")?;
let updated_content = insert_entry(&content, &entry)?;
fs::write(&changelog_path, updated_content).context("Failed to write CHANGELOG.md")?;
Ok(())
}
fn create_changelog(path: &PathBuf) -> Result<()> {
let template = 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).
## [Unreleased]
### Added
### Changed
### Deprecated
### Removed
### Fixed
### Security
"#;
fs::write(path, template)?;
Ok(())
}
fn is_unreleased_header(line: &str) -> bool {
line.starts_with("## [Unreleased]")
}
fn is_version_header(line: &str) -> bool {
line.starts_with("## [") && !is_unreleased_header(line)
}
fn is_section_boundary(line: &str) -> bool {
line.starts_with("### ") || line.starts_with("## ")
}
fn insert_entry(content: &str, entry: &ChangelogEntry) -> Result<String> {
let lines: Vec<&str> = content.lines().collect();
let mut result = Vec::new();
let mut in_unreleased = false;
let mut in_target_section = false;
let mut inserted = false;
let section_header = entry.category.section_header();
for line in lines.iter() {
if is_unreleased_header(line) {
in_unreleased = true;
result.push(line.to_string());
continue;
}
if in_unreleased && is_version_header(line) {
in_unreleased = false;
}
if in_unreleased && line.starts_with(section_header) {
in_target_section = true;
result.push(line.to_string());
continue;
}
if in_target_section && is_section_boundary(line) {
in_target_section = false;
}
if in_target_section && !inserted && !line.trim().is_empty() {
result.push(entry.to_markdown());
inserted = true;
}
result.push(line.to_string());
}
if !inserted {
result.push(entry.to_markdown());
}
Ok(result.join("\n"))
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_category_from_labels() {
assert_eq!(
ChangeCategory::from_labels(&["feature".to_string()]),
Some(ChangeCategory::Added)
);
assert_eq!(
ChangeCategory::from_labels(&["bug".to_string()]),
Some(ChangeCategory::Fixed)
);
assert_eq!(
ChangeCategory::from_labels(&["security".to_string()]),
Some(ChangeCategory::Security)
);
}
#[test]
fn test_entry_to_markdown() {
let entry =
ChangelogEntry::new(ChangeCategory::Added, "New feature".to_string(), Some(123));
assert_eq!(entry.to_markdown(), "- New feature (#123)");
let entry2 = ChangelogEntry::new(ChangeCategory::Fixed, "Bug fix".to_string(), None);
assert_eq!(entry2.to_markdown(), "- Bug fix");
}
#[test]
fn test_create_changelog() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("CHANGELOG.md");
create_changelog(&path).unwrap();
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("## [Unreleased]"));
assert!(content.contains("### Added"));
assert!(content.contains("### Fixed"));
}
#[test]
fn test_add_to_changelog() {
let temp = TempDir::new().unwrap();
let project_path = temp.path().to_path_buf();
let entry =
ChangelogEntry::new(ChangeCategory::Added, "Test feature".to_string(), Some(42));
add_to_changelog(&project_path, entry.clone()).unwrap();
let changelog_path = project_path.join("CHANGELOG.md");
assert!(changelog_path.exists());
let content = fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("- Test feature (#42)"));
}
#[test]
fn test_insert_entry() {
let content = r#"# Changelog
## [Unreleased]
### Added
### Fixed
"#;
let entry = ChangelogEntry::new(ChangeCategory::Added, "New feature".to_string(), Some(10));
let result = insert_entry(content, &entry).unwrap();
assert!(result.contains("- New feature (#10)"));
}
#[test]
fn test_multiple_entries_same_section() {
let temp = TempDir::new().unwrap();
let project_path = temp.path().to_path_buf();
let entry1 = ChangelogEntry::new(ChangeCategory::Fixed, "Fix bug 1".to_string(), Some(1));
let entry2 = ChangelogEntry::new(ChangeCategory::Fixed, "Fix bug 2".to_string(), Some(2));
add_to_changelog(&project_path, entry1).unwrap();
add_to_changelog(&project_path, entry2).unwrap();
let content = fs::read_to_string(project_path.join("CHANGELOG.md")).unwrap();
assert!(content.contains("- Fix bug 1 (#1)"));
assert!(content.contains("- Fix bug 2 (#2)"));
}
}