knope_versioning/release_notes/
mod.rs1use std::{cmp::Ordering, fmt::Write};
2
3pub use changelog::Changelog;
4pub use config::{CommitFooter, CustomChangeType, SectionName, SectionSource, Sections};
5use itertools::Itertools;
6pub use release::Release;
7use time::{OffsetDateTime, macros::format_description};
8
9use crate::{Action, changes::Change, package, semver::Version};
10
11mod changelog;
12mod config;
13mod release;
14
15#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct ReleaseNotes {
18 pub sections: Sections,
19 pub changelog: Option<Changelog>,
20}
21
22impl ReleaseNotes {
23 pub fn create_release(
29 &mut self,
30 version: Version,
31 changes: &[Change],
32 package_name: &package::Name,
33 ) -> Result<Vec<Action>, TimeError> {
34 let mut notes = String::new();
35 for (section_name, sources) in self.sections.iter() {
36 let changes = changes
37 .iter()
38 .filter_map(|change| {
39 if sources.contains(&change.change_type) {
40 Some(ChangeDescription::from(change))
41 } else {
42 None
43 }
44 })
45 .sorted()
46 .collect_vec();
47 if !changes.is_empty() {
48 notes.push_str("\n\n## ");
49 notes.push_str(section_name.as_ref());
50 notes.push_str("\n\n");
51 notes.push_str(&build_body(changes));
52 }
53 }
54
55 let notes = notes.trim().to_string();
56 let release = Release {
57 title: release_title(&version)?,
58 version,
59 notes,
60 package_name: package_name.clone(),
61 };
62
63 let mut pending_actions = Vec::with_capacity(2);
64 if let Some(changelog) = self.changelog.as_mut() {
65 let new_changes = changelog.with_release(&release);
66 pending_actions.push(Action::WriteToFile {
67 path: changelog.path.clone(),
68 content: changelog.content.clone(),
69 diff: format!("\n{new_changes}\n"),
70 });
71 }
72 pending_actions.push(Action::CreateRelease(release));
73 Ok(pending_actions)
74 }
75}
76
77#[derive(Debug, thiserror::Error)]
78#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
79#[error("Failed to format current time")]
80#[cfg_attr(
81 feature = "miette",
82 diagnostic(
83 code(release_notes::time_format),
84 help(
85 "This is probably a bug with knope, please file an issue at https://github.com/knope-dev/knope"
86 )
87 )
88)]
89pub struct TimeError(#[from] time::error::Format);
90
91#[derive(Clone, Debug, Eq, PartialEq)]
92enum ChangeDescription {
93 Simple(String),
94 Complex(String, String),
95}
96
97impl Ord for ChangeDescription {
98 fn cmp(&self, other: &Self) -> Ordering {
99 match (self, other) {
100 (Self::Simple(_), Self::Complex(_, _)) => Ordering::Less,
101 (Self::Complex(_, _), Self::Simple(_)) => Ordering::Greater,
102 _ => Ordering::Equal,
103 }
104 }
105}
106
107impl PartialOrd for ChangeDescription {
108 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
109 Some(self.cmp(other))
110 }
111}
112
113impl From<&Change> for ChangeDescription {
114 fn from(change: &Change) -> Self {
115 let mut lines = change
116 .description
117 .trim()
118 .lines()
119 .skip_while(|it| it.is_empty());
120 let summary: String = lines
121 .next()
122 .unwrap_or_default()
123 .chars()
124 .skip_while(|it| *it == '#' || *it == ' ')
125 .collect();
126 let body: String = lines.skip_while(|it| it.is_empty()).join("\n");
127 if body.is_empty() {
128 Self::Simple(summary)
129 } else {
130 Self::Complex(summary, body)
131 }
132 }
133}
134
135fn build_body(changes: Vec<ChangeDescription>) -> String {
136 let mut body = String::new();
137 let mut changes = changes.into_iter().peekable();
138 while let Some(change) = changes.next() {
139 match change {
140 ChangeDescription::Simple(summary) => {
141 write!(&mut body, "- {summary}").ok();
142 }
143 ChangeDescription::Complex(summary, details) => {
144 write!(&mut body, "### {summary}\n\n{details}").ok();
145 }
146 }
147 match changes.peek() {
148 Some(ChangeDescription::Simple(_)) => body.push('\n'),
149 Some(ChangeDescription::Complex(_, _)) => body.push_str("\n\n"),
150 None => (),
151 }
152 }
153 body
154}
155
156fn release_title(version: &Version) -> Result<String, TimeError> {
162 let format = format_description!("[year]-[month]-[day]");
163 let date_str = OffsetDateTime::now_utc().date().format(&format)?;
164 Ok(format!("{version} ({date_str})"))
165}
166
167#[cfg(test)]
168mod test_change_description {
169 use pretty_assertions::assert_eq;
170
171 use super::*;
172 use crate::changes::{ChangeSource, ChangeType};
173
174 #[test]
175 fn conventional_commit() {
176 let change = Change {
177 change_type: ChangeType::Feature,
178 original_source: ChangeSource::ConventionalCommit(String::new()),
179 description: "a feature".into(),
180 };
181 let description = ChangeDescription::from(&change);
182 assert_eq!(
183 description,
184 ChangeDescription::Simple("a feature".to_string())
185 );
186 }
187
188 #[test]
189 fn simple_changeset() {
190 let change = Change {
191 change_type: ChangeType::Feature,
192 original_source: ChangeSource::ConventionalCommit(String::new()),
193 description: "# a feature\n\n\n\n".into(),
194 };
195 let description = ChangeDescription::from(&change);
196 assert_eq!(
197 description,
198 ChangeDescription::Simple("a feature".to_string())
199 );
200 }
201
202 #[test]
203 fn complex_changeset() {
204 let change = Change {
205 original_source: ChangeSource::ConventionalCommit(String::new()),
206 change_type: ChangeType::Feature,
207 description: "# a feature\n\nwith details\n\n- first\n- second".into(),
208 };
209 let description = ChangeDescription::from(&change);
210 assert_eq!(
211 description,
212 ChangeDescription::Complex(
213 "a feature".to_string(),
214 "with details\n\n- first\n- second".to_string()
215 )
216 );
217 }
218}