Skip to main content

knope_versioning/release_notes/
mod.rs

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/// Defines how release notes are handled for a package.
17#[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    /// Returns the first if any forge-specific variable in `Self::change_templates`
26    /// (for example, `$pr_number`, `$pr_author_login`).
27    #[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    /// Create new release notes for use in changelogs / forges.
35    ///
36    /// # Errors
37    ///
38    /// If the current date can't be formatted
39    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
128/// Create the title of a new release with no Markdown header level.
129///
130/// # Errors
131///
132/// If the current date can't be formatted
133fn 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    /// Returns any forge-specific variables int this template that require API calls to populate
196    /// (for example, `$pr_number`, `$pr_author_login`).
197    #[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)", // commit-only
284            "###### $summary!!! $notAVariable\n\n$details", // Complex change files, should skip #s
285            "* $summary",                                   // A fallback that's always applicable
286        ]
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            ) // Can't check the date
359        );
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(); // Only applies to commits
368        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", // commit + details
428            "* $summary by $commit_author_name ($commit_hash)",             // commit only
429            "### $summary\n\n$details",                                     // details only
430            "* $summary",                                                   // fallback
431        ]
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            // Committed change file with details - should use first template (commit + details)
443            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            // Committed change file without details - should use second template (commit only)
458            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            // Uncommitted change file with details - should use third template (details only)
473            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            // Uncommitted change file without details - should use fallback template
483            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            // Conventional commit - should use second template (commit only)
493            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            // Has PR info and login -> first template
545            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            // Has login but no PR -> second template
560            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            // No git info at all -> third template
575            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            // Has git info but no login/PR -> third template
585            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}