1use std::{collections::HashMap, path::PathBuf};
2
3use miette::{IntoDiagnostic, Result, miette};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
9pub struct ChangelogConfig {
10 pub directory: Option<PathBuf>,
13}
14
15pub struct ChangelogExtractor;
20
21impl ChangelogExtractor {
22 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 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 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
56struct 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 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 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 fn latest_version(&self) -> Option<&str> {
113 self.sections.first().map(|s| s.version.as_str())
114 }
115
116 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
125fn parse_version_heading(line: &str) -> Option<String> {
138 let rest = line.strip_prefix("## ")?;
139 let version = if rest.starts_with('[') {
140 let end = rest.find(']')?;
141 rest[1..end].trim_start_matches('v')
142 } else {
143 rest.split(" - ").next()?.trim().trim_start_matches('v')
145 };
146 if version.is_empty() {
147 None
148 } else {
149 Some(version.to_owned())
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 const SAMPLE: &str = "\
158# Changelog
159
160## [1.2.0] - 2024-03-01
161
162### Added
163- New feature
164
165## [1.1.0] - 2024-01-15
166
167### Fixed
168- Bug fix
169
170## 1.0.0
171
172Initial release.
173";
174
175 #[test]
176 fn latest_version_is_first_heading() {
177 let cl = Changelog::parse(SAMPLE);
178 assert_eq!(cl.latest_version(), Some("1.2.0"));
179 }
180
181 #[test]
182 fn section_body_is_trimmed() {
183 let cl = Changelog::parse(SAMPLE);
184 let body = cl.section("1.2.0").unwrap();
185 assert!(body.starts_with("### Added"));
186 assert!(body.ends_with("- New feature"));
187 }
188
189 #[test]
190 fn unbracketed_heading_is_parsed() {
191 let cl = Changelog::parse(SAMPLE);
192 assert!(cl.section("1.0.0").is_some());
193 }
194
195 #[test]
196 fn unknown_version_returns_none() {
197 let cl = Changelog::parse(SAMPLE);
198 assert!(cl.section("9.9.9").is_none());
199 }
200
201 const PREFIXED: &str = "\
202# Changelog
203
204## [v2.0.0] - 2025-06-01
205
206### Added
207- Major feature
208
209## v1.5.0 - 2025-01-01
210
211### Fixed
212- Important fix
213";
214
215 #[test]
216 fn strips_v_prefix_from_bracketed_heading() {
217 let cl = Changelog::parse(PREFIXED);
218 assert!(cl.section("2.0.0").is_some());
219 }
220
221 #[test]
222 fn strips_v_prefix_from_unbracketed_heading() {
223 let cl = Changelog::parse(PREFIXED);
224 assert!(cl.section("1.5.0").is_some());
225 }
226
227 #[test]
228 fn v_prefixed_latest_version() {
229 let cl = Changelog::parse(PREFIXED);
230 assert_eq!(cl.latest_version(), Some("2.0.0"));
231 }
232}