const PREAMBLE: &str = "# Changelog\n\n\
All notable changes to this project are documented in this file.\n\n\
The format follows \
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project \
adheres to \
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n";
#[derive(
Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize,
)]
#[non_exhaustive]
pub struct Changelog {
pub configuration: crate::Configuration,
#[serde(default)]
pub contributors: std::collections::BTreeMap<String, crate::Contributor>,
pub introduction: Option<String>,
pub references: std::collections::BTreeMap<String, String>,
pub sections: Vec<crate::Section>,
}
impl Changelog {
pub fn to_ron(&self) -> sysexits::Result<String> {
let pretty = ron::ser::PrettyConfig::new().indentor(" ".to_owned());
match ron::ser::to_string_pretty(self, pretty) {
Ok(body) => Ok(format!("{body}\n")),
Err(reason) => {
eprintln!(
"git-harvest: cannot serialise the CHANGELOG: {reason}"
);
Err(sysexits::ExitCode::Software)
}
}
}
#[must_use]
pub fn to_markdown(&self) -> String {
let mut out = String::from(PREAMBLE);
let mut references = self.references.clone();
if let Some(lead) = &self.introduction {
out.push_str(lead);
out.push_str("\n\n");
}
for section in &self.sections {
if section.released.is_some() {
out.push_str(§ion_markdown(
&self.configuration,
section,
&self.contributors,
&mut references,
));
}
}
for section in &self.sections {
if section.released.is_some() {
for (label, url) in §ion.references {
references
.entry(label.clone())
.or_insert_with(|| url.clone());
}
}
}
out.push_str(&reference_block(&references));
format!("{}\n", out.trim_end())
}
}
type Registry = std::collections::BTreeMap<String, crate::Contributor>;
type References = std::collections::BTreeMap<String, String>;
fn credit_label(alias: &str, contributors: &Registry) -> String {
if !alias.contains('@') {
return format!("@{alias}");
}
contributors
.get(alias)
.and_then(crate::Contributor::primary_name)
.map_or_else(|| alias.to_owned(), ToOwned::to_owned)
}
fn credit_token(
alias: &str,
contributors: &Registry,
references: &mut References,
) -> String {
let label = credit_label(alias, contributors);
contributors
.get(alias)
.and_then(crate::Contributor::primary_url)
.map_or_else(
|| label.clone(),
|url| {
references
.entry(label.clone())
.or_insert_with(|| url.to_owned());
format!("[{label}]")
},
)
}
fn ordered_buckets<'a>(
configuration: &'a crate::Configuration,
section: &'a crate::Section,
) -> Vec<&'a str> {
let mut seen = std::collections::BTreeSet::new();
let mut order = Vec::new();
for bucket in &configuration.buckets {
if section.changes.contains_key(bucket) {
order.push(bucket.as_str());
seen.insert(bucket.as_str());
}
}
for bucket in section.changes.keys() {
if !seen.contains(bucket.as_str()) {
order.push(bucket.as_str());
}
}
order
}
fn reference_block(references: &References) -> String {
let mut out = String::new();
for (label, url) in references {
out.push('[');
out.push_str(label);
out.push_str("]: ");
out.push_str(url);
out.push('\n');
}
out
}
fn bucket_markdown(
bucket: &str,
entries: &[crate::Entry],
contributors: &Registry,
references: &mut References,
) -> String {
if entries.is_empty() {
return String::new();
}
let mut out = format!("### {bucket}\n\n");
for entry in entries {
out.push_str("- ");
out.push_str(entry.text());
if !entry.aliases.is_empty() {
let credit: Vec<String> = entry
.aliases
.iter()
.map(|alias| credit_token(alias, contributors, references))
.collect();
out.push_str(" (");
out.push_str(&credit.join(", "));
out.push(')');
}
out.push('\n');
}
out.push('\n');
out
}
fn contributors_markdown(
order: &[&str],
section: &crate::Section,
contributors: &Registry,
references: &mut References,
) -> String {
let mut credited = indexmap::IndexSet::new();
for bucket in order {
for entry in §ion.changes[*bucket] {
for alias in &entry.aliases {
credited.insert(alias.clone());
}
}
}
if credited.is_empty() {
return String::new();
}
let mut out = String::from("### Contributors\n\n");
for alias in &credited {
out.push_str("- ");
out.push_str(&credit_token(alias, contributors, references));
if !alias.contains('@')
&& let Some(name) = contributors
.get(alias)
.and_then(crate::Contributor::primary_name)
{
out.push_str(" — ");
out.push_str(name);
}
out.push('\n');
}
out.push('\n');
out
}
fn section_markdown(
configuration: &crate::Configuration,
section: &crate::Section,
contributors: &Registry,
references: &mut References,
) -> String {
let date = section
.released
.map(|moment| moment.format("%Y-%m-%d").to_string())
.unwrap_or_default();
let mut out = format!(
"## [{}.{}.{}] - {date}\n\n",
section.version.major, section.version.minor, section.version.patch,
);
if let Some(lead) = §ion.introduction {
out.push_str(lead);
out.push_str("\n\n");
}
let order = ordered_buckets(configuration, section);
for bucket in &order {
out.push_str(&bucket_markdown(
bucket,
§ion.changes[*bucket],
contributors,
references,
));
}
out.push_str(&contributors_markdown(
&order,
section,
contributors,
references,
));
out
}