Skip to main content

wtg_cli/
changelog.rs

1//! CHANGELOG.md parsing for Keep a Changelog format.
2//!
3//! Supports Keep a Changelog format with version headers at either level 2 (`##`)
4//! or level 3 (`###`). The header level is auto-detected from the content - once
5//! a version header is found at a specific level, that level is used consistently
6//! for section boundaries.
7//!
8//! See <https://keepachangelog.com> for format specification.
9
10use std::fs;
11use std::path::Path;
12use std::sync::LazyLock;
13
14use regex::Regex;
15
16/// Regex for parsing version headers at level 2: `## [version]` or `## [version - date]`
17/// Captures everything inside brackets; date part is stripped in code if present.
18static HEADER_REGEX_L2: LazyLock<Regex> =
19    LazyLock::new(|| Regex::new(r"(?m)^## \[([^\]]+)\]").expect("Invalid changelog header regex"));
20
21/// Regex for parsing version headers at level 3: `### [version]` or `### [version - date]`
22/// Captures everything inside brackets; date part is stripped in code if present.
23static HEADER_REGEX_L3: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"(?m)^### \[([^\]]+)\]").expect("Invalid changelog header regex"));
25
26/// Maximum number of lines to include in changelog output before truncation.
27pub const MAX_LINES: usize = 20;
28
29/// Extract the changelog section for a specific version.
30///
31/// Looks for CHANGELOG.md (case-insensitive) at the given path and extracts
32/// the section matching the version. Returns None if file doesn't exist,
33/// version not found, or format is invalid.
34///
35/// # Arguments
36/// * `repo_root` - Path to the repository root
37/// * `version` - Version to find (with or without 'v' prefix)
38///
39/// # Returns
40/// The changelog section content, or None if not found.
41#[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
48/// Find CHANGELOG.md file (case-insensitive) at repo root.
49fn 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/// Extract a version section from changelog content.
62///
63/// Matches changelog formats with version headers at either `##` or `###` level.
64/// The header level is auto-detected: tries `##` first, then `###` if no matches found.
65/// Version matching is flexible: strips 'v' prefix from both sides for comparison.
66#[must_use]
67pub fn extract_version_section(content: &str, version: &str) -> Option<String> {
68    // Try level 2 headers first (## [version])
69    if let Some(result) = extract_version_section_with_regex(content, version, &HEADER_REGEX_L2) {
70        return Some(result);
71    }
72
73    // Fall back to level 3 headers (### [version])
74    extract_version_section_with_regex(content, version, &HEADER_REGEX_L3)
75}
76
77/// Extract a version section using a specific header regex.
78fn extract_version_section_with_regex(
79    content: &str,
80    version: &str,
81    header_regex: &Regex,
82) -> Option<String> {
83    // Normalize version by stripping 'v' prefix
84    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        // Normalize captured version:
94        // - Strip 'v' prefix
95        // - Strip date suffix (` - 2024-01-15` style)
96        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            // We found the next section header, mark end
107            section_end = Some(full_match.start());
108            break;
109        }
110
111        if normalized_captured == normalized_version {
112            // Found our version, start after the header line
113            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/// Truncate content to `MAX_LINES`, returning (content, `remaining_lines`).
132///
133/// If content exceeds `MAX_LINES`, returns truncated content and count of remaining lines.
134/// Otherwise returns original content and 0.
135#[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) // +1 for newline
145        .sum::<usize>();
146
147    // Find the actual byte position (handle last line without newline)
148    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        // Faker-style changelog with ### for versions
247        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        // Search with v prefix, should still find version without v
277        let result = extract_version_section(changelog, "v40.1.2");
278        assert!(result.is_some());
279        assert!(result.unwrap().contains("Feature A"));
280
281        // Search without v prefix
282        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}