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 #[must_use]
28 pub fn first_variable_needing_forge_data(&self) -> Option<&'static str> {
29 self.change_templates
30 .iter()
31 .find_map(ChangeTemplate::first_variable_needing_forge_data)
32 }
33
34 pub(crate) fn create_release(
40 &mut self,
41 version: Version,
42 changes: &[Change],
43 package_name: &package::Name,
44 ) -> Result<Vec<Action>, TimeError> {
45 let mut notes = String::new();
46 for (section_name, sources) in self.sections.iter() {
47 let mut changes = changes
48 .iter()
49 .filter(|change| sources.contains(&change.change_type))
50 .sorted()
51 .peekable();
52 if changes.peek().is_some() {
53 if !notes.is_empty() {
54 notes.push_str("\n\n");
55 }
56 notes.push_str("## ");
57 notes.push_str(section_name.as_ref());
58 notes.push_str("\n\n");
59 write_body(&mut notes, changes, &self.change_templates);
60 }
61 }
62
63 let release = Release {
64 title: release_title(&version)?,
65 version,
66 notes,
67 package_name: package_name.clone(),
68 };
69
70 let mut pending_actions = Vec::with_capacity(2);
71 if let Some(changelog) = self.changelog.as_mut() {
72 let new_changes = changelog.with_release(&release);
73 pending_actions.push(Action::WriteToFile {
74 path: changelog.path.clone(),
75 content: changelog.content.clone(),
76 diff: format!("\n{new_changes}\n"),
77 });
78 }
79 pending_actions.push(Action::CreateRelease(release));
80 Ok(pending_actions)
81 }
82}
83
84#[derive(Debug, thiserror::Error)]
85#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
86#[error("Failed to format current time")]
87#[cfg_attr(
88 feature = "miette",
89 diagnostic(
90 code(release_notes::time_format),
91 help(
92 "This is probably a bug with knope, please file an issue at https://github.com/knope-dev/knope"
93 )
94 )
95)]
96pub struct TimeError(#[from] time::error::Format);
97
98fn write_body<'change>(
99 out: &mut String,
100 changes: Peekable<impl Iterator<Item = &'change Change>>,
101 templates: &[ChangeTemplate],
102) {
103 let mut changes = changes.peekable();
104 while let Some(change) = changes.next() {
105 write_change(out, change, templates);
106
107 match changes.peek().map(|change| change.details.is_some()) {
108 Some(false) => out.push('\n'),
109 Some(true) => out.push_str("\n\n"),
110 None => (),
111 }
112 }
113}
114
115fn write_change(out: &mut String, change: &Change, templates: &[ChangeTemplate]) {
116 for template in templates {
117 if template.write(change, out) {
118 return;
119 }
120 }
121 if let Some(details) = &change.details {
122 write!(out, "### {summary}\n\n{details}", summary = change.summary).ok();
123 } else {
124 write!(out, "- {summary}", summary = change.summary).ok();
125 }
126}
127
128fn release_title(version: &Version) -> Result<String, TimeError> {
134 let format = format_description!("[year]-[month]-[day]");
135 let date_str = OffsetDateTime::now_utc().date().format(&format)?;
136 Ok(format!("{version} ({date_str})"))
137}
138
139#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
140pub struct ChangeTemplate(Cow<'static, str>);
141
142impl ChangeTemplate {
143 const PR_AUTHOR_LOGIN: &'static str = "$pr_author_login";
144 const COMMIT_AUTHOR_NAME: &'static str = "$commit_author_name";
145 const COMMIT_HASH: &'static str = "$commit_hash";
146 const DETAILS: &'static str = "$details";
147 const PR_NUMBER: &'static str = "$pr_number";
148 const SUMMARY: &'static str = "$summary";
149
150 fn write(&self, change: &Change, out: &mut String) -> bool {
151 let mut result = self.0.to_string();
152 if result.contains(Self::COMMIT_AUTHOR_NAME) || result.contains(Self::COMMIT_HASH) {
153 if let Some(git) = change.git.as_ref() {
154 result = result.replace(Self::COMMIT_AUTHOR_NAME, &git.author_name);
155 result = result.replace(Self::COMMIT_HASH, &git.hash);
156 } else {
157 return false;
158 }
159 }
160
161 if result.contains(Self::PR_AUTHOR_LOGIN) {
162 if let Some(login) = change
163 .git
164 .as_ref()
165 .and_then(|g| g.pr_author_login.as_deref())
166 {
167 result = result.replace(Self::PR_AUTHOR_LOGIN, login);
168 } else {
169 return false;
170 }
171 }
172
173 if result.contains(Self::PR_NUMBER) {
174 if let Some(pr) = change.git.as_ref().and_then(|g| g.pr_number) {
175 result = result.replace(Self::PR_NUMBER, &pr.to_string());
176 } else {
177 return false;
178 }
179 }
180
181 if result.contains(Self::DETAILS) {
182 if let Some(details) = change.details.as_deref() {
183 result = result.replace(Self::DETAILS, details);
184 } else {
185 return false;
186 }
187 }
188
189 result = result.replace(Self::SUMMARY, &change.summary);
190 out.push_str(&result);
191
192 true
193 }
194
195 #[must_use]
198 pub fn first_variable_needing_forge_data(&self) -> Option<&'static str> {
199 [Self::PR_AUTHOR_LOGIN, Self::PR_NUMBER]
200 .into_iter()
201 .find(|&variable| self.0.contains(variable))
202 }
203}
204
205impl From<String> for ChangeTemplate {
206 fn from(template: String) -> Self {
207 Self(Cow::Owned(template))
208 }
209}
210
211impl From<&'static str> for ChangeTemplate {
212 fn from(template: &'static str) -> Self {
213 Self(Cow::Borrowed(template))
214 }
215}
216
217#[cfg(test)]
218mod test_release_notes {
219 use std::sync::Arc;
220
221 use changesets::UniqueId;
222 use pretty_assertions::assert_eq;
223
224 use super::*;
225 use crate::changes::{ChangeSource, ChangeType, GitInfo};
226
227 #[test]
228 fn simple_changes_before_complex() {
229 let changes = vec![
230 Change {
231 change_type: ChangeType::Feature,
232 original_source: ChangeSource::ChangeFile {
233 id: Arc::new(UniqueId::exact("")),
234 },
235 summary: "a complex feature".into(),
236 details: Some("some details".into()),
237 git: None,
238 },
239 Change {
240 change_type: ChangeType::Feature,
241 original_source: ChangeSource::ChangeFile {
242 id: Arc::new(UniqueId::exact("")),
243 },
244 summary: "a simple feature".into(),
245 details: None,
246 git: None,
247 },
248 Change {
249 change_type: ChangeType::Feature,
250 original_source: ChangeSource::ConventionalCommit {
251 description: String::new(),
252 },
253 summary: "a super simple feature".into(),
254 details: None,
255 git: None,
256 },
257 ];
258
259 let mut actions = ReleaseNotes::create_release(
260 &mut ReleaseNotes::default(),
261 Version::new(1, 0, 0, None),
262 &changes,
263 &package::Name::Default,
264 )
265 .expect("can create release notes");
266 assert_eq!(actions.len(), 1);
267
268 let action = actions.pop().unwrap();
269
270 let Action::CreateRelease(release) = action else {
271 panic!("expected release action");
272 };
273
274 assert_eq!(
275 release.notes,
276 "## Features\n\n- a simple feature\n- a super simple feature\n\n### a complex feature\n\nsome details"
277 );
278 }
279
280 #[test]
281 fn custom_templates() {
282 let change_templates = [
283 "* $summary by $commit_author_name ($commit_hash)", "###### $summary!!! $notAVariable\n\n$details", "* $summary", ]
287 .into_iter()
288 .map(ChangeTemplate::from)
289 .collect_vec();
290
291 let mut release_notes = ReleaseNotes {
292 change_templates,
293 changelog: Some(Changelog::new(
294 "CHANGELOG.md".into(),
295 "# My Changelog\n\n## 1.2.3 (previous version)".to_string(),
296 )),
297 ..ReleaseNotes::default()
298 };
299
300 let changes = &[
301 Change {
302 change_type: ChangeType::Feature,
303 original_source: ChangeSource::ChangeFile {
304 id: Arc::new(UniqueId::exact("")),
305 },
306 summary: "a complex feature".to_string(),
307 details: Some("some details".into()),
308 git: None,
309 },
310 Change {
311 change_type: ChangeType::Feature,
312 original_source: ChangeSource::ChangeFile {
313 id: Arc::new(UniqueId::exact("")),
314 },
315 summary: "a simple feature".into(),
316 details: None,
317 git: None,
318 },
319 Change {
320 change_type: ChangeType::Feature,
321 original_source: ChangeSource::ConventionalCommit {
322 description: String::new(),
323 },
324 summary: "a super simple feature".into(),
325 details: None,
326 git: Some(GitInfo {
327 author_name: "Sushi".into(),
328 hash: "1234".into(),
329 pr_number: None,
330 pr_author_login: None,
331 }),
332 },
333 ];
334
335 let mut actions = release_notes
336 .create_release(
337 Version::new(1, 3, 0, None),
338 changes,
339 &package::Name::Default,
340 )
341 .expect("can create release notes");
342 let Some(Action::CreateRelease(release)) = actions.pop() else {
343 panic!("expected release action");
344 };
345
346 assert_eq!(
347 release.notes,
348 "## Features\n\n* a simple feature\n* a super simple feature by Sushi (1234)\n\n###### a complex feature!!! $notAVariable\n\nsome details"
349 );
350
351 let Some(Action::WriteToFile { diff, .. }) = actions.pop() else {
352 panic!("expected write changelog action");
353 };
354
355 assert!(
356 diff.ends_with(
357 "\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"
358 ) );
360 }
361
362 #[test]
363 fn fall_back_to_built_in_templates() {
364 let change_templates = ["* $summary by $commit_author_name"]
365 .into_iter()
366 .map(ChangeTemplate::from)
367 .collect_vec(); let mut release_notes = ReleaseNotes {
369 change_templates,
370 ..ReleaseNotes::default()
371 };
372
373 let changes = &[
374 Change {
375 change_type: ChangeType::Feature,
376 original_source: ChangeSource::ChangeFile {
377 id: Arc::new(UniqueId::exact("")),
378 },
379 summary: "a complex feature".to_string(),
380 details: Some("some details".into()),
381 git: None,
382 },
383 Change {
384 change_type: ChangeType::Feature,
385 original_source: ChangeSource::ChangeFile {
386 id: Arc::new(UniqueId::exact("")),
387 },
388 summary: "a simple feature".into(),
389 details: None,
390 git: None,
391 },
392 Change {
393 change_type: ChangeType::Feature,
394 original_source: ChangeSource::ConventionalCommit {
395 description: String::new(),
396 },
397 summary: "a super simple feature".into(),
398 details: None,
399 git: Some(GitInfo {
400 author_name: "Sushi".into(),
401 hash: "1234".into(),
402 pr_number: None,
403 pr_author_login: None,
404 }),
405 },
406 ];
407
408 let mut actions = release_notes
409 .create_release(
410 Version::new(1, 3, 0, None),
411 changes,
412 &package::Name::Default,
413 )
414 .expect("can create release notes");
415 let Some(Action::CreateRelease(release)) = actions.pop() else {
416 panic!("expected release action");
417 };
418 assert_eq!(
419 release.notes,
420 "## Features\n\n- a simple feature\n* a super simple feature by Sushi\n\n### a complex feature\n\nsome details"
421 );
422 }
423
424 #[test]
425 fn change_files_with_commit_info_use_commit_templates() {
426 let change_templates = [
427 "* $summary by $commit_author_name ($commit_hash)\n\n$details", "* $summary by $commit_author_name ($commit_hash)", "### $summary\n\n$details", "* $summary", ]
432 .into_iter()
433 .map(ChangeTemplate::from)
434 .collect_vec();
435
436 let mut release_notes = ReleaseNotes {
437 change_templates,
438 ..ReleaseNotes::default()
439 };
440
441 let changes = &[
442 Change {
444 change_type: ChangeType::Feature,
445 original_source: ChangeSource::ChangeFile {
446 id: Arc::new(UniqueId::exact("committed-with-details")),
447 },
448 summary: "a committed feature with details".to_string(),
449 details: Some("some implementation details".into()),
450 git: Some(GitInfo {
451 author_name: "Alice".into(),
452 hash: "abc123".into(),
453 pr_number: None,
454 pr_author_login: None,
455 }),
456 },
457 Change {
459 change_type: ChangeType::Feature,
460 original_source: ChangeSource::ChangeFile {
461 id: Arc::new(UniqueId::exact("committed-simple")),
462 },
463 summary: "a committed simple feature".into(),
464 details: None,
465 git: Some(GitInfo {
466 author_name: "Bob".into(),
467 hash: "def456".into(),
468 pr_number: None,
469 pr_author_login: None,
470 }),
471 },
472 Change {
474 change_type: ChangeType::Feature,
475 original_source: ChangeSource::ChangeFile {
476 id: Arc::new(UniqueId::exact("uncommitted-with-details")),
477 },
478 summary: "an uncommitted feature with details".to_string(),
479 details: Some("some more details".into()),
480 git: None,
481 },
482 Change {
484 change_type: ChangeType::Feature,
485 original_source: ChangeSource::ChangeFile {
486 id: Arc::new(UniqueId::exact("uncommitted-simple")),
487 },
488 summary: "an uncommitted simple feature".into(),
489 details: None,
490 git: None,
491 },
492 Change {
494 change_type: ChangeType::Feature,
495 original_source: ChangeSource::ConventionalCommit {
496 description: "feat: conventional commit feature".into(),
497 },
498 summary: "conventional commit feature".into(),
499 details: None,
500 git: Some(GitInfo {
501 author_name: "Charlie".into(),
502 hash: "ghi789".into(),
503 pr_number: None,
504 pr_author_login: None,
505 }),
506 },
507 ];
508
509 let mut actions = release_notes
510 .create_release(
511 Version::new(2, 0, 0, None),
512 changes,
513 &package::Name::Default,
514 )
515 .expect("can create release notes");
516
517 let Some(Action::CreateRelease(release)) = actions.pop() else {
518 panic!("expected release action");
519 };
520
521 assert_eq!(
522 release.notes,
523 "## 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"
524 );
525 }
526
527 #[test]
528 fn github_style_templates_with_pr_and_login() {
529 let change_templates = [
530 "* $summary by @$pr_author_login in #$pr_number",
531 "* $summary by @$pr_author_login",
532 "* $summary",
533 ]
534 .into_iter()
535 .map(ChangeTemplate::from)
536 .collect_vec();
537
538 let mut release_notes = ReleaseNotes {
539 change_templates,
540 ..ReleaseNotes::default()
541 };
542
543 let changes = &[
544 Change {
546 change_type: ChangeType::Feature,
547 original_source: ChangeSource::ConventionalCommit {
548 description: String::new(),
549 },
550 summary: "add dark mode".into(),
551 details: None,
552 git: Some(GitInfo {
553 author_name: "Dale Seo".into(),
554 hash: "abc1234".into(),
555 pr_number: Some(42),
556 pr_author_login: Some("DaleSeo".into()),
557 }),
558 },
559 Change {
561 change_type: ChangeType::Feature,
562 original_source: ChangeSource::ConventionalCommit {
563 description: String::new(),
564 },
565 summary: "improve logging".into(),
566 details: None,
567 git: Some(GitInfo {
568 author_name: "Alice".into(),
569 hash: "def5678".into(),
570 pr_number: None,
571 pr_author_login: Some("alice".into()),
572 }),
573 },
574 Change {
576 change_type: ChangeType::Feature,
577 original_source: ChangeSource::ChangeFile {
578 id: Arc::new(UniqueId::exact("")),
579 },
580 summary: "uncommitted feature".into(),
581 details: None,
582 git: None,
583 },
584 Change {
586 change_type: ChangeType::Fix,
587 original_source: ChangeSource::ConventionalCommit {
588 description: String::new(),
589 },
590 summary: "fix crash".into(),
591 details: None,
592 git: Some(GitInfo {
593 author_name: "Bob".into(),
594 hash: "ghi9012".into(),
595 pr_number: None,
596 pr_author_login: None,
597 }),
598 },
599 ];
600
601 let mut actions = release_notes
602 .create_release(
603 Version::new(1, 1, 0, None),
604 changes,
605 &package::Name::Default,
606 )
607 .expect("can create release notes");
608
609 let Some(Action::CreateRelease(release)) = actions.pop() else {
610 panic!("expected release action");
611 };
612
613 assert_eq!(
614 release.notes,
615 "## 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"
616 );
617 }
618
619 #[test]
620 fn needs_forge_data_false_for_local_only_template() {
621 let notes = ReleaseNotes {
622 change_templates: vec![ChangeTemplate::from("* $summary by $commit_author_name")],
623 ..ReleaseNotes::default()
624 };
625 assert!(notes.first_variable_needing_forge_data().is_none());
626 }
627
628 #[test]
629 fn needs_forge_data_true_for_pr_number() {
630 let notes = ReleaseNotes {
631 change_templates: vec![ChangeTemplate::from("* $summary in #$pr_number")],
632 ..ReleaseNotes::default()
633 };
634 assert!(notes.first_variable_needing_forge_data().is_some());
635 }
636
637 #[test]
638 fn needs_forge_data_true_for_author_login() {
639 let notes = ReleaseNotes {
640 change_templates: vec![ChangeTemplate::from("* $summary by @$pr_author_login")],
641 ..ReleaseNotes::default()
642 };
643 assert!(notes.first_variable_needing_forge_data().is_some());
644 }
645
646 #[test]
647 fn needs_forge_data_false_for_default() {
648 let notes = ReleaseNotes::default();
649 assert!(notes.first_variable_needing_forge_data().is_none());
650 }
651}