use crate::changelog::{Changelog, ChangelogEntry, ChangelogSection, SectionType};
use crate::config::ChangelogConfig;
use std::collections::HashMap;
#[derive(Debug)]
pub struct KeepAChangelogFormatter<'a> {
config: &'a ChangelogConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[allow(dead_code)]
pub(crate) enum KeepAChangelogSection {
Added,
Changed,
Deprecated,
Removed,
Fixed,
Security,
}
impl KeepAChangelogSection {
pub(crate) fn title(&self) -> &str {
match self {
KeepAChangelogSection::Added => "Added",
KeepAChangelogSection::Changed => "Changed",
KeepAChangelogSection::Deprecated => "Deprecated",
KeepAChangelogSection::Removed => "Removed",
KeepAChangelogSection::Fixed => "Fixed",
KeepAChangelogSection::Security => "Security",
}
}
pub(crate) fn priority(&self) -> u8 {
match self {
KeepAChangelogSection::Added => 0,
KeepAChangelogSection::Changed => 1,
KeepAChangelogSection::Deprecated => 2,
KeepAChangelogSection::Removed => 3,
KeepAChangelogSection::Fixed => 4,
KeepAChangelogSection::Security => 5,
}
}
}
impl<'a> KeepAChangelogFormatter<'a> {
#[must_use]
pub fn new(config: &'a ChangelogConfig) -> Self {
Self { config }
}
#[must_use]
pub fn format(&self, changelog: &Changelog) -> String {
let mut output = String::new();
output.push_str(&self.format_version_header(changelog));
output.push_str("\n\n");
let grouped_sections = self.group_sections(&changelog.sections);
let mut sections: Vec<_> = grouped_sections.into_iter().collect();
sections.sort_by_key(|(section, _)| section.priority());
for (keep_section, entries) in sections {
if !entries.is_empty() {
output.push_str(&self.format_section(&keep_section, &entries));
output.push('\n');
}
}
output
}
pub(crate) fn format_version_header(&self, changelog: &Changelog) -> String {
let date_str = changelog.date.format("%Y-%m-%d").to_string();
if self.config.template.version_header.contains("{version}")
&& self.config.template.version_header.contains("{date}")
{
self.config
.template
.version_header
.replace("{version}", &changelog.version)
.replace("{date}", &date_str)
} else {
format!("## [{}] - {}", changelog.version, date_str)
}
}
pub(crate) fn group_sections<'b>(
&self,
sections: &'b [ChangelogSection],
) -> HashMap<KeepAChangelogSection, Vec<&'b ChangelogEntry>> {
let mut grouped: HashMap<KeepAChangelogSection, Vec<&ChangelogEntry>> = HashMap::new();
for section in sections {
let keep_section = self.map_section_type(§ion.section_type);
for entry in §ion.entries {
grouped.entry(keep_section).or_default().push(entry);
}
}
grouped
}
pub(crate) fn map_section_type(&self, section_type: &SectionType) -> KeepAChangelogSection {
match section_type {
SectionType::Features => KeepAChangelogSection::Added,
SectionType::Fixes => KeepAChangelogSection::Fixed,
SectionType::Deprecations => KeepAChangelogSection::Deprecated,
SectionType::Breaking => KeepAChangelogSection::Changed,
SectionType::Performance => KeepAChangelogSection::Changed,
SectionType::Refactoring => KeepAChangelogSection::Changed,
SectionType::Documentation => KeepAChangelogSection::Changed,
SectionType::Build => KeepAChangelogSection::Changed,
SectionType::CI => KeepAChangelogSection::Changed,
SectionType::Tests => KeepAChangelogSection::Changed,
SectionType::Other => KeepAChangelogSection::Changed,
}
}
pub(crate) fn format_section(
&self,
section: &KeepAChangelogSection,
entries: &[&ChangelogEntry],
) -> String {
let mut output = String::new();
output.push_str(&format!("### {}\n\n", section.title()));
for entry in entries {
output.push_str(&self.format_entry(entry));
output.push('\n');
}
output
}
pub(crate) fn format_entry(&self, entry: &ChangelogEntry) -> String {
let mut output = String::from("- ");
if entry.breaking {
output.push_str("**BREAKING**: ");
}
output.push_str(&entry.description);
if self.config.include_commit_links {
output.push(' ');
if let Some(ref repo_url) = self.config.repository_url {
let commit_link = self.format_commit_link(entry, repo_url);
output.push_str(&commit_link);
} else {
output.push_str(&format!("({})", entry.short_hash));
}
}
if self.config.include_issue_links && !entry.references.is_empty() {
output.push(' ');
if let Some(ref repo_url) = self.config.repository_url {
let issue_links = self.format_issue_links(entry, repo_url);
output.push_str(&format!("({})", issue_links.join(", ")));
} else {
let refs = entry.references.join(", ");
output.push_str(&format!("({})", refs));
}
}
if self.config.include_authors && !entry.author.is_empty() {
output.push_str(&format!(" by {}", entry.author));
}
output
}
pub(crate) fn format_commit_link(&self, entry: &ChangelogEntry, base_url: &str) -> String {
let url = base_url.trim_end_matches('/');
format!("[{}]({}/commit/{})", entry.short_hash, url, entry.commit_hash)
}
pub(crate) fn format_issue_links(&self, entry: &ChangelogEntry, base_url: &str) -> Vec<String> {
let url = base_url.trim_end_matches('/');
entry
.references
.iter()
.map(|ref_| {
let issue_num = ref_.trim_start_matches('#');
format!("[{}]({}/issues/{})", ref_, url, issue_num)
})
.collect()
}
#[must_use]
pub fn format_header(&self) -> String {
if self.config.template.header.contains("Keep a Changelog")
|| self.config.template.header.contains("keepachangelog")
{
self.config.template.header.clone()
} else {
String::from(
"# Changelog\n\n\
All notable changes to this project will be documented in this file.\n\n\
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n\
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n",
)
}
}
#[must_use]
pub fn format_complete(&self, changelogs: &[Changelog]) -> String {
let mut output = self.format_header();
output.push_str("## [Unreleased]\n\n");
for changelog in changelogs {
output.push_str(&self.format(changelog));
}
output
}
}