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