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> {
134 let rest = line.strip_prefix("## ")?;
135 let version = if rest.starts_with('[') {
136 let end = rest.find(']')?;
137 &rest[1..end]
138 } else {
139 rest.split(" - ").next()?.trim()
141 };
142 if version.is_empty() {
143 None
144 } else {
145 Some(version.to_owned())
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 const SAMPLE: &str = "\
154# Changelog
155
156## [1.2.0] - 2024-03-01
157
158### Added
159- New feature
160
161## [1.1.0] - 2024-01-15
162
163### Fixed
164- Bug fix
165
166## 1.0.0
167
168Initial release.
169";
170
171 #[test]
172 fn latest_version_is_first_heading() {
173 let cl = Changelog::parse(SAMPLE);
174 assert_eq!(cl.latest_version(), Some("1.2.0"));
175 }
176
177 #[test]
178 fn section_body_is_trimmed() {
179 let cl = Changelog::parse(SAMPLE);
180 let body = cl.section("1.2.0").unwrap();
181 assert!(body.starts_with("### Added"));
182 assert!(body.ends_with("- New feature"));
183 }
184
185 #[test]
186 fn unbracketed_heading_is_parsed() {
187 let cl = Changelog::parse(SAMPLE);
188 assert!(cl.section("1.0.0").is_some());
189 }
190
191 #[test]
192 fn unknown_version_returns_none() {
193 let cl = Changelog::parse(SAMPLE);
194 assert!(cl.section("9.9.9").is_none());
195 }
196}