Skip to main content

abbaye/
changelog.rs

1use std::{collections::HashMap, path::PathBuf};
2
3use miette::{IntoDiagnostic, Result, miette};
4use serde::{Deserialize, Serialize};
5
6/// Configuration for [`ChangelogExtractor`].
7#[derive(Debug, Default, Clone, Deserialize, Serialize)]
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    /// Returns every section in the changelog as a map from version string to
43    /// Markdown body.  The file is read only once, making this efficient when
44    /// you need notes for multiple versions.
45    pub async fn all_sections(&self, config: ChangelogConfig) -> Result<HashMap<String, String>> {
46        let changelog = Changelog::load(config).await?;
47        Ok(changelog
48            .sections
49            .into_iter()
50            .map(|s| (s.version, s.body))
51            .collect())
52    }
53}
54
55// ── Parsing ───────────────────────────────────────────────────────────────────
56
57struct ChangelogSection {
58    version: String,
59    body: String,
60}
61
62struct Changelog {
63    sections: Vec<ChangelogSection>,
64}
65
66impl Changelog {
67    async fn load(config: ChangelogConfig) -> Result<Self> {
68        let path = config
69            .directory
70            .unwrap_or_else(|| PathBuf::from("."))
71            .join("CHANGELOG.md");
72
73        let content = tokio::fs::read_to_string(&path).await.into_diagnostic()?;
74
75        Ok(Self::parse(&content))
76    }
77
78    fn parse(content: &str) -> Self {
79        let mut sections: Vec<ChangelogSection> = Vec::new();
80        let mut current_version: Option<String> = None;
81        let mut current_body: Vec<&str> = Vec::new();
82
83        for line in content.lines() {
84            if let Some(version) = parse_version_heading(line) {
85                // Flush the previous section before starting a new one.
86                if let Some(ver) = current_version.take() {
87                    sections.push(ChangelogSection {
88                        version: ver,
89                        body: current_body.join("\n").trim().to_owned(),
90                    });
91                    current_body.clear();
92                }
93                current_version = Some(version);
94            } else if current_version.is_some() {
95                current_body.push(line);
96            }
97        }
98
99        // Flush the final section.
100        if let Some(ver) = current_version {
101            sections.push(ChangelogSection {
102                version: ver,
103                body: current_body.join("\n").trim().to_owned(),
104            });
105        }
106
107        Self { sections }
108    }
109
110    /// Returns the version string from the first (latest) section.
111    fn latest_version(&self) -> Option<&str> {
112        self.sections.first().map(|s| s.version.as_str())
113    }
114
115    /// Returns the body of the section whose version matches `version`.
116    fn section(&self, version: &str) -> Option<&str> {
117        self.sections
118            .iter()
119            .find(|s| s.version == version)
120            .map(|s| s.body.as_str())
121    }
122}
123
124/// Parses an `##`-level Markdown heading and returns the version string, or
125/// `None` if the heading does not look like a version entry.
126///
127/// Recognised formats:
128/// - `## [1.2.0] - 2024-01-15`  (Keep a Changelog bracketed)
129/// - `## [1.2.0]`               (bracketed, no date)
130/// - `## 1.2.0 - 2024-01-15`   (unbracketed with date)
131/// - `## 1.2.0`                 (unbracketed, no date)
132fn parse_version_heading(line: &str) -> Option<String> {
133    let rest = line.strip_prefix("## ")?;
134    let version = if rest.starts_with('[') {
135        let end = rest.find(']')?;
136        &rest[1..end]
137    } else {
138        // Everything before an optional ` - <date>` suffix.
139        rest.split(" - ").next()?.trim()
140    };
141    if version.is_empty() {
142        None
143    } else {
144        Some(version.to_owned())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    const SAMPLE: &str = "\
153# Changelog
154
155## [1.2.0] - 2024-03-01
156
157### Added
158- New feature
159
160## [1.1.0] - 2024-01-15
161
162### Fixed
163- Bug fix
164
165## 1.0.0
166
167Initial release.
168";
169
170    #[test]
171    fn latest_version_is_first_heading() {
172        let cl = Changelog::parse(SAMPLE);
173        assert_eq!(cl.latest_version(), Some("1.2.0"));
174    }
175
176    #[test]
177    fn section_body_is_trimmed() {
178        let cl = Changelog::parse(SAMPLE);
179        let body = cl.section("1.2.0").unwrap();
180        assert!(body.starts_with("### Added"));
181        assert!(body.ends_with("- New feature"));
182    }
183
184    #[test]
185    fn unbracketed_heading_is_parsed() {
186        let cl = Changelog::parse(SAMPLE);
187        assert!(cl.section("1.0.0").is_some());
188    }
189
190    #[test]
191    fn unknown_version_returns_none() {
192        let cl = Changelog::parse(SAMPLE);
193        assert!(cl.section("9.9.9").is_none());
194    }
195}