use crate::changelog::{Changelog, ChangelogEntry, ChangelogSection, SectionType};
use crate::config::ChangelogConfig;
use std::collections::HashMap;
#[derive(Debug)]
pub struct ConventionalCommitsFormatter<'a> {
config: &'a ChangelogConfig,
}
impl<'a> ConventionalCommitsFormatter<'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_type, _)| section_type.priority());
for (section_type, entries) in sections {
if !entries.is_empty() {
output.push_str(&self.format_section(§ion_type, &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<SectionType, Vec<&'b ChangelogEntry>> {
let mut grouped: HashMap<SectionType, Vec<&ChangelogEntry>> = HashMap::new();
for section in sections {
for entry in §ion.entries {
grouped.entry(section.section_type).or_default().push(entry);
}
}
grouped
}
pub(crate) fn format_section(
&self,
section_type: &SectionType,
entries: &[&ChangelogEntry],
) -> String {
let mut output = String::new();
let title = self.get_section_title(section_type);
let section_header = self.config.template.section_header.replace("{section}", &title);
output.push_str(§ion_header);
output.push_str("\n\n");
for entry in entries {
output.push_str(&self.format_entry(entry));
output.push('\n');
}
output
}
pub(crate) fn get_section_title(&self, section_type: &SectionType) -> String {
if *section_type == SectionType::Breaking {
return self.config.conventional.breaking_section.clone();
}
let commit_type = self.section_type_to_commit_type(section_type);
if let Some(custom_title) = self.config.conventional.types.get(&commit_type) {
return custom_title.clone();
}
section_type.title().to_string()
}
pub(crate) fn section_type_to_commit_type(&self, section_type: &SectionType) -> String {
match section_type {
SectionType::Breaking => "breaking".to_string(),
SectionType::Features => "feat".to_string(),
SectionType::Fixes => "fix".to_string(),
SectionType::Performance => "perf".to_string(),
SectionType::Deprecations => "deprecate".to_string(),
SectionType::Documentation => "docs".to_string(),
SectionType::Refactoring => "refactor".to_string(),
SectionType::Build => "build".to_string(),
SectionType::CI => "ci".to_string(),
SectionType::Tests => "test".to_string(),
SectionType::Other => "chore".to_string(),
}
}
pub(crate) fn format_entry(&self, entry: &ChangelogEntry) -> String {
let mut output = String::from("- ");
if let Some(ref scope) = entry.scope {
output.push_str(&format!("**{}**: ", scope));
}
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("Conventional Commits")
|| self.config.template.header.contains("conventional")
{
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 [Conventional Commits](https://www.conventionalcommits.org/),\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
}
}