1use std::{borrow::Cow, fmt::Write, iter::Peekable};
2
3pub use changelog::Changelog;
4pub use config::{CommitFooter, CustomChangeType, SectionName, SectionSource, Sections};
5use itertools::Itertools;
6pub use release::Release;
7use serde::Deserialize;
8use time::{OffsetDateTime, macros::format_description};
9
10use crate::{Action, changes::Change, package, semver::Version};
11
12mod changelog;
13mod config;
14mod release;
15
16#[derive(Clone, Debug, Default, Eq, PartialEq)]
18pub struct ReleaseNotes {
19 pub sections: Sections,
20 pub changelog: Option<Changelog>,
21 pub change_templates: Vec<ChangeTemplate>,
22}
23
24impl ReleaseNotes {
25 pub(crate) fn create_release(
31 &mut self,
32 version: Version,
33 changes: &[Change],
34 package_name: &package::Name,
35 ) -> Result<Vec<Action>, TimeError> {
36 let mut notes = String::new();
37 for (section_name, sources) in self.sections.iter() {
38 let mut changes = changes
39 .iter()
40 .filter(|change| sources.contains(&change.change_type))
41 .sorted()
42 .peekable();
43 if changes.peek().is_some() {
44 if !notes.is_empty() {
45 notes.push_str("\n\n");
46 }
47 notes.push_str("## ");
48 notes.push_str(section_name.as_ref());
49 notes.push_str("\n\n");
50 write_body(&mut notes, changes, &self.change_templates);
51 }
52 }
53
54 let release = Release {
55 title: release_title(&version)?,
56 version,
57 notes,
58 package_name: package_name.clone(),
59 };
60
61 let mut pending_actions = Vec::with_capacity(2);
62 if let Some(changelog) = self.changelog.as_mut() {
63 let new_changes = changelog.with_release(&release);
64 pending_actions.push(Action::WriteToFile {
65 path: changelog.path.clone(),
66 content: changelog.content.clone(),
67 diff: format!("\n{new_changes}\n"),
68 });
69 }
70 pending_actions.push(Action::CreateRelease(release));
71 Ok(pending_actions)
72 }
73}
74
75#[derive(Debug, thiserror::Error)]
76#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
77#[error("Failed to format current time")]
78#[cfg_attr(
79 feature = "miette",
80 diagnostic(
81 code(release_notes::time_format),
82 help(
83 "This is probably a bug with knope, please file an issue at https://github.com/knope-dev/knope"
84 )
85 )
86)]
87pub struct TimeError(#[from] time::error::Format);
88
89fn write_body<'change>(
90 out: &mut String,
91 changes: Peekable<impl Iterator<Item = &'change Change>>,
92 templates: &[ChangeTemplate],
93) {
94 let mut changes = changes.peekable();
95 while let Some(change) = changes.next() {
96 write_change(out, change, templates);
97
98 match changes.peek().map(|change| change.details.is_some()) {
99 Some(false) => out.push('\n'),
100 Some(true) => out.push_str("\n\n"),
101 None => (),
102 }
103 }
104}
105
106fn write_change(out: &mut String, change: &Change, templates: &[ChangeTemplate]) {
107 for template in templates {
108 if template.write(change, out) {
109 return;
110 }
111 }
112 if let Some(details) = &change.details {
113 write!(out, "### {summary}\n\n{details}", summary = change.summary).ok();
114 } else {
115 write!(out, "- {summary}", summary = change.summary).ok();
116 }
117}
118
119fn release_title(version: &Version) -> Result<String, TimeError> {
125 let format = format_description!("[year]-[month]-[day]");
126 let date_str = OffsetDateTime::now_utc().date().format(&format)?;
127 Ok(format!("{version} ({date_str})"))
128}
129
130#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
131pub struct ChangeTemplate(Cow<'static, str>);
132
133impl ChangeTemplate {
134 const COMMIT_AUTHOR_NAME: &'static str = "$commit_author_name";
135 const COMMIT_HASH: &'static str = "$commit_hash";
136 const SUMMARY: &'static str = "$summary";
137 const DETAILS: &'static str = "$details";
138
139 fn write(&self, change: &Change, out: &mut String) -> bool {
140 let mut result = self.0.to_string();
141 if result.contains(Self::COMMIT_AUTHOR_NAME) || result.contains(Self::COMMIT_HASH) {
142 if let Some(git) = change.git.as_ref() {
143 result = result.replace(Self::COMMIT_AUTHOR_NAME, &git.author_name);
144 result = result.replace(Self::COMMIT_HASH, &git.hash);
145 } else {
146 return false;
148 }
149 }
150
151 if result.contains(Self::DETAILS) {
152 if let Some(details) = change.details.as_deref() {
153 result = result.replace(Self::DETAILS, details);
154 } else {
155 return false;
156 }
157 }
158
159 result = result.replace(Self::SUMMARY, &change.summary);
160 out.push_str(&result);
161
162 true
163 }
164}
165
166impl From<String> for ChangeTemplate {
167 fn from(template: String) -> Self {
168 Self(Cow::Owned(template))
169 }
170}
171
172impl From<&'static str> for ChangeTemplate {
173 fn from(template: &'static str) -> Self {
174 Self(Cow::Borrowed(template))
175 }
176}
177
178#[cfg(test)]
179mod test_release_notes {
180 use std::sync::Arc;
181
182 use changesets::UniqueId;
183 use pretty_assertions::assert_eq;
184
185 use super::*;
186 use crate::changes::{ChangeSource, ChangeType, GitInfo};
187
188 #[test]
189 fn simple_changes_before_complex() {
190 let changes = vec![
191 Change {
192 change_type: ChangeType::Feature,
193 original_source: ChangeSource::ChangeFile {
194 id: Arc::new(UniqueId::exact("")),
195 },
196 summary: "a complex feature".into(),
197 details: Some("some details".into()),
198 git: None,
199 },
200 Change {
201 change_type: ChangeType::Feature,
202 original_source: ChangeSource::ChangeFile {
203 id: Arc::new(UniqueId::exact("")),
204 },
205 summary: "a simple feature".into(),
206 details: None,
207 git: None,
208 },
209 Change {
210 change_type: ChangeType::Feature,
211 original_source: ChangeSource::ConventionalCommit {
212 description: String::new(),
213 },
214 summary: "a super simple feature".into(),
215 details: None,
216 git: None,
217 },
218 ];
219
220 let mut actions = ReleaseNotes::create_release(
221 &mut ReleaseNotes::default(),
222 Version::new(1, 0, 0, None),
223 &changes,
224 &package::Name::Default,
225 )
226 .expect("can create release notes");
227 assert_eq!(actions.len(), 1);
228
229 let action = actions.pop().unwrap();
230
231 let Action::CreateRelease(release) = action else {
232 panic!("expected release action");
233 };
234
235 assert_eq!(
236 release.notes,
237 "## Features\n\n- a simple feature\n- a super simple feature\n\n### a complex feature\n\nsome details"
238 );
239 }
240
241 #[test]
242 fn custom_templates() {
243 let change_templates = [
244 "* $summary by $commit_author_name ($commit_hash)", "###### $summary!!! $notAVariable\n\n$details", "* $summary", ]
248 .into_iter()
249 .map(ChangeTemplate::from)
250 .collect_vec();
251
252 let mut release_notes = ReleaseNotes {
253 change_templates,
254 changelog: Some(Changelog::new(
255 "CHANGELOG.md".into(),
256 "# My Changelog\n\n## 1.2.3 (previous version)".to_string(),
257 )),
258 ..ReleaseNotes::default()
259 };
260
261 let changes = &[
262 Change {
263 change_type: ChangeType::Feature,
264 original_source: ChangeSource::ChangeFile {
265 id: Arc::new(UniqueId::exact("")),
266 },
267 summary: "a complex feature".to_string(),
268 details: Some("some details".into()),
269 git: None,
270 },
271 Change {
272 change_type: ChangeType::Feature,
273 original_source: ChangeSource::ChangeFile {
274 id: Arc::new(UniqueId::exact("")),
275 },
276 summary: "a simple feature".into(),
277 details: None,
278 git: None,
279 },
280 Change {
281 change_type: ChangeType::Feature,
282 original_source: ChangeSource::ConventionalCommit {
283 description: String::new(),
284 },
285 summary: "a super simple feature".into(),
286 details: None,
287 git: Some(GitInfo {
288 author_name: "Sushi".into(),
289 hash: "1234".into(),
290 }),
291 },
292 ];
293
294 let mut actions = release_notes
295 .create_release(
296 Version::new(1, 3, 0, None),
297 changes,
298 &package::Name::Default,
299 )
300 .expect("can create release notes");
301 let Some(Action::CreateRelease(release)) = actions.pop() else {
302 panic!("expected release action");
303 };
304
305 assert_eq!(
306 release.notes,
307 "## Features\n\n* a simple feature\n* a super simple feature by Sushi (1234)\n\n###### a complex feature!!! $notAVariable\n\nsome details"
308 );
309
310 let Some(Action::WriteToFile { diff, .. }) = actions.pop() else {
311 panic!("expected write changelog action");
312 };
313
314 assert!(
315 diff.ends_with(
316 "\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"
317 ) );
319 }
320
321 #[test]
322 fn fall_back_to_built_in_templates() {
323 let change_templates = ["* $summary by $commit_author_name"]
324 .into_iter()
325 .map(ChangeTemplate::from)
326 .collect_vec(); let mut release_notes = ReleaseNotes {
328 change_templates,
329 ..ReleaseNotes::default()
330 };
331
332 let changes = &[
333 Change {
334 change_type: ChangeType::Feature,
335 original_source: ChangeSource::ChangeFile {
336 id: Arc::new(UniqueId::exact("")),
337 },
338 summary: "a complex feature".to_string(),
339 details: Some("some details".into()),
340 git: None,
341 },
342 Change {
343 change_type: ChangeType::Feature,
344 original_source: ChangeSource::ChangeFile {
345 id: Arc::new(UniqueId::exact("")),
346 },
347 summary: "a simple feature".into(),
348 details: None,
349 git: None,
350 },
351 Change {
352 change_type: ChangeType::Feature,
353 original_source: ChangeSource::ConventionalCommit {
354 description: String::new(),
355 },
356 summary: "a super simple feature".into(),
357 details: None,
358 git: Some(GitInfo {
359 author_name: "Sushi".into(),
360 hash: "1234".into(),
361 }),
362 },
363 ];
364
365 let mut actions = release_notes
366 .create_release(
367 Version::new(1, 3, 0, None),
368 changes,
369 &package::Name::Default,
370 )
371 .expect("can create release notes");
372 let Some(Action::CreateRelease(release)) = actions.pop() else {
373 panic!("expected release action");
374 };
375 assert_eq!(
376 release.notes,
377 "## Features\n\n- a simple feature\n* a super simple feature by Sushi\n\n### a complex feature\n\nsome details"
378 );
379 }
380
381 #[test]
382 fn change_files_with_commit_info_use_commit_templates() {
383 let change_templates = [
384 "* $summary by $commit_author_name ($commit_hash)\n\n$details", "* $summary by $commit_author_name ($commit_hash)", "### $summary\n\n$details", "* $summary", ]
389 .into_iter()
390 .map(ChangeTemplate::from)
391 .collect_vec();
392
393 let mut release_notes = ReleaseNotes {
394 change_templates,
395 ..ReleaseNotes::default()
396 };
397
398 let changes = &[
399 Change {
401 change_type: ChangeType::Feature,
402 original_source: ChangeSource::ChangeFile {
403 id: Arc::new(UniqueId::exact("committed-with-details")),
404 },
405 summary: "a committed feature with details".to_string(),
406 details: Some("some implementation details".into()),
407 git: Some(GitInfo {
408 author_name: "Alice".into(),
409 hash: "abc123".into(),
410 }),
411 },
412 Change {
414 change_type: ChangeType::Feature,
415 original_source: ChangeSource::ChangeFile {
416 id: Arc::new(UniqueId::exact("committed-simple")),
417 },
418 summary: "a committed simple feature".into(),
419 details: None,
420 git: Some(GitInfo {
421 author_name: "Bob".into(),
422 hash: "def456".into(),
423 }),
424 },
425 Change {
427 change_type: ChangeType::Feature,
428 original_source: ChangeSource::ChangeFile {
429 id: Arc::new(UniqueId::exact("uncommitted-with-details")),
430 },
431 summary: "an uncommitted feature with details".to_string(),
432 details: Some("some more details".into()),
433 git: None,
434 },
435 Change {
437 change_type: ChangeType::Feature,
438 original_source: ChangeSource::ChangeFile {
439 id: Arc::new(UniqueId::exact("uncommitted-simple")),
440 },
441 summary: "an uncommitted simple feature".into(),
442 details: None,
443 git: None,
444 },
445 Change {
447 change_type: ChangeType::Feature,
448 original_source: ChangeSource::ConventionalCommit {
449 description: "feat: conventional commit feature".into(),
450 },
451 summary: "conventional commit feature".into(),
452 details: None,
453 git: Some(GitInfo {
454 author_name: "Charlie".into(),
455 hash: "ghi789".into(),
456 }),
457 },
458 ];
459
460 let mut actions = release_notes
461 .create_release(
462 Version::new(2, 0, 0, None),
463 changes,
464 &package::Name::Default,
465 )
466 .expect("can create release notes");
467
468 let Some(Action::CreateRelease(release)) = actions.pop() else {
469 panic!("expected release action");
470 };
471
472 assert_eq!(
473 release.notes,
474 "## 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"
475 );
476 }
477}