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)
133fn parse_version_heading(line: &str) -> Option<String> {
134    let rest = line.strip_prefix("## ")?;
135    let version = if rest.starts_with('[') {
136        let end = rest.find(']')?;
137        &rest[1..end]
138    } else {
139        // Everything before an optional ` - <date>` suffix.
140        rest.split(" - ").next()?.trim()
141    };
142    if version.is_empty() {
143        None
144    } else {
145        Some(version.to_owned())
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    const SAMPLE: &str = "\
154# Changelog
155
156## [1.2.0] - 2024-03-01
157
158### Added
159- New feature
160
161## [1.1.0] - 2024-01-15
162
163### Fixed
164- Bug fix
165
166## 1.0.0
167
168Initial release.
169";
170
171    #[test]
172    fn latest_version_is_first_heading() {
173        let cl = Changelog::parse(SAMPLE);
174        assert_eq!(cl.latest_version(), Some("1.2.0"));
175    }
176
177    #[test]
178    fn section_body_is_trimmed() {
179        let cl = Changelog::parse(SAMPLE);
180        let body = cl.section("1.2.0").unwrap();
181        assert!(body.starts_with("### Added"));
182        assert!(body.ends_with("- New feature"));
183    }
184
185    #[test]
186    fn unbracketed_heading_is_parsed() {
187        let cl = Changelog::parse(SAMPLE);
188        assert!(cl.section("1.0.0").is_some());
189    }
190
191    #[test]
192    fn unknown_version_returns_none() {
193        let cl = Changelog::parse(SAMPLE);
194        assert!(cl.section("9.9.9").is_none());
195    }
196}