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::{macros::format_description, OffsetDateTime};
8
9use crate::{changes::Change, package, semver::Version, Action};
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(feature = "miette", diagnostic(
81    code(release_notes::time_format),
82    help("This is probably a bug with knope, please file an issue at https://github.com/knope-dev/knope")
83))]
84pub struct TimeError(#[from] time::error::Format);
85
86#[derive(Clone, Debug, Eq, PartialEq)]
87enum ChangeDescription {
88    Simple(String),
89    Complex(String, String),
90}
91
92impl Ord for ChangeDescription {
93    fn cmp(&self, other: &Self) -> Ordering {
94        match (self, other) {
95            (Self::Simple(_), Self::Complex(_, _)) => Ordering::Less,
96            (Self::Complex(_, _), Self::Simple(_)) => Ordering::Greater,
97            _ => Ordering::Equal,
98        }
99    }
100}
101
102impl PartialOrd for ChangeDescription {
103    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
104        Some(self.cmp(other))
105    }
106}
107
108impl From<&Change> for ChangeDescription {
109    fn from(change: &Change) -> Self {
110        let mut lines = change
111            .description
112            .trim()
113            .lines()
114            .skip_while(|it| it.is_empty());
115        let summary: String = lines
116            .next()
117            .unwrap_or_default()
118            .chars()
119            .skip_while(|it| *it == '#' || *it == ' ')
120            .collect();
121        let body: String = lines.skip_while(|it| it.is_empty()).join("\n");
122        if body.is_empty() {
123            Self::Simple(summary)
124        } else {
125            Self::Complex(summary, body)
126        }
127    }
128}
129
130fn build_body(changes: Vec<ChangeDescription>) -> String {
131    let mut body = String::new();
132    let mut changes = changes.into_iter().peekable();
133    while let Some(change) = changes.next() {
134        match change {
135            ChangeDescription::Simple(summary) => {
136                body.push_str(&format!("- {summary}"));
137            }
138            ChangeDescription::Complex(summary, details) => {
139                body.push_str(&format!("### {summary}\n\n{details}"));
140            }
141        }
142        match changes.peek() {
143            Some(ChangeDescription::Simple(_)) => body.push('\n'),
144            Some(ChangeDescription::Complex(_, _)) => body.push_str("\n\n"),
145            None => (),
146        }
147    }
148    body
149}
150
151/// Create the title of a new release with no Markdown header level.
152///
153/// # Errors
154///
155/// If the current date can't be formatted
156fn release_title(version: &Version) -> Result<String, TimeError> {
157    let format = format_description!("[year]-[month]-[day]");
158    let date_str = OffsetDateTime::now_utc().date().format(&format)?;
159    Ok(format!("{version} ({date_str})"))
160}
161
162#[cfg(test)]
163mod test_change_description {
164    use pretty_assertions::assert_eq;
165
166    use super::*;
167    use crate::changes::{ChangeSource, ChangeType};
168
169    #[test]
170    fn conventional_commit() {
171        let change = Change {
172            change_type: ChangeType::Feature,
173            original_source: ChangeSource::ConventionalCommit(String::new()),
174            description: "a feature".into(),
175        };
176        let description = ChangeDescription::from(&change);
177        assert_eq!(
178            description,
179            ChangeDescription::Simple("a feature".to_string())
180        );
181    }
182
183    #[test]
184    fn simple_changeset() {
185        let change = Change {
186            change_type: ChangeType::Feature,
187            original_source: ChangeSource::ConventionalCommit(String::new()),
188            description: "# a feature\n\n\n\n".into(),
189        };
190        let description = ChangeDescription::from(&change);
191        assert_eq!(
192            description,
193            ChangeDescription::Simple("a feature".to_string())
194        );
195    }
196
197    #[test]
198    fn complex_changeset() {
199        let change = Change {
200            original_source: ChangeSource::ConventionalCommit(String::new()),
201            change_type: ChangeType::Feature,
202            description: "# a feature\n\nwith details\n\n- first\n- second".into(),
203        };
204        let description = ChangeDescription::from(&change);
205        assert_eq!(
206            description,
207            ChangeDescription::Complex(
208                "a feature".to_string(),
209                "with details\n\n- first\n- second".to_string()
210            )
211        );
212    }
213}