use std::sync::LazyLock;
use regex::Regex;
use semver::Version;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ParsedReleaseHighlights {
pub version: String,
pub items: Vec<String>,
}
pub(crate) fn parse_highlights(version: &Version, body: &str) -> ParsedReleaseHighlights {
let items = extract_highlight_items(body);
ParsedReleaseHighlights {
version: version.to_string(),
items,
}
}
fn extract_highlight_items(body: &str) -> Vec<String> {
let mut items = Vec::new();
let mut in_highlights = false;
for line in body.lines() {
let trimmed = line.trim();
if trimmed.eq_ignore_ascii_case("### Highlights") {
in_highlights = true;
continue;
}
if !in_highlights {
continue;
}
if trimmed.starts_with("## ") {
break;
}
if trimmed.starts_with("#### ") {
continue;
}
if let Some(content) = trimmed.strip_prefix("- ") {
let cleaned = strip_commit_metadata(content);
let cleaned = cleaned.trim();
if !cleaned.is_empty() {
items.push(cleaned.to_string());
}
}
}
items
}
static COMMIT_METADATA_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\s*\([0-9a-fA-F]+\)\s*(\(@[^)]+\))?\s*$").unwrap());
fn strip_commit_metadata(text: &str) -> String {
COMMIT_METADATA_RE.replace(text, "").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
#[test]
fn empty_body_returns_empty_items() {
let result = parse_highlights(&v("0.1.0"), "");
assert!(result.items.is_empty());
assert_eq!(result.version, "0.1.0");
}
#[test]
fn no_highlights_section_returns_empty() {
let body = "## 0.1.0\n### Features\n- Something\n";
let result = parse_highlights(&v("0.1.0"), body);
assert!(result.items.is_empty());
}
#[test]
fn extracts_basic_highlights() {
let body = "\
## 0.125.0 - 2026-06-10
### Highlights
#### Features
- Add vtcode-ui to publish sequence (bb7228ed)
- Implement new parser (a1b2c3d4) (@vinhnx)
### Other Changes
#### Other
- Some other change
";
let result = parse_highlights(&v("0.125.0"), body);
assert_eq!(result.items.len(), 2);
assert_eq!(result.items[0], "Add vtcode-ui to publish sequence");
assert_eq!(result.items[1], "Implement new parser");
}
#[test]
fn handles_subsections() {
let body = "\
### Highlights
#### Bug Fixes
- Fix parameter handling (dac7afa0)
- Apply PR review fixes (d530f6c7) (@kernitus)
#### Features
- Add new model support (d4ed7872)
### Other Changes
- Not a highlight
";
let result = parse_highlights(&v("0.124.0"), body);
assert_eq!(result.items.len(), 3);
assert_eq!(result.items[0], "Fix parameter handling");
assert_eq!(result.items[1], "Apply PR review fixes");
assert_eq!(result.items[2], "Add new model support");
}
#[test]
fn strips_commit_hash_only() {
let body = "\
### Highlights
- Simple fix (abc1234)
";
let result = parse_highlights(&v("0.1.0"), body);
assert_eq!(result.items, vec!["Simple fix"]);
}
#[test]
fn strips_commit_hash_and_author() {
let body = "\
### Highlights
- Feature name (deadbeef) (@someone)
";
let result = parse_highlights(&v("0.1.0"), body);
assert_eq!(result.items, vec!["Feature name"]);
}
#[test]
fn stops_at_next_major_section() {
let body = "\
### Highlights
- Item one (abc1234)
### Other Changes
- Should not appear (def5678)
### More Stuff
- Also excluded
";
let result = parse_highlights(&v("0.1.0"), body);
assert_eq!(result.items, vec!["Item one"]);
}
#[test]
fn skips_empty_bullet_lines() {
let body = "\
### Highlights
- First item (abc1234)
- Second item (def5678)
";
let result = parse_highlights(&v("0.1.0"), body);
assert_eq!(result.items, vec!["First item", "Second item"]);
}
#[test]
fn highlights_with_no_subsections() {
let body = "\
### Highlights
- Direct item one (abc1234)
- Direct item two (def5678) (@author)
";
let result = parse_highlights(&v("0.1.0"), body);
assert_eq!(result.items, vec!["Direct item one", "Direct item two"]);
}
#[test]
fn case_insensitive_header_match() {
let body = "### highlights\n- Item (abc1234)\n";
let result = parse_highlights(&v("0.1.0"), body);
assert_eq!(result.items, vec!["Item"]);
}
}