kjuulh 8db6fc9d75
chore: add docs
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-08-01 22:56:21 +02:00

533 lines
15 KiB
Rust

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<String>,
version: String,
config: Option<Config>,
release_date: Option<NaiveDate>,
release_link: Option<String>,
}
impl ChangeLogBuilder {
pub fn new<C>(commits: C, version: impl Into<String>) -> Self
where
C: IntoIterator,
C::Item: AsRef<str>,
{
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<String>) -> 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::<Utc>::from_utc(d, Utc))
.unwrap_or_else(Utc::now)
.timestamp()
}
}
pub struct ChangeLog<'a> {
release: Release<'a>,
config: Option<Config>,
release_link: Option<String>,
}
impl ChangeLog<'_> {
pub fn generate(&self) -> anyhow::Result<String> {
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<String>) -> anyhow::Result<String> {
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<String>) -> 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<CommitParser> {
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<String>, 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<String> {
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<Option<String>> {
last_changes_from_str(changelog)
}
pub fn last_changes_from_str(changelog: &str) -> anyhow::Result<Option<String>> {
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<Option<String>> {
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<Option<ChangelogRelease>> {
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<Self> {
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())
}
}