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;
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
44struct 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 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 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 fn latest_version(&self) -> Option<&str> {
101 self.sections.first().map(|s| s.version.as_str())
102 }
103
104 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
113fn 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 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}