use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::commit::Commit;
use super::version::SemanticVersion;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Changelog {
pub entries: Vec<ChangelogEntry>,
pub version: SemanticVersion,
pub released_at: Option<DateTime<Utc>>,
}
impl Changelog {
#[must_use]
pub const fn new(version: SemanticVersion) -> Self {
Self {
entries: Vec::new(),
version,
released_at: None,
}
}
pub fn add_entry(&mut self, entry: ChangelogEntry) {
self.entries.push(entry);
}
#[must_use]
pub fn entries_by_section(&self, section: ChangelogSection) -> Vec<&ChangelogEntry> {
self.entries
.iter()
.filter(|e| e.section == section)
.collect()
}
#[must_use]
pub fn breaking_changes(&self) -> Vec<&ChangelogEntry> {
self.entries_by_section(ChangelogSection::Breaking)
}
#[must_use]
pub fn features(&self) -> Vec<&ChangelogEntry> {
self.entries_by_section(ChangelogSection::Added)
}
#[must_use]
pub fn fixes(&self) -> Vec<&ChangelogEntry> {
self.entries_by_section(ChangelogSection::Fixed)
}
#[must_use]
pub fn format_keep_a_changelog(&self) -> String {
use std::fmt::Write;
let mut output = String::new();
writeln!(output, "## [{}]", self.version).unwrap();
if let Some(date) = &self.released_at {
writeln!(output, " - {}", date.format("%Y-%m-%d")).unwrap();
}
writeln!(output).unwrap();
for section in ChangelogSection::all() {
let entries = self.entries_by_section(section);
if !entries.is_empty() {
writeln!(output, "\n### {}\n", section.title()).unwrap();
for entry in entries {
output.push_str(&entry.format_markdown());
}
}
}
output
}
pub fn format_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangelogSection {
Breaking,
Added,
Fixed,
Changed,
Deprecated,
Removed,
Security,
}
impl ChangelogSection {
#[must_use]
pub fn all() -> Vec<Self> {
vec![
Self::Breaking,
Self::Added,
Self::Fixed,
Self::Changed,
Self::Deprecated,
Self::Removed,
Self::Security,
]
}
#[must_use]
pub const fn title(self) -> &'static str {
match self {
Self::Breaking => "Breaking Changes",
Self::Added => "Added",
Self::Fixed => "Fixed",
Self::Changed => "Changed",
Self::Deprecated => "Deprecated",
Self::Removed => "Removed",
Self::Security => "Security",
}
}
#[must_use]
pub const fn order(self) -> usize {
match self {
Self::Breaking => 0,
Self::Added => 1,
Self::Fixed => 2,
Self::Changed => 3,
Self::Deprecated => 4,
Self::Removed => 5,
Self::Security => 6,
}
}
#[must_use]
pub fn from_commit_type(commit_type: &str, breaking: bool) -> Option<Self> {
if breaking {
return Some(Self::Breaking);
}
match commit_type.to_lowercase().as_str() {
"feat" => Some(Self::Added),
"fix" => Some(Self::Fixed),
"refactor" | "perf" => Some(Self::Changed),
"deprecated" => Some(Self::Deprecated),
"removed" => Some(Self::Removed),
"security" => Some(Self::Security),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogEntry {
pub section: ChangelogSection,
pub message: String,
pub scope: Option<String>,
pub commit_hash: Option<String>,
pub affected_crates: Vec<String>,
}
impl ChangelogEntry {
#[must_use]
pub const fn new(section: ChangelogSection, message: String) -> Self {
Self {
section,
message,
scope: None,
commit_hash: None,
affected_crates: Vec::new(),
}
}
#[must_use]
pub fn from_commit(commit: &Commit) -> Option<Self> {
let commit_type = commit.commit_type?;
let section =
ChangelogSection::from_commit_type(&commit_type.to_string(), commit.breaking)?;
let mut entry = Self::new(section, commit.short_message().to_string());
entry.scope.clone_from(&commit.scope);
entry.commit_hash = Some(commit.short_hash.clone());
Some(entry)
}
#[must_use]
pub fn with_scope(mut self, scope: String) -> Self {
self.scope = Some(scope);
self
}
#[must_use]
pub fn with_commit_hash(mut self, hash: String) -> Self {
self.commit_hash = Some(hash);
self
}
#[must_use]
pub fn with_affected_crate(mut self, krate: String) -> Self {
self.affected_crates.push(krate);
self
}
#[must_use]
pub fn format_markdown(&self) -> String {
use std::fmt::Write;
let mut output = String::from("- ");
if let Some(scope) = &self.scope {
write!(output, "**{scope}**: ").unwrap();
}
output.push_str(&self.message);
if let Some(hash) = &self.commit_hash {
write!(output, " ({hash})").unwrap();
if !self.affected_crates.is_empty() {
write!(output, " in {}", self.affected_crates.join(", ")).unwrap();
}
}
writeln!(output).unwrap();
output
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogConfig {
pub format: ChangelogFormat,
pub path: String,
pub incremental: bool,
pub sections: Vec<ChangelogSectionConfig>,
pub exclude_types: Vec<String>,
pub exclude_scopes: Vec<String>,
}
impl Default for ChangelogConfig {
fn default() -> Self {
Self {
format: ChangelogFormat::KeepAChangelog,
path: "CHANGELOG.md".to_string(),
incremental: true,
sections: ChangelogSectionConfig::default_all(),
exclude_types: vec![
"docs".to_string(),
"test".to_string(),
"chore".to_string(),
"style".to_string(),
"ci".to_string(),
"build".to_string(),
],
exclude_scopes: vec!["internal".to_string(), "deps".to_string()],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ChangelogFormat {
KeepAChangelog,
GitHubReleases,
Json,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogSectionConfig {
pub section: ChangelogSection,
pub title: String,
pub order: usize,
pub commit_types: Vec<String>,
}
impl ChangelogSectionConfig {
#[must_use]
pub fn default_all() -> Vec<Self> {
vec![
Self {
section: ChangelogSection::Breaking,
title: "Breaking Changes".to_string(),
order: 0,
commit_types: vec!["feat!".to_string()],
},
Self {
section: ChangelogSection::Added,
title: "Added".to_string(),
order: 1,
commit_types: vec!["feat".to_string()],
},
Self {
section: ChangelogSection::Fixed,
title: "Fixed".to_string(),
order: 2,
commit_types: vec!["fix".to_string()],
},
Self {
section: ChangelogSection::Changed,
title: "Changed".to_string(),
order: 3,
commit_types: vec!["refactor".to_string(), "perf".to_string()],
},
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::commit::Commit;
#[test]
fn test_changelog_entry_from_commit() {
let commit = Commit::new(
"abc123".to_string(),
"feat: add new feature".to_string(),
"Author".to_string(),
"author@example.com".to_string(),
Utc::now(),
);
let entry = ChangelogEntry::from_commit(&commit);
assert!(entry.is_some());
let entry = entry.unwrap();
assert_eq!(entry.section, ChangelogSection::Added);
assert_eq!(entry.message, "feat: add new feature");
}
#[test]
fn test_changelog_format() {
let mut changelog = Changelog::new(SemanticVersion::parse("1.0.0").unwrap());
changelog.released_at = Some(Utc::now());
let mut entry =
ChangelogEntry::new(ChangelogSection::Added, "Add cool feature".to_string());
entry.scope = Some("api".to_string());
entry.commit_hash = Some("abc123".to_string());
changelog.add_entry(entry);
let formatted = changelog.format_keep_a_changelog();
assert!(formatted.contains("## [1.0.0]"));
assert!(formatted.contains("### Added"));
assert!(formatted.contains("**api**"));
}
#[test]
fn test_changelog_sections() {
let section = ChangelogSection::from_commit_type("feat", false);
assert_eq!(section, Some(ChangelogSection::Added));
let section = ChangelogSection::from_commit_type("feat", true);
assert_eq!(section, Some(ChangelogSection::Breaking));
let section = ChangelogSection::from_commit_type("fix", false);
assert_eq!(section, Some(ChangelogSection::Fixed));
}
}