knope_versioning/release_notes/
mod.rs1use 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#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct ReleaseNotes {
18 pub sections: Sections,
19 pub changelog: Option<Changelog>,
20}
21
22impl ReleaseNotes {
23 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
151fn 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}