at 2f4eb91
use std::{collections::HashMap, path::PathBuf}; use miette::{IntoDiagnostic, Result, miette}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Configuration for [`ChangelogExtractor`]. #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)] pub struct ChangelogConfig { /// Directory that contains the `CHANGELOG.md`. /// Defaults to the current working directory. pub directory: Option<PathBuf>, } /// Reads version information from a `CHANGELOG.md` file. /// /// Use [`ChangelogExtractor::section`] to retrieve /// the full release notes for a specific version. pub struct ChangelogExtractor; impl ChangelogExtractor { /// Returns the latest (topmost) version found in the changelog. pub async fn latest_version(&self, config: ChangelogConfig) -> Result<String> { let changelog = Changelog::load(config).await?; changelog .latest_version() .map(str::to_owned) .ok_or_else(|| miette!("no version headings found in CHANGELOG.md")) } /// Returns the body of the changelog section for `version`. /// /// `version` should match the string as it appears in the heading, without /// brackets (e.g. `"1.2.0"` matches both `## [1.2.0]` and `## 1.2.0`). pub async fn section(&self, config: ChangelogConfig, version: &str) -> Result<String> { let changelog = Changelog::load(config).await?; changelog .section(version) .map(str::to_owned) .ok_or_else(|| miette!("version {version} not found in CHANGELOG.md")) } /// Returns every section in the changelog as a map from version string to /// Markdown body. The file is read only once, making this efficient when /// you need notes for multiple versions. pub async fn all_sections(&self, config: ChangelogConfig) -> Result<HashMap<String, String>> { let changelog = Changelog::load(config).await?; Ok(changelog .sections .into_iter() .map(|s| (s.version, s.body)) .collect()) } } // ── Parsing ─────────────────────────────────────────────────────────────────── struct ChangelogSection { version: String, body: String, } struct Changelog { sections: Vec<ChangelogSection>, } impl Changelog { async fn load(config: ChangelogConfig) -> Result<Self> { let path = config .directory .unwrap_or_else(|| PathBuf::from(".")) .join("CHANGELOG.md"); let content = tokio::fs::read_to_string(&path).await.into_diagnostic()?; Ok(Self::parse(&content)) } fn parse(content: &str) -> Self { let mut sections: Vec<ChangelogSection> = Vec::new(); let mut current_version: Option<String> = None; let mut current_body: Vec<&str> = Vec::new(); for line in content.lines() { if let Some(version) = parse_version_heading(line) { // Flush the previous section before starting a new one. if let Some(ver) = current_version.take() { sections.push(ChangelogSection { version: ver, body: current_body.join("\n").trim().to_owned(), }); current_body.clear(); } current_version = Some(version); } else if current_version.is_some() { current_body.push(line); } } // Flush the final section. if let Some(ver) = current_version { sections.push(ChangelogSection { version: ver, body: current_body.join("\n").trim().to_owned(), }); } Self { sections } } /// Returns the version string from the first (latest) section. fn latest_version(&self) -> Option<&str> { self.sections.first().map(|s| s.version.as_str()) } /// Returns the body of the section whose version matches `version`. fn section(&self, version: &str) -> Option<&str> { self.sections .iter() .find(|s| s.version == version) .map(|s| s.body.as_str()) } } /// Parses an `##`-level Markdown heading and returns the version string, or /// `None` if the heading does not look like a version entry. /// /// Recognised formats: /// - `## [1.2.0] - 2024-01-15` (Keep a Changelog bracketed) /// - `## [1.2.0]` (bracketed, no date) /// - `## 1.2.0 - 2024-01-15` (unbracketed with date) /// - `## 1.2.0` (unbracketed, no date) fn parse_version_heading(line: &str) -> Option<String> { let rest = line.strip_prefix("## ")?; let version = if rest.starts_with('[') { let end = rest.find(']')?; &rest[1..end] } else { // Everything before an optional ` - <date>` suffix. rest.split(" - ").next()?.trim() }; if version.is_empty() { None } else { Some(version.to_owned()) } } #[cfg(test)] mod tests { use super::*; const SAMPLE: &str = "\ # Changelog ## [1.2.0] - 2024-03-01 ### Added - New feature ## [1.1.0] - 2024-01-15 ### Fixed - Bug fix ## 1.0.0 Initial release. "; #[test] fn latest_version_is_first_heading() { let cl = Changelog::parse(SAMPLE); assert_eq!(cl.latest_version(), Some("1.2.0")); } #[test] fn section_body_is_trimmed() { let cl = Changelog::parse(SAMPLE); let body = cl.section("1.2.0").unwrap(); assert!(body.starts_with("### Added")); assert!(body.ends_with("- New feature")); } #[test] fn unbracketed_heading_is_parsed() { let cl = Changelog::parse(SAMPLE); assert!(cl.section("1.0.0").is_some()); } #[test] fn unknown_version_returns_none() { let cl = Changelog::parse(SAMPLE); assert!(cl.section("9.9.9").is_none()); } }