diff --git a/Cargo.lock b/Cargo.lock index b47cfbb..40c82bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,6 +346,8 @@ dependencies = [ "conventional_commit_parser", "dotenv", "git-cliff-core", + "lazy_static", + "parse-changelog", "pretty_assertions", "regex", "reqwest", @@ -815,6 +817,7 @@ checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" dependencies = [ "equivalent", "hashbrown 0.14.0", + "serde", ] [[package]] @@ -887,6 +890,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lexopt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401" + [[package]] name = "libc" version = "0.2.147" @@ -1110,6 +1119,22 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parse-changelog" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a24196a65fc15a0a747df8c041abc5a009f2c09c550b0a14f7eeb0c10255ef" +dependencies = [ + "anyhow", + "indexmap 2.0.0", + "lexopt", + "memchr", + "once_cell", + "regex", + "serde", + "serde_json", +] + [[package]] name = "parse-zoneinfo" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index ac4ee9b..f2ca9ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ reqwest = { version = "0.11.18" } git-cliff-core = "1.2.0" regex = "*" chrono = "*" +lazy_static = "*" +parse-changelog = "*" tracing-test = "0.2" pretty_assertions = "1.4" diff --git a/crates/cuddle-please/Cargo.toml b/crates/cuddle-please/Cargo.toml index 7660b9d..c863442 100644 --- a/crates/cuddle-please/Cargo.toml +++ b/crates/cuddle-please/Cargo.toml @@ -19,6 +19,8 @@ tempdir.workspace = true git-cliff-core.workspace = true regex.workspace = true chrono.workspace = true +lazy_static.workspace = true +parse-changelog.workspace = true [dev-dependencies] tracing-test = { workspace = true, features = ["no-env-filter"] } diff --git a/crates/cuddle-please/src/cliff/mod.rs b/crates/cuddle-please/src/cliff/mod.rs index 2e5f7d9..badfb85 100644 --- a/crates/cuddle-please/src/cliff/mod.rs +++ b/crates/cuddle-please/src/cliff/mod.rs @@ -107,6 +107,31 @@ impl ChangeLog<'_> { .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<'a>(&self) -> Config { let config = Config { changelog: default_changelog_config( @@ -118,6 +143,18 @@ impl ChangeLog<'_> { config } + + fn default_config_with_header<'a>(&self, header: Option) -> Config { + let config = Config { + changelog: default_changelog_config( + header, + self.release_link.as_ref().map(|rl| rl.as_str()), + ), + git: default_git_config(), + }; + + config + } } fn default_git_config() -> GitConfig { @@ -199,6 +236,245 @@ fn default_changelog_body_config(release_link: Option<&str>) -> String { } } +mod changelog_parser { + use std::{fs::read_to_string, path::Path}; + + 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: &Path) -> anyhow::Result> { + let changelog = read_to_string(changelog).context("can't read changelog file")?; + 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::*;