Skip to main content

abbaye/
changelog.rs

1use std::path::PathBuf;
2
3use miette::{IntoDiagnostic, Result, miette};
4use serde::Deserialize;
5
6/// Configuration for [`ChangelogExtractor`].
7#[derive(Debug, Default, Clone, Deserialize)]
8pub struct ChangelogConfig {
9    /// Directory that contains the `CHANGELOG.md`.
10    /// Defaults to the current working directory.
11    pub directory: Option<PathBuf>,
12}
13
14/// Reads version information from a `CHANGELOG.md` file.
15///
16/// Use [`ChangelogExtractor::section`] to retrieve
17/// the full release notes for a specific version.
18pub struct ChangelogExtractor;
19
20impl ChangelogExtractor {
21    /// Returns the latest (topmost) version found in the changelog.
22    pub async fn latest_version(&self, config: ChangelogConfig) -> Result<String> {
23        let changelog = Changelog::load(config).await?;
24        changelog
25            .latest_version()
26            .map(str::to_owned)
27            .ok_or_else(|| miette!("no version headings found in CHANGELOG.md"))
28    }
29
30    /// Returns the body of the changelog section for `version`.
31    ///
32    /// `version` should match the string as it appears in the heading, without
33    /// brackets (e.g. `"1.2.0"` matches both `## [1.2.0]` and `## 1.2.0`).
34    pub async fn section(&self, config: ChangelogConfig, version: &str) -> Result<String> {
35        let changelog = Changelog::load(config).await?;
36        changelog
37            .section(version)
38            .map(str::to_owned)
39            .ok_or_else(|| miette!("version {version} not found in CHANGELOG.md"))
40    }
41}
42
43// ── Parsing ───────────────────────────────────────────────────────────────────
44
45struct ChangelogSection {
46    version: String,
47    body: String,
48}
49
50struct Changelog {
51    sections: Vec<ChangelogSection>,
52}
53
54impl Changelog {
55    async fn load(config: ChangelogConfig) -> Result<Self> {
56        let path = config
57            .directory
58            .unwrap_or_else(|| PathBuf::from("."))
59            .join("CHANGELOG.md");
60
61        let content = tokio::fs::read_to_string(&path).await.into_diagnostic()?;
62
63        Ok(Self::parse(&content))
64    }
65
66    fn parse(content: &str) -> Self {
67        let mut sections: Vec<ChangelogSection> = Vec::new();
68        let mut current_version: Option<String> = None;
69        let mut current_body: Vec<&str> = Vec::new();
70
71        for line in content.lines() {
72            if let Some(version) = parse_version_heading(line) {
73                // Flush the previous section before starting a new one.
74                if let Some(ver) = current_version.take() {
75                    sections.push(ChangelogSection {
76                        version: ver,
77                        body: current_body.join("\n").trim().to_owned(),
78                    });
79                    current_body.clear();
80                }
81                current_version = Some(version);
82            } else if current_version.is_some() {
83                current_body.push(line);
84            }
85        }
86
87        // Flush the final section.
88        if let Some(ver) = current_version {
89            sections.push(ChangelogSection {
90                version: ver,
91                body: current_body.join("\n").trim().to_owned(),
92            });
93        }
94
95        Self { sections }
96    }
97
98    /// Returns the version string from the first (latest) section.
99    fn latest_version(&self) -> Option<&str> {
100        self.sections.first().map(|s| s.version.as_str())
101    }
102
103    /// Returns the body of the section whose version matches `version`.
104    fn section(&self, version: &str) -> Option<&str> {
105        self.sections
106            .iter()
107            .find(|s| s.version == version)
108            .map(|s| s.body.as_str())
109    }
110}
111
112/// Parses an `##`-level Markdown heading and returns the version string, or
113/// `None` if the heading does not look like a version entry.
114///
115/// Recognised formats:
116/// - `## [1.2.0] - 2024-01-15`  (Keep a Changelog bracketed)
117/// - `## [1.2.0]`               (bracketed, no date)
118/// - `## 1.2.0 - 2024-01-15`   (unbracketed with date)
119/// - `## 1.2.0`                 (unbracketed, no date)
120fn parse_version_heading(line: &str) -> Option<String> {
121    let rest = line.strip_prefix("## ")?;
122    let version = if rest.starts_with('[') {
123        let end = rest.find(']')?;
124        &rest[1..end]
125    } else {
126        // Everything before an optional ` - <date>` suffix.
127        rest.split(" - ").next()?.trim()
128    };
129    if version.is_empty() {
130        None
131    } else {
132        Some(version.to_owned())
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    const SAMPLE: &str = "\
141# Changelog
142
143## [1.2.0] - 2024-03-01
144
145### Added
146- New feature
147
148## [1.1.0] - 2024-01-15
149
150### Fixed
151- Bug fix
152
153## 1.0.0
154
155Initial release.
156";
157
158    #[test]
159    fn latest_version_is_first_heading() {
160        let cl = Changelog::parse(SAMPLE);
161        assert_eq!(cl.latest_version(), Some("1.2.0"));
162    }
163
164    #[test]
165    fn section_body_is_trimmed() {
166        let cl = Changelog::parse(SAMPLE);
167        let body = cl.section("1.2.0").unwrap();
168        assert!(body.starts_with("### Added"));
169        assert!(body.ends_with("- New feature"));
170    }
171
172    #[test]
173    fn unbracketed_heading_is_parsed() {
174        let cl = Changelog::parse(SAMPLE);
175        assert!(cl.section("1.0.0").is_some());
176    }
177
178    #[test]
179    fn unknown_version_returns_none() {
180        let cl = Changelog::parse(SAMPLE);
181        assert!(cl.section("9.9.9").is_none());
182    }
183}