1use std::fs;
11use std::path::Path;
12use std::sync::LazyLock;
13
14use regex::Regex;
15
16static HEADER_REGEX_L2: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(r"(?m)^## \[([^\]]+)\]").expect("Invalid changelog header regex"));
20
21static HEADER_REGEX_L3: LazyLock<Regex> =
24 LazyLock::new(|| Regex::new(r"(?m)^### \[([^\]]+)\]").expect("Invalid changelog header regex"));
25
26pub const MAX_LINES: usize = 20;
28
29#[must_use]
42pub fn parse_changelog_for_version(repo_root: &Path, version: &str) -> Option<String> {
43 let changelog_path = find_changelog_file(repo_root)?;
44 let content = fs::read_to_string(changelog_path).ok()?;
45 extract_version_section(&content, version)
46}
47
48fn find_changelog_file(repo_root: &Path) -> Option<std::path::PathBuf> {
50 let entries = fs::read_dir(repo_root).ok()?;
51 for entry in entries.flatten() {
52 let name = entry.file_name();
53 let name_str = name.to_string_lossy();
54 if name_str.eq_ignore_ascii_case("changelog.md") {
55 return Some(entry.path());
56 }
57 }
58 None
59}
60
61#[must_use]
67pub fn extract_version_section(content: &str, version: &str) -> Option<String> {
68 if let Some(result) = extract_version_section_with_regex(content, version, &HEADER_REGEX_L2) {
70 return Some(result);
71 }
72
73 extract_version_section_with_regex(content, version, &HEADER_REGEX_L3)
75}
76
77fn extract_version_section_with_regex(
79 content: &str,
80 version: &str,
81 header_regex: &Regex,
82) -> Option<String> {
83 let normalized_version = version.strip_prefix('v').unwrap_or(version);
85
86 let mut section_start: Option<usize> = None;
87 let mut section_end: Option<usize> = None;
88
89 for caps in header_regex.captures_iter(content) {
90 let full_match = caps.get(0)?;
91 let captured_version = caps.get(1)?.as_str();
92
93 let normalized_captured = captured_version
97 .strip_prefix('v')
98 .unwrap_or(captured_version);
99 let normalized_captured = normalized_captured
100 .split(" - ")
101 .next()
102 .unwrap_or(normalized_captured)
103 .trim();
104
105 if section_start.is_some() {
106 section_end = Some(full_match.start());
108 break;
109 }
110
111 if normalized_captured == normalized_version {
112 let line_end = content[full_match.end()..]
114 .find('\n')
115 .map_or_else(|| full_match.end(), |i| full_match.end() + i + 1);
116 section_start = Some(line_end);
117 }
118 }
119
120 let start = section_start?;
121 let end = section_end.unwrap_or(content.len());
122
123 let section = content[start..end].trim();
124 if section.is_empty() {
125 return None;
126 }
127
128 Some(section.to_string())
129}
130
131#[must_use]
136pub fn truncate_content(content: &str) -> (&str, usize) {
137 let lines: Vec<&str> = content.lines().collect();
138 if lines.len() <= MAX_LINES {
139 return (content, 0);
140 }
141
142 let truncated_end = lines[..MAX_LINES]
143 .iter()
144 .map(|l| l.len() + 1) .sum::<usize>();
146
147 let truncated_end = truncated_end.min(content.len());
149 let truncated = &content[..truncated_end].trim_end();
150
151 (truncated, lines.len() - MAX_LINES)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 const SAMPLE_CHANGELOG: &str = r"# Changelog
159
160All notable changes to this project will be documented in this file.
161
162## [Unreleased]
163
164### Added
165- Something new
166
167## [1.2.0] - 2024-01-15
168
169### Added
170- Feature X
171- Feature Y
172
173### Fixed
174- Bug in authentication
175
176## [1.1.0] - 2024-01-01
177
178### Added
179- Initial feature
180";
181
182 #[test]
183 fn extracts_version_section() {
184 let result = extract_version_section(SAMPLE_CHANGELOG, "1.2.0");
185 assert!(result.is_some());
186 let content = result.unwrap();
187 assert!(content.contains("Feature X"));
188 assert!(content.contains("Bug in authentication"));
189 assert!(!content.contains("Initial feature"));
190 }
191
192 #[test]
193 fn extracts_version_with_v_prefix() {
194 let result = extract_version_section(SAMPLE_CHANGELOG, "v1.2.0");
195 assert!(result.is_some());
196 let content = result.unwrap();
197 assert!(content.contains("Feature X"));
198 }
199
200 #[test]
201 fn handles_changelog_with_v_prefix_in_header() {
202 let changelog = r"# Changelog
203
204## [v2.0.0] - 2024-02-01
205
206### Changed
207- Major update
208";
209 let result = extract_version_section(changelog, "2.0.0");
210 assert!(result.is_some());
211 assert!(result.unwrap().contains("Major update"));
212
213 let result2 = extract_version_section(changelog, "v2.0.0");
214 assert!(result2.is_some());
215 }
216
217 #[test]
218 fn returns_none_for_missing_version() {
219 let result = extract_version_section(SAMPLE_CHANGELOG, "9.9.9");
220 assert!(result.is_none());
221 }
222
223 #[test]
224 fn extracts_unreleased_section() {
225 let result = extract_version_section(SAMPLE_CHANGELOG, "Unreleased");
226 assert!(result.is_some());
227 }
228
229 #[test]
230 fn returns_none_for_empty_section() {
231 let changelog = r"# Changelog
232
233## [1.0.0]
234
235## [0.9.0]
236
237### Added
238- Something
239";
240 let result = extract_version_section(changelog, "1.0.0");
241 assert!(result.is_none());
242 }
243
244 #[test]
245 fn extracts_version_from_level3_headers() {
246 let changelog = r"## Changelog
248
249### [v40.1.2 - 2026-01-13](https://github.com/joke2k/faker/compare/v40.1.1...v40.1.2)
250
251* Make `tzdata` conditionally required based on platform. Thanks @rodrigobnogueira.
252
253### [v40.1.1 - 2026-01-10](https://github.com/joke2k/faker/compare/v40.1.0...v40.1.1)
254
255* Fix something else
256";
257 let result = extract_version_section(changelog, "v40.1.2");
258 assert!(result.is_some());
259 let content = result.unwrap();
260 assert!(content.contains("tzdata"));
261 assert!(!content.contains("Fix something else"));
262 }
263
264 #[test]
265 fn extracts_version_from_level3_headers_without_v_prefix() {
266 let changelog = r"## Changelog
267
268### [40.1.2 - 2026-01-13]
269
270* Feature A
271
272### [40.1.1 - 2026-01-10]
273
274* Feature B
275";
276 let result = extract_version_section(changelog, "v40.1.2");
278 assert!(result.is_some());
279 assert!(result.unwrap().contains("Feature A"));
280
281 let result2 = extract_version_section(changelog, "40.1.2");
283 assert!(result2.is_some());
284 assert!(result2.unwrap().contains("Feature A"));
285 }
286
287 #[test]
288 fn truncates_long_content() {
289 let long_content = (0..30)
290 .map(|i| format!("Line {i}"))
291 .collect::<Vec<_>>()
292 .join("\n");
293 let (truncated, remaining) = truncate_content(&long_content);
294
295 assert_eq!(remaining, 10);
296 assert!(truncated.lines().count() <= MAX_LINES);
297 assert!(truncated.contains("Line 0"));
298 assert!(truncated.contains("Line 19"));
299 assert!(!truncated.contains("Line 20"));
300 }
301
302 #[test]
303 fn does_not_truncate_short_content() {
304 let short_content = "Line 1\nLine 2\nLine 3";
305 let (result, remaining) = truncate_content(short_content);
306
307 assert_eq!(remaining, 0);
308 assert_eq!(result, short_content);
309 }
310}