Skip to main content

abbaye/
changelog.rs

1use std::{collections::HashMap, path::PathBuf};
2
3use miette::{IntoDiagnostic, Result, miette};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7/// Configuration for [`ChangelogExtractor`].
8#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
9pub struct ChangelogConfig {
10    /// Directory that contains the `CHANGELOG.md`.
11    /// Defaults to the current working directory.
12    pub directory: Option<PathBuf>,
13}
14
15/// Reads version information from a `CHANGELOG.md` file.
16///
17/// Use [`ChangelogExtractor::section`] to retrieve
18/// the full release notes for a specific version.
19pub struct ChangelogExtractor;
20
21impl ChangelogExtractor {
22    /// Returns the latest (topmost) version found in the changelog.
23    pub async fn latest_version(&self, config: ChangelogConfig) -> Result<String> {
24        let changelog = Changelog::load(config).await?;
25        changelog
26            .latest_version()
27            .map(str::to_owned)
28            .ok_or_else(|| miette!("no version headings found in CHANGELOG.md"))
29    }
30
31    /// Returns the body of the changelog section for `version`.
32    ///
33    /// `version` should match the string as it appears in the heading, without
34    /// brackets (e.g. `"1.2.0"` matches both `## [1.2.0]` and `## 1.2.0`).
35    pub async fn section(&self, config: ChangelogConfig, version: &str) -> Result<String> {
36        let changelog = Changelog::load(config).await?;
37        changelog
38            .section(version)
39            .map(str::to_owned)
40            .ok_or_else(|| miette!("version {version} not found in CHANGELOG.md"))
41    }
42
43    /// Returns every section in the changelog as a map from version string to
44    /// Markdown body.  The file is read only once, making this efficient when
45    /// you need notes for multiple versions.
46    pub async fn all_sections(&self, config: ChangelogConfig) -> Result<HashMap<String, String>> {
47        let changelog = Changelog::load(config).await?;
48        Ok(changelog
49            .sections
50            .into_iter()
51            .map(|s| (s.version, s.body))
52            .collect())
53    }
54}
55
56// ── Parsing ───────────────────────────────────────────────────────────────────
57
58struct ChangelogSection {
59    version: String,
60    body: String,
61}
62
63struct Changelog {
64    sections: Vec<ChangelogSection>,
65}
66
67impl Changelog {
68    async fn load(config: ChangelogConfig) -> Result<Self> {
69        let path = config
70            .directory
71            .unwrap_or_else(|| PathBuf::from("."))
72            .join("CHANGELOG.md");
73
74        let content = tokio::fs::read_to_string(&path).await.into_diagnostic()?;
75
76        Ok(Self::parse(&content))
77    }
78
79    fn parse(content: &str) -> Self {
80        let mut sections: Vec<ChangelogSection> = Vec::new();
81        let mut current_version: Option<String> = None;
82        let mut current_body: Vec<&str> = Vec::new();
83
84        for line in content.lines() {
85            if let Some(version) = parse_version_heading(line) {
86                // Flush the previous section before starting a new one.
87                if let Some(ver) = current_version.take() {
88                    sections.push(ChangelogSection {
89                        version: ver,
90                        body: current_body.join("\n").trim().to_owned(),
91                    });
92                    current_body.clear();
93                }
94                current_version = Some(version);
95            } else if current_version.is_some() {
96                current_body.push(line);
97            }
98        }
99
100        // Flush the final section.
101        if let Some(ver) = current_version {
102            sections.push(ChangelogSection {
103                version: ver,
104                body: current_body.join("\n").trim().to_owned(),
105            });
106        }
107
108        Self { sections }
109    }
110
111    /// Returns the version string from the first (latest) section.
112    fn latest_version(&self) -> Option<&str> {
113        self.sections.first().map(|s| s.version.as_str())
114    }
115
116    /// Returns the body of the section whose version matches `version`.
117    fn section(&self, version: &str) -> Option<&str> {
118        self.sections
119            .iter()
120            .find(|s| s.version == version)
121            .map(|s| s.body.as_str())
122    }
123}
124
125/// Parses an `##`-level Markdown heading and returns the version string, or
126/// `None` if the heading does not look like a version entry.
127///
128/// Recognised formats:
129/// - `## [1.2.0] - 2024-01-15`  (Keep a Changelog bracketed)
130/// - `## [1.2.0]`               (bracketed, no date)
131/// - `## 1.2.0 - 2024-01-15`   (unbracketed with date)
132/// - `## 1.2.0`                 (unbracketed, no date)
133///
134/// A leading `v` prefix is silently stripped from the version string so that
135/// `## [v1.2.0]` produces `"1.2.0"`, matching the convention used by version
136/// extractors with `tag_prefix = "v"`.
137fn parse_version_heading(line: &str) -> Option<String> {
138    let rest = line.strip_prefix("## ")?;
139    let version = if rest.starts_with('[') {
140        let end = rest.find(']')?;
141        rest[1..end].trim_start_matches('v')
142    } else {
143        // Everything before an optional ` - <date>` suffix.
144        rest.split(" - ").next()?.trim().trim_start_matches('v')
145    };
146    if version.is_empty() {
147        None
148    } else {
149        Some(version.to_owned())
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    const SAMPLE: &str = "\
158# Changelog
159
160## [1.2.0] - 2024-03-01
161
162### Added
163- New feature
164
165## [1.1.0] - 2024-01-15
166
167### Fixed
168- Bug fix
169
170## 1.0.0
171
172Initial release.
173";
174
175    #[test]
176    fn latest_version_is_first_heading() {
177        let cl = Changelog::parse(SAMPLE);
178        assert_eq!(cl.latest_version(), Some("1.2.0"));
179    }
180
181    #[test]
182    fn section_body_is_trimmed() {
183        let cl = Changelog::parse(SAMPLE);
184        let body = cl.section("1.2.0").unwrap();
185        assert!(body.starts_with("### Added"));
186        assert!(body.ends_with("- New feature"));
187    }
188
189    #[test]
190    fn unbracketed_heading_is_parsed() {
191        let cl = Changelog::parse(SAMPLE);
192        assert!(cl.section("1.0.0").is_some());
193    }
194
195    #[test]
196    fn unknown_version_returns_none() {
197        let cl = Changelog::parse(SAMPLE);
198        assert!(cl.section("9.9.9").is_none());
199    }
200
201    const PREFIXED: &str = "\
202# Changelog
203
204## [v2.0.0] - 2025-06-01
205
206### Added
207- Major feature
208
209## v1.5.0 - 2025-01-01
210
211### Fixed
212- Important fix
213";
214
215    #[test]
216    fn strips_v_prefix_from_bracketed_heading() {
217        let cl = Changelog::parse(PREFIXED);
218        assert!(cl.section("2.0.0").is_some());
219    }
220
221    #[test]
222    fn strips_v_prefix_from_unbracketed_heading() {
223        let cl = Changelog::parse(PREFIXED);
224        assert!(cl.section("1.5.0").is_some());
225    }
226
227    #[test]
228    fn v_prefixed_latest_version() {
229        let cl = Changelog::parse(PREFIXED);
230        assert_eq!(cl.latest_version(), Some("2.0.0"));
231    }
232}