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