use anyhow::Context; use chrono::{DateTime, NaiveDate, Utc}; use git_cliff_core::{ changelog::Changelog, commit::Commit, config::{ChangelogConfig, CommitParser, Config, GitConfig}, release::Release, }; use regex::Regex; pub struct ChangeLogBuilder { commits: Vec, version: String, config: Option, release_date: Option, release_link: Option, } impl ChangeLogBuilder { pub fn new(commits: C, version: impl Into) -> Self where C: IntoIterator, C::Item: AsRef, { Self { commits: commits .into_iter() .map(|s| s.as_ref().to_string()) .collect(), version: version.into(), config: None, release_date: None, release_link: None, } } pub fn with_release_date(self, release_date: NaiveDate) -> Self { Self { release_date: Some(release_date), ..self } } pub fn with_release_link(self, release_link: impl Into) -> Self { Self { release_link: Some(release_link.into()), ..self } } pub fn with_config(self, config: Config) -> Self { Self { config: Some(config), ..self } } pub fn build<'a>(self) -> ChangeLog<'a> { let git_config = self .config .clone() .map(|c| c.git) .unwrap_or_else(default_git_config); let timestamp = self.release_timestamp(); let commits = self .commits .clone() .into_iter() .map(|c| Commit::new("id".into(), c)) .filter_map(|c| c.process(&git_config).ok()) .collect(); ChangeLog { release: Release { version: Some(self.version), commits, commit_id: None, timestamp, previous: None, }, config: self.config, release_link: self.release_link, } } fn release_timestamp(&self) -> i64 { self.release_date .and_then(|date| date.and_hms_opt(0, 0, 0)) .map(|d| DateTime::::from_utc(d, Utc)) .unwrap_or_else(Utc::now) .timestamp() } } pub struct ChangeLog<'a> { release: Release<'a>, config: Option, release_link: Option, } impl ChangeLog<'_> { pub fn generate(&self) -> anyhow::Result { let config = self.config.clone().unwrap_or_else(|| self.default_config()); let changelog = Changelog::new(vec![self.release.clone()], &config)?; let mut buffer = Vec::new(); changelog .generate(&mut buffer) .context("failed to generate changelog")?; String::from_utf8(buffer) .context("cannot convert bytes to string (contains non utf-8 char indices)") } pub fn prepend(self, old_changelog: impl Into) -> anyhow::Result { let old_changelog = old_changelog.into(); if let Ok(Some(last_version)) = changelog_parser::last_version_from_str(&old_changelog) { let next_version = self .release .version .as_ref() .context("current release contains no version")?; if next_version == &last_version { return Ok(old_changelog); } } let old_header = changelog_parser::parse_header(&old_changelog); let config = self .config .clone() .unwrap_or_else(|| self.default_config_with_header(old_header)); let changelog = Changelog::new(vec![self.release], &config)?; let mut out = Vec::new(); changelog.prepend(old_changelog, &mut out)?; String::from_utf8(out) .context("cannot convert bytes to string (contains non utf-8 char indices)") } fn default_config(&self) -> Config { let config = Config { changelog: default_changelog_config(None, self.release_link.as_deref()), git: default_git_config(), }; config } fn default_config_with_header(&self, header: Option) -> Config { let config = Config { changelog: default_changelog_config(header, self.release_link.as_deref()), git: default_git_config(), }; config } } fn default_git_config() -> GitConfig { GitConfig { conventional_commits: Some(true), filter_unconventional: Some(false), filter_commits: Some(true), commit_parsers: Some(default_commit_parsers()), ..Default::default() } } fn default_commit_parsers() -> Vec { fn create_commit_parser(message: &str, group: &str) -> CommitParser { CommitParser { message: Regex::new(&format!("^{message}")).ok(), body: None, group: Some(group.into()), default_scope: None, scope: None, skip: None, } } vec![ create_commit_parser("feat", "added"), create_commit_parser("changed", "changed"), create_commit_parser("deprecated", "deprecated"), create_commit_parser("removed", "removed"), create_commit_parser("fix", "fixed"), create_commit_parser("security", "security"), create_commit_parser("docs", "docs"), CommitParser { message: Regex::new(".*").ok(), group: Some(String::from("other")), body: None, default_scope: None, skip: None, scope: None, }, ] } const CHANGELOG_HEADER: &str = 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] "#; fn default_changelog_config(header: Option, release_link: Option<&str>) -> ChangelogConfig { ChangelogConfig { header: Some(header.unwrap_or(String::from(CHANGELOG_HEADER))), body: Some(default_changelog_body_config(release_link)), footer: None, trim: Some(true), } } fn default_changelog_body_config(release_link: Option<&str>) -> String { const PRE: &str = r#" ## [{{ version | trim_start_matches(pat="v") }}]"#; const POST: &str = r#" - {{ timestamp | date(format="%Y-%m-%d") }} {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | upper_first }} {% for commit in commits %} {%- if commit.scope -%} - *({{commit.scope}})* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}) {% endfor -%}){% endif %} {% else -%} - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }} {% endif -%} {% endfor -%} {% endfor %}"#; match release_link { Some(link) => format!("{}{}{}", PRE, link, POST), None => format!("{}{}", PRE, POST), } } pub mod changelog_parser { use anyhow::Context; use regex::Regex; /// Parse the header from a changelog. /// The changelog header is a string at the begin of the changelog that: /// - Starts with `# Changelog`, `# CHANGELOG`, or `# changelog` /// - ends with `## Unreleased`, `## [Unreleased]` or `## ..anything..` /// (in the ..anything.. case, `## ..anything..` is not included in the header) pub fn parse_header(changelog: &str) -> Option { lazy_static::lazy_static! { static ref FIRST_RE: Regex = Regex::new(r"(?s)^(# Changelog|# CHANGELOG|# changelog)(.*)(## Unreleased|## \[Unreleased\])").unwrap(); static ref SECOND_RE: Regex = Regex::new(r"(?s)^(# Changelog|# CHANGELOG|# changelog)(.*)(\n## )").unwrap(); } if let Some(captures) = FIRST_RE.captures(changelog) { return Some(format!("{}\n", &captures[0])); } if let Some(captures) = SECOND_RE.captures(changelog) { return Some(format!("{}{}", &captures[1], &captures[2])); } None } pub fn last_changes(changelog: &str) -> anyhow::Result> { last_changes_from_str(changelog) } pub fn last_changes_from_str(changelog: &str) -> anyhow::Result> { let parser = ChangelogParser::new(changelog)?; let last_release = parser.last_release().map(|r| r.notes.to_string()); Ok(last_release) } pub fn last_version_from_str(changelog: &str) -> anyhow::Result> { let parser = ChangelogParser::new(changelog)?; let last_release = parser.last_release().map(|r| r.version.to_string()); Ok(last_release) } pub fn last_release_from_str(changelog: &str) -> anyhow::Result> { let parser = ChangelogParser::new(changelog)?; let last_release = parser.last_release().map(ChangelogRelease::from_release); Ok(last_release) } pub struct ChangelogRelease { title: String, notes: String, } impl ChangelogRelease { fn from_release(release: &parse_changelog::Release) -> Self { Self { title: release.title.to_string(), notes: release.notes.to_string(), } } pub fn title(&self) -> &str { &self.title } pub fn notes(&self) -> &str { &self.notes } } pub struct ChangelogParser<'a> { changelog: parse_changelog::Changelog<'a>, } impl<'a> ChangelogParser<'a> { pub fn new(changelog_text: &'a str) -> anyhow::Result { let changelog = parse_changelog::parse(changelog_text).context("can't parse changelog")?; Ok(Self { changelog }) } fn last_release(&self) -> Option<&parse_changelog::Release> { let last_release = release_at(&self.changelog, 0)?; let last_release = if last_release.version.to_lowercase().contains("unreleased") { release_at(&self.changelog, 1)? } else { last_release }; Some(last_release) } } fn release_at<'a>( changelog: &'a parse_changelog::Changelog, index: usize, ) -> Option<&'a parse_changelog::Release<'a>> { let release = changelog.get_index(index)?.1; Some(release) } #[cfg(test)] mod tests { use super::*; fn last_changes_from_str_test(changelog: &str) -> String { last_changes_from_str(changelog).unwrap().unwrap() } #[test] fn changelog_header_is_parsed() { let changelog = "\ # Changelog My custom changelog header ## [Unreleased] "; let header = parse_header(changelog).unwrap(); let expected_header = "\ # Changelog My custom changelog header ## [Unreleased] "; assert_eq!(header, expected_header); } #[test] fn changelog_header_without_unreleased_is_parsed() { let changelog = "\ # Changelog My custom changelog header ## [0.2.5] - 2022-12-16 "; let header = parse_header(changelog).unwrap(); let expected_header = "\ # Changelog My custom changelog header "; assert_eq!(header, expected_header); } #[test] fn changelog_header_with_versions_is_parsed() { let changelog = "\ # Changelog My custom changelog header ## [Unreleased] ## [0.2.5] - 2022-12-16 "; let header = parse_header(changelog).unwrap(); let expected_header = "\ # Changelog My custom changelog header ## [Unreleased] "; assert_eq!(header, expected_header); } #[test] fn changelog_header_isnt_recognized() { // A two-level header similar to `## [Unreleased]` is missing let changelog = "\ # Changelog My custom changelog header "; let header = parse_header(changelog); assert_eq!(header, None); } #[test] fn changelog_with_unreleased_section_is_parsed() { let changelog = "\ # 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] ## [0.2.5] - 2022-12-16 ### Added - Add function to retrieve default branch (#372) ## [0.2.4] - 2022-12-12 ### Changed - improved error message "; let changes = last_changes_from_str_test(changelog); let expected_changes = "\ ### Added - Add function to retrieve default branch (#372)"; assert_eq!(changes, expected_changes); } #[test] fn changelog_without_unreleased_section_is_parsed() { let changelog = "\ # 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). ## [0.2.5](https://github.com/MarcoIeni/release-plz/compare/git_cmd-v0.2.4...git_cmd-v0.2.5) - 2022-12-16 ### Added - Add function to retrieve default branch (#372) ## [0.2.4] - 2022-12-12 ### Changed - improved error message "; let changes = last_changes_from_str_test(changelog); let expected_changes = "\ ### Added - Add function to retrieve default branch (#372)"; assert_eq!(changes, expected_changes); } } } #[cfg(test)] mod tests { use super::*; #[test] fn bare_release() { let commits: Vec<&str> = Vec::new(); let changelog = ChangeLogBuilder::new(commits, "0.0.0") .with_release_date(NaiveDate::from_ymd_opt(1995, 5, 15).unwrap()) .build(); let expected = 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] "######; pretty_assertions::assert_eq!(expected, &changelog.generate().unwrap()) } #[test] fn generates_changelog() { let commits: Vec<&str> = vec![ "feat: some feature", "some random commit", "fix: some fix", "chore(scope): some chore", ]; let changelog = ChangeLogBuilder::new(commits, "1.0.0") .with_release_date(NaiveDate::from_ymd_opt(1995, 5, 15).unwrap()) .build(); let expected = 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] ## [1.0.0] - 1995-05-15 ### Added - some feature ### Fixed - some fix ### Other - some random commit - *(scope)* some chore "######; pretty_assertions::assert_eq!(expected, &changelog.generate().unwrap()) } }