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    /// Create new release notes for use in changelogs / forges.
26    ///
27    /// # Errors
28    ///
29    /// If the current date can't be formatted
30    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
119/// Create the title of a new release with no Markdown header level.
120///
121/// # Errors
122///
123/// If the current date can't be formatted
124fn 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                // Change doesn't have commit info, template not applicable
147                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)", // commit-only
245            "###### $summary!!! $notAVariable\n\n$details", // Complex change files, should skip #s
246            "* $summary",                                   // A fallback that's always applicable
247        ]
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            ) // Can't check the date
318        );
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(); // Only applies to commits
327        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", // commit + details
385            "* $summary by $commit_author_name ($commit_hash)",             // commit only
386            "### $summary\n\n$details",                                     // details only
387            "* $summary",                                                   // fallback
388        ]
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            // Committed change file with details - should use first template (commit + details)
400            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            // Committed change file without details - should use second template (commit only)
413            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            // Uncommitted change file with details - should use third template (details only)
426            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            // Uncommitted change file without details - should use fallback template
436            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            // Conventional commit - should use second template (commit only)
446            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}