use std::{borrow::Cow, fmt::Write, iter::Peekable};
pub use changelog::Changelog;
pub use config::{CommitFooter, CustomChangeType, SectionName, SectionSource, Sections};
use itertools::Itertools;
pub use release::Release;
use serde::Deserialize;
use time::{OffsetDateTime, macros::format_description};
use crate::{Action, changes::Change, package, semver::Version};
mod changelog;
mod config;
mod release;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReleaseNotes {
pub sections: Sections,
pub changelog: Option<Changelog>,
pub change_templates: Vec<ChangeTemplate>,
}
impl ReleaseNotes {
#[must_use]
pub fn first_variable_needing_forge_data(&self) -> Option<&'static str> {
self.change_templates
.iter()
.find_map(ChangeTemplate::first_variable_needing_forge_data)
}
pub(crate) fn create_release(
&mut self,
version: Version,
changes: &[Change],
package_name: &package::Name,
) -> Result<Vec<Action>, TimeError> {
let mut notes = String::new();
for (section_name, sources) in self.sections.iter() {
let mut changes = changes
.iter()
.filter(|change| sources.contains(&change.change_type))
.sorted()
.peekable();
if changes.peek().is_some() {
if !notes.is_empty() {
notes.push_str("\n\n");
}
notes.push_str("## ");
notes.push_str(section_name.as_ref());
notes.push_str("\n\n");
write_body(&mut notes, changes, &self.change_templates);
}
}
let release = Release {
title: release_title(&version)?,
version,
notes,
package_name: package_name.clone(),
};
let mut pending_actions = Vec::with_capacity(2);
if let Some(changelog) = self.changelog.as_mut() {
let new_changes = changelog.with_release(&release);
pending_actions.push(Action::WriteToFile {
path: changelog.path.clone(),
content: changelog.content.clone(),
diff: format!("\n{new_changes}\n"),
});
}
pending_actions.push(Action::CreateRelease(release));
Ok(pending_actions)
}
}
#[derive(Debug, thiserror::Error)]
#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
#[error("Failed to format current time")]
#[cfg_attr(
feature = "miette",
diagnostic(
code(release_notes::time_format),
help(
"This is probably a bug with knope, please file an issue at https://github.com/knope-dev/knope"
)
)
)]
pub struct TimeError(#[from] time::error::Format);
fn write_body<'change>(
out: &mut String,
changes: Peekable<impl Iterator<Item = &'change Change>>,
templates: &[ChangeTemplate],
) {
let mut changes = changes.peekable();
while let Some(change) = changes.next() {
write_change(out, change, templates);
match changes.peek().map(|change| change.details.is_some()) {
Some(false) => out.push('\n'),
Some(true) => out.push_str("\n\n"),
None => (),
}
}
}
fn write_change(out: &mut String, change: &Change, templates: &[ChangeTemplate]) {
for template in templates {
if template.write(change, out) {
return;
}
}
if let Some(details) = &change.details {
write!(out, "### {summary}\n\n{details}", summary = change.summary).ok();
} else {
write!(out, "- {summary}", summary = change.summary).ok();
}
}
fn release_title(version: &Version) -> Result<String, TimeError> {
let format = format_description!("[year]-[month]-[day]");
let date_str = OffsetDateTime::now_utc().date().format(&format)?;
Ok(format!("{version} ({date_str})"))
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct ChangeTemplate(Cow<'static, str>);
impl ChangeTemplate {
const PR_AUTHOR_LOGIN: &'static str = "$pr_author_login";
const COMMIT_AUTHOR_NAME: &'static str = "$commit_author_name";
const COMMIT_HASH: &'static str = "$commit_hash";
const DETAILS: &'static str = "$details";
const PR_NUMBER: &'static str = "$pr_number";
const SUMMARY: &'static str = "$summary";
fn write(&self, change: &Change, out: &mut String) -> bool {
let mut result = self.0.to_string();
if result.contains(Self::COMMIT_AUTHOR_NAME) || result.contains(Self::COMMIT_HASH) {
if let Some(git) = change.git.as_ref() {
result = result.replace(Self::COMMIT_AUTHOR_NAME, &git.author_name);
result = result.replace(Self::COMMIT_HASH, &git.hash);
} else {
return false;
}
}
if result.contains(Self::PR_AUTHOR_LOGIN) {
if let Some(login) = change
.git
.as_ref()
.and_then(|g| g.pr_author_login.as_deref())
{
result = result.replace(Self::PR_AUTHOR_LOGIN, login);
} else {
return false;
}
}
if result.contains(Self::PR_NUMBER) {
if let Some(pr) = change.git.as_ref().and_then(|g| g.pr_number) {
result = result.replace(Self::PR_NUMBER, &pr.to_string());
} else {
return false;
}
}
if result.contains(Self::DETAILS) {
if let Some(details) = change.details.as_deref() {
result = result.replace(Self::DETAILS, details);
} else {
return false;
}
}
result = result.replace(Self::SUMMARY, &change.summary);
out.push_str(&result);
true
}
#[must_use]
pub fn first_variable_needing_forge_data(&self) -> Option<&'static str> {
[Self::PR_AUTHOR_LOGIN, Self::PR_NUMBER]
.into_iter()
.find(|&variable| self.0.contains(variable))
}
}
impl From<String> for ChangeTemplate {
fn from(template: String) -> Self {
Self(Cow::Owned(template))
}
}
impl From<&'static str> for ChangeTemplate {
fn from(template: &'static str) -> Self {
Self(Cow::Borrowed(template))
}
}
#[cfg(test)]
mod test_release_notes {
use std::sync::Arc;
use changesets::UniqueId;
use pretty_assertions::assert_eq;
use super::*;
use crate::changes::{ChangeSource, ChangeType, GitInfo};
#[test]
fn simple_changes_before_complex() {
let changes = vec![
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("")),
},
summary: "a complex feature".into(),
details: Some("some details".into()),
git: None,
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("")),
},
summary: "a simple feature".into(),
details: None,
git: None,
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ConventionalCommit {
description: String::new(),
},
summary: "a super simple feature".into(),
details: None,
git: None,
},
];
let mut actions = ReleaseNotes::create_release(
&mut ReleaseNotes::default(),
Version::new(1, 0, 0, None),
&changes,
&package::Name::Default,
)
.expect("can create release notes");
assert_eq!(actions.len(), 1);
let action = actions.pop().unwrap();
let Action::CreateRelease(release) = action else {
panic!("expected release action");
};
assert_eq!(
release.notes,
"## Features\n\n- a simple feature\n- a super simple feature\n\n### a complex feature\n\nsome details"
);
}
#[test]
fn custom_templates() {
let change_templates = [
"* $summary by $commit_author_name ($commit_hash)", "###### $summary!!! $notAVariable\n\n$details", "* $summary", ]
.into_iter()
.map(ChangeTemplate::from)
.collect_vec();
let mut release_notes = ReleaseNotes {
change_templates,
changelog: Some(Changelog::new(
"CHANGELOG.md".into(),
"# My Changelog\n\n## 1.2.3 (previous version)".to_string(),
)),
..ReleaseNotes::default()
};
let changes = &[
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("")),
},
summary: "a complex feature".to_string(),
details: Some("some details".into()),
git: None,
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("")),
},
summary: "a simple feature".into(),
details: None,
git: None,
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ConventionalCommit {
description: String::new(),
},
summary: "a super simple feature".into(),
details: None,
git: Some(GitInfo {
author_name: "Sushi".into(),
hash: "1234".into(),
pr_number: None,
pr_author_login: None,
}),
},
];
let mut actions = release_notes
.create_release(
Version::new(1, 3, 0, None),
changes,
&package::Name::Default,
)
.expect("can create release notes");
let Some(Action::CreateRelease(release)) = actions.pop() else {
panic!("expected release action");
};
assert_eq!(
release.notes,
"## Features\n\n* a simple feature\n* a super simple feature by Sushi (1234)\n\n###### a complex feature!!! $notAVariable\n\nsome details"
);
let Some(Action::WriteToFile { diff, .. }) = actions.pop() else {
panic!("expected write changelog action");
};
assert!(
diff.ends_with(
"\n\n### Features\n\n* a simple feature\n* a super simple feature by Sushi (1234)\n\n####### a complex feature!!! $notAVariable\n\nsome details\n"
) );
}
#[test]
fn fall_back_to_built_in_templates() {
let change_templates = ["* $summary by $commit_author_name"]
.into_iter()
.map(ChangeTemplate::from)
.collect_vec(); let mut release_notes = ReleaseNotes {
change_templates,
..ReleaseNotes::default()
};
let changes = &[
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("")),
},
summary: "a complex feature".to_string(),
details: Some("some details".into()),
git: None,
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("")),
},
summary: "a simple feature".into(),
details: None,
git: None,
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ConventionalCommit {
description: String::new(),
},
summary: "a super simple feature".into(),
details: None,
git: Some(GitInfo {
author_name: "Sushi".into(),
hash: "1234".into(),
pr_number: None,
pr_author_login: None,
}),
},
];
let mut actions = release_notes
.create_release(
Version::new(1, 3, 0, None),
changes,
&package::Name::Default,
)
.expect("can create release notes");
let Some(Action::CreateRelease(release)) = actions.pop() else {
panic!("expected release action");
};
assert_eq!(
release.notes,
"## Features\n\n- a simple feature\n* a super simple feature by Sushi\n\n### a complex feature\n\nsome details"
);
}
#[test]
fn change_files_with_commit_info_use_commit_templates() {
let change_templates = [
"* $summary by $commit_author_name ($commit_hash)\n\n$details", "* $summary by $commit_author_name ($commit_hash)", "### $summary\n\n$details", "* $summary", ]
.into_iter()
.map(ChangeTemplate::from)
.collect_vec();
let mut release_notes = ReleaseNotes {
change_templates,
..ReleaseNotes::default()
};
let changes = &[
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("committed-with-details")),
},
summary: "a committed feature with details".to_string(),
details: Some("some implementation details".into()),
git: Some(GitInfo {
author_name: "Alice".into(),
hash: "abc123".into(),
pr_number: None,
pr_author_login: None,
}),
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("committed-simple")),
},
summary: "a committed simple feature".into(),
details: None,
git: Some(GitInfo {
author_name: "Bob".into(),
hash: "def456".into(),
pr_number: None,
pr_author_login: None,
}),
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("uncommitted-with-details")),
},
summary: "an uncommitted feature with details".to_string(),
details: Some("some more details".into()),
git: None,
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("uncommitted-simple")),
},
summary: "an uncommitted simple feature".into(),
details: None,
git: None,
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ConventionalCommit {
description: "feat: conventional commit feature".into(),
},
summary: "conventional commit feature".into(),
details: None,
git: Some(GitInfo {
author_name: "Charlie".into(),
hash: "ghi789".into(),
pr_number: None,
pr_author_login: None,
}),
},
];
let mut actions = release_notes
.create_release(
Version::new(2, 0, 0, None),
changes,
&package::Name::Default,
)
.expect("can create release notes");
let Some(Action::CreateRelease(release)) = actions.pop() else {
panic!("expected release action");
};
assert_eq!(
release.notes,
"## Features\n\n* a committed simple feature by Bob (def456)\n* an uncommitted simple feature\n* conventional commit feature by Charlie (ghi789)\n\n* a committed feature with details by Alice (abc123)\n\nsome implementation details\n\n### an uncommitted feature with details\n\nsome more details"
);
}
#[test]
fn github_style_templates_with_pr_and_login() {
let change_templates = [
"* $summary by @$pr_author_login in #$pr_number",
"* $summary by @$pr_author_login",
"* $summary",
]
.into_iter()
.map(ChangeTemplate::from)
.collect_vec();
let mut release_notes = ReleaseNotes {
change_templates,
..ReleaseNotes::default()
};
let changes = &[
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ConventionalCommit {
description: String::new(),
},
summary: "add dark mode".into(),
details: None,
git: Some(GitInfo {
author_name: "Dale Seo".into(),
hash: "abc1234".into(),
pr_number: Some(42),
pr_author_login: Some("DaleSeo".into()),
}),
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ConventionalCommit {
description: String::new(),
},
summary: "improve logging".into(),
details: None,
git: Some(GitInfo {
author_name: "Alice".into(),
hash: "def5678".into(),
pr_number: None,
pr_author_login: Some("alice".into()),
}),
},
Change {
change_type: ChangeType::Feature,
original_source: ChangeSource::ChangeFile {
id: Arc::new(UniqueId::exact("")),
},
summary: "uncommitted feature".into(),
details: None,
git: None,
},
Change {
change_type: ChangeType::Fix,
original_source: ChangeSource::ConventionalCommit {
description: String::new(),
},
summary: "fix crash".into(),
details: None,
git: Some(GitInfo {
author_name: "Bob".into(),
hash: "ghi9012".into(),
pr_number: None,
pr_author_login: None,
}),
},
];
let mut actions = release_notes
.create_release(
Version::new(1, 1, 0, None),
changes,
&package::Name::Default,
)
.expect("can create release notes");
let Some(Action::CreateRelease(release)) = actions.pop() else {
panic!("expected release action");
};
assert_eq!(
release.notes,
"## Features\n\n* add dark mode by @DaleSeo in #42\n* improve logging by @alice\n* uncommitted feature\n\n## Fixes\n\n* fix crash"
);
}
#[test]
fn needs_forge_data_false_for_local_only_template() {
let notes = ReleaseNotes {
change_templates: vec![ChangeTemplate::from("* $summary by $commit_author_name")],
..ReleaseNotes::default()
};
assert!(notes.first_variable_needing_forge_data().is_none());
}
#[test]
fn needs_forge_data_true_for_pr_number() {
let notes = ReleaseNotes {
change_templates: vec![ChangeTemplate::from("* $summary in #$pr_number")],
..ReleaseNotes::default()
};
assert!(notes.first_variable_needing_forge_data().is_some());
}
#[test]
fn needs_forge_data_true_for_author_login() {
let notes = ReleaseNotes {
change_templates: vec![ChangeTemplate::from("* $summary by @$pr_author_login")],
..ReleaseNotes::default()
};
assert!(notes.first_variable_needing_forge_data().is_some());
}
#[test]
fn needs_forge_data_false_for_default() {
let notes = ReleaseNotes::default();
assert!(notes.first_variable_needing_forge_data().is_none());
}
}