knope_versioning/release_notes/
changelog.rs1use std::{fmt::Display, str::FromStr};
2
3use itertools::Itertools;
4use relative_path::RelativePathBuf;
5use thiserror::Error;
6use time::{Date, macros::format_description};
7
8use crate::{package, release_notes::Release, semver::Version};
9
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct Changelog {
12 pub path: RelativePathBuf,
14 pub content: String,
16 release_header_level: HeaderLevel,
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21enum HeaderLevel {
22 H1,
23 H2,
24}
25
26impl HeaderLevel {
27 const fn as_str(self) -> &'static str {
28 match self {
29 Self::H1 => "#",
30 Self::H2 => "##",
31 }
32 }
33}
34
35impl Display for HeaderLevel {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.write_str(self.as_str())
38 }
39}
40
41impl Changelog {
42 #[must_use]
43 pub fn new(path: RelativePathBuf, content: String) -> Self {
44 let release_header_level = content
45 .lines()
46 .filter(|line| line.starts_with('#'))
47 .nth(1)
48 .and_then(|header| {
49 if header.starts_with("##") {
50 Some(HeaderLevel::H2)
51 } else if header.starts_with('#') {
52 Some(HeaderLevel::H1)
53 } else {
54 None
55 }
56 })
57 .unwrap_or(HeaderLevel::H2);
58 Changelog {
59 path,
60 content,
61 release_header_level,
62 }
63 }
64
65 #[must_use]
67 pub fn get_release(&self, version: &Version, package_name: &package::Name) -> Option<Release> {
68 let expected_header_start = format!(
69 "{release_header_level} {version}",
70 release_header_level = self.release_header_level
71 );
72
73 let mut lines = self.content.lines();
74 let (title, version) = loop {
75 let line = lines.next()?;
76 if !line.starts_with(&expected_header_start) {
77 continue;
78 }
79 let Ok((header_level, title_version, _)) = parse_title(line) else {
80 continue;
81 };
82 if header_level == self.release_header_level && *version == title_version {
83 break (
84 line.trim_start_matches('#').trim().to_string(),
86 title_version,
87 );
88 }
89 };
90 let notes = lines
91 .take_while(|line| {
92 !line.starts_with(&format!(
93 "{release_header_level} ",
95 release_header_level = self.release_header_level
96 ))
97 })
98 .map(|line| match self.release_header_level {
99 HeaderLevel::H1 => line,
100 HeaderLevel::H2 => reduce_header_level(line),
101 })
102 .join("\n");
103 (!notes.is_empty()).then_some(Release {
104 title,
105 version,
106 notes,
107 package_name: package_name.clone(),
108 })
109 }
110
111 #[must_use]
113 pub fn with_release(&mut self, release: &Release) -> String {
114 let mut not_written = true;
115 let new_changes = format!(
116 "{header_level} {title}\n\n{body}",
117 header_level = self.release_header_level,
118 title = release.title,
119 body = release
120 .notes
121 .lines()
122 .map(|line| {
123 if line.starts_with('#') && self.release_header_level == HeaderLevel::H2 {
125 format!("#{line}")
126 } else {
127 line.to_string()
128 }
129 })
130 .join("\n")
131 );
132 let mut new_content = String::with_capacity(self.content.len() + new_changes.len());
133
134 for line in self.content.lines() {
135 if not_written && parse_title(line).is_ok() {
136 new_content.push_str(&new_changes);
138 new_content.push_str("\n\n");
139 not_written = false;
140 }
141 new_content.push_str(line);
142 new_content.push('\n');
143 }
144
145 if not_written {
146 new_content.push_str(&new_changes);
147 }
148
149 if (self.content.ends_with('\n') || self.content.is_empty()) && !new_content.ends_with('\n')
150 {
151 new_content.push('\n');
153 }
154
155 self.content = new_content;
156 new_changes
157 }
158}
159
160fn parse_title(title: &str) -> Result<(HeaderLevel, Version, Option<Date>), ParseError> {
161 let mut parts = title.split_ascii_whitespace();
162 let header_level = match parts.next() {
163 Some("##") => HeaderLevel::H2,
164 Some("#") => HeaderLevel::H1,
165 _ => return Err(ParseError::HeaderLevel),
166 };
167 let version = parts.next().ok_or(ParseError::MissingVersion)?;
168 let version = Version::from_str(version).map_err(|_| ParseError::MissingVersion)?;
169 let mut date = None;
170 for part in parts {
171 let part = part.trim_start_matches('(').trim_end_matches(')');
172 date = Date::parse(part, format_description!("[year]-[month]-[day]")).ok();
173 if date.is_some() {
174 break;
175 }
176 }
177 Ok((header_level, version, date))
178}
179
180fn reduce_header_level(line: &str) -> &str {
181 if line.starts_with("##") {
182 #[allow(clippy::indexing_slicing)] &line[1..] } else {
185 line
186 }
187}
188
189#[cfg(test)]
190#[allow(clippy::unwrap_used)]
191mod test_parse_title {
192 use time::macros::date;
193
194 use super::*;
195
196 #[test]
197 fn no_date() {
198 let title = "## 0.1.2";
199 let (header_level, version, date) = parse_title(title).unwrap();
200 assert_eq!(header_level, HeaderLevel::H2);
201 assert_eq!(version, Version::new(0, 1, 2, None));
202 assert!(date.is_none());
203 }
204
205 #[test]
206 fn with_date() {
207 let title = "## 0.1.2 (2023-05-02)";
208 let (header_level, version, date) = parse_title(title).unwrap();
209 assert_eq!(header_level, HeaderLevel::H2);
210 assert_eq!(version, Version::new(0, 1, 2, None));
211 assert_eq!(date, Some(date!(2023 - 05 - 02)));
212 }
213
214 #[test]
215 fn no_version() {
216 let title = "## 2023-05-02";
217 let result = parse_title(title);
218 assert!(result.is_err());
219 }
220
221 #[test]
222 fn bad_version() {
223 let title = "## sad";
224 let result = parse_title(title);
225 assert!(result.is_err());
226 }
227
228 #[test]
229 fn h1() {
230 let title = "# 0.1.2 (2023-05-02)";
231 let (header_level, version, date) = parse_title(title).unwrap();
232 assert_eq!(header_level, HeaderLevel::H1);
233 assert_eq!(version, Version::new(0, 1, 2, None));
234 assert_eq!(date, Some(date!(2023 - 05 - 02)));
235 }
236}
237
238#[derive(Clone, Debug, Eq, PartialEq, Error)]
239#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
240pub enum ParseError {
241 #[error("Missing version")]
242 #[cfg_attr(
243 feature = "miette",
244 diagnostic(
245 code = "changelog::missing_version",
246 help = "The expected changelog format is very particular, a release title must start with the
247 semantic version immediately after the header level. For example: `## 0.1.0 - 2020-12-25"
248 )
249 )]
250 MissingVersion,
251 #[error("Bad header level")]
252 #[cfg_attr(
253 feature = "miette",
254 diagnostic(
255 code = "changelog::header_level",
256 help = "The expected changelog format is very particular, a release title be header level 1
257 (#) or 2 (##). For example: `## 0.1.0 - 2020-12-25"
258 )
259 )]
260 HeaderLevel,
261}