Skip to main content

knope_versioning/release_notes/
changelog.rs

1use 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    /// The path to the CHANGELOG file
13    pub path: RelativePathBuf,
14    /// The content that's been written to `path`
15    pub content: String,
16    /// The header level of the title of each release (the version + date)
17    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    /// Find a release matching `version`, if any, within the changelog.
66    #[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                    // Release titles should not be markdown formatted
85                    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                    // Next version
94                    "{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    /// Update `self.content` with the new release, return the diff being applied.
112    #[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                    // Release notes are at H1, we need to format them properly for this changelog
124                    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                // Insert new changes before the next release in the changelog
137                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            // Preserve white space at end of file
152            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)] // Just checked len above
183        &line[1..] // Reduce header level by one
184    } 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}