knope_versioning/release_notes/
mod.rs

1use std::cmp::Ordering;
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/// Defines how release notes are handled for a package.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct ReleaseNotes {
18    pub sections: Sections,
19    pub changelog: Option<Changelog>,
20}
21
22impl ReleaseNotes {
23    /// Create new release notes for use in changelogs / forges.
24    ///
25    /// # Errors
26    ///
27    /// If the current date can't be formatted
28    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                body.push_str(&format!("- {summary}"));
142            }
143            ChangeDescription::Complex(summary, details) => {
144                body.push_str(&format!("### {summary}\n\n{details}"));
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
156/// Create the title of a new release with no Markdown header level.
157///
158/// # Errors
159///
160/// If the current date can't be formatted
161fn 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}