1use std::path::PathBuf;
2
3use miette::{IntoDiagnostic, Result, miette};
4use serde::Deserialize;
5
6#[derive(Debug, Default, Clone, Deserialize)]
8pub struct ChangelogConfig {
9 pub directory: Option<PathBuf>,
12}
13
14pub struct ChangelogExtractor;
19
20impl ChangelogExtractor {
21 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 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
43struct ChangelogSection {
46 version: String,
47 body: String,
48}
49
50struct Changelog {
51 sections: Vec<ChangelogSection>,
52}
53
54impl Changelog {
55 async fn load(config: ChangelogConfig) -> Result<Self> {
56 let path = config
57 .directory
58 .unwrap_or_else(|| PathBuf::from("."))
59 .join("CHANGELOG.md");
60
61 let content = tokio::fs::read_to_string(&path).await.into_diagnostic()?;
62
63 Ok(Self::parse(&content))
64 }
65
66 fn parse(content: &str) -> Self {
67 let mut sections: Vec<ChangelogSection> = Vec::new();
68 let mut current_version: Option<String> = None;
69 let mut current_body: Vec<&str> = Vec::new();
70
71 for line in content.lines() {
72 if let Some(version) = parse_version_heading(line) {
73 if let Some(ver) = current_version.take() {
75 sections.push(ChangelogSection {
76 version: ver,
77 body: current_body.join("\n").trim().to_owned(),
78 });
79 current_body.clear();
80 }
81 current_version = Some(version);
82 } else if current_version.is_some() {
83 current_body.push(line);
84 }
85 }
86
87 if let Some(ver) = current_version {
89 sections.push(ChangelogSection {
90 version: ver,
91 body: current_body.join("\n").trim().to_owned(),
92 });
93 }
94
95 Self { sections }
96 }
97
98 fn latest_version(&self) -> Option<&str> {
100 self.sections.first().map(|s| s.version.as_str())
101 }
102
103 fn section(&self, version: &str) -> Option<&str> {
105 self.sections
106 .iter()
107 .find(|s| s.version == version)
108 .map(|s| s.body.as_str())
109 }
110}
111
112fn parse_version_heading(line: &str) -> Option<String> {
121 let rest = line.strip_prefix("## ")?;
122 let version = if rest.starts_with('[') {
123 let end = rest.find(']')?;
124 &rest[1..end]
125 } else {
126 rest.split(" - ").next()?.trim()
128 };
129 if version.is_empty() {
130 None
131 } else {
132 Some(version.to_owned())
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 const SAMPLE: &str = "\
141# Changelog
142
143## [1.2.0] - 2024-03-01
144
145### Added
146- New feature
147
148## [1.1.0] - 2024-01-15
149
150### Fixed
151- Bug fix
152
153## 1.0.0
154
155Initial release.
156";
157
158 #[test]
159 fn latest_version_is_first_heading() {
160 let cl = Changelog::parse(SAMPLE);
161 assert_eq!(cl.latest_version(), Some("1.2.0"));
162 }
163
164 #[test]
165 fn section_body_is_trimmed() {
166 let cl = Changelog::parse(SAMPLE);
167 let body = cl.section("1.2.0").unwrap();
168 assert!(body.starts_with("### Added"));
169 assert!(body.ends_with("- New feature"));
170 }
171
172 #[test]
173 fn unbracketed_heading_is_parsed() {
174 let cl = Changelog::parse(SAMPLE);
175 assert!(cl.section("1.0.0").is_some());
176 }
177
178 #[test]
179 fn unknown_version_returns_none() {
180 let cl = Changelog::parse(SAMPLE);
181 assert!(cl.section("9.9.9").is_none());
182 }
183}