use crate::error::{ChangelogError, ChangelogResult};
use chrono::{DateTime, NaiveDate, Utc};
use regex::Regex;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ParsedVersion {
pub version: String,
pub date: Option<DateTime<Utc>>,
pub content: String,
pub raw_header: String,
}
#[derive(Debug, Clone)]
pub struct ParsedChangelog {
pub header: String,
pub versions: Vec<ParsedVersion>,
}
impl ParsedChangelog {
#[must_use]
pub fn get_version(&self, version: &str) -> Option<&ParsedVersion> {
self.versions.iter().find(|v| v.version == version)
}
#[must_use]
pub fn latest_version(&self) -> Option<&ParsedVersion> {
self.versions.first()
}
#[must_use]
pub fn version_list(&self) -> Vec<&str> {
self.versions.iter().map(|v| v.version.as_str()).collect()
}
#[must_use]
pub fn has_version(&self, version: &str) -> bool {
self.versions.iter().any(|v| v.version == version)
}
#[must_use]
pub fn version_count(&self) -> usize {
self.versions.len()
}
}
#[derive(Clone)]
pub struct ChangelogParser {
}
impl ChangelogParser {
#[must_use]
pub fn new() -> Self {
Self {}
}
pub fn parse(&self, content: &str) -> ChangelogResult<ParsedChangelog> {
let lines: Vec<&str> = content.lines().collect();
let version_indices = self.find_version_indices(&lines)?;
let header_end = version_indices.first().copied().unwrap_or(lines.len());
let header = lines[..header_end].join("\n");
let mut versions = Vec::new();
for i in 0..version_indices.len() {
let start = version_indices[i];
let end = version_indices.get(i + 1).copied().unwrap_or(lines.len());
if let Some(parsed_version) = self.parse_version_section(&lines[start..end], start)? {
versions.push(parsed_version);
}
}
Ok(ParsedChangelog { header, versions })
}
fn find_version_indices(&self, lines: &[&str]) -> ChangelogResult<Vec<usize>> {
let version_regex =
Regex::new(r"^##\s+\[?v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?(?:\+[a-zA-Z0-9.]+)?)\]?")
.map_err(|e| ChangelogError::ParseError {
line: 0,
reason: format!("Failed to compile version regex: {}", e),
})?;
Ok(lines
.iter()
.enumerate()
.filter_map(|(i, line)| if version_regex.is_match(line) { Some(i) } else { None })
.collect())
}
fn parse_version_section(
&self,
lines: &[&str],
start_line: usize,
) -> ChangelogResult<Option<ParsedVersion>> {
if lines.is_empty() {
return Ok(None);
}
let header_line = lines[0];
let version = self.extract_version(header_line, start_line)?;
let date = self.extract_date(header_line);
let content = if lines.len() > 1 { lines[1..].join("\n") } else { String::new() };
Ok(Some(ParsedVersion { version, date, content, raw_header: header_line.to_string() }))
}
fn extract_version(&self, line: &str, line_num: usize) -> ChangelogResult<String> {
let version_regex =
Regex::new(r"^##\s+\[?v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?(?:\+[a-zA-Z0-9.]+)?)\]?")
.map_err(|e| ChangelogError::ParseError {
line: line_num,
reason: format!("Failed to compile version regex: {}", e),
})?;
version_regex
.captures(line)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.ok_or_else(|| ChangelogError::ParseError {
line: line_num,
reason: format!("Failed to extract version from: {}", line),
})
}
pub(crate) fn extract_date(&self, line: &str) -> Option<DateTime<Utc>> {
let date_regex = Regex::new(r"(\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-/]\d{2}[-/]\d{4})").ok()?;
date_regex.captures(line).and_then(|caps| {
caps.get(1).and_then(|m| {
let date_str = m.as_str();
self.parse_date_string(date_str)
})
})
}
fn parse_date_string(&self, date_str: &str) -> Option<DateTime<Utc>> {
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
return Some(date.and_hms_opt(0, 0, 0)?.and_utc());
}
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y/%m/%d") {
return Some(date.and_hms_opt(0, 0, 0)?.and_utc());
}
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%d-%m-%Y") {
return Some(date.and_hms_opt(0, 0, 0)?.and_utc());
}
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%d/%m/%Y") {
return Some(date.and_hms_opt(0, 0, 0)?.and_utc());
}
None
}
pub fn parse_to_map(&self, content: &str) -> ChangelogResult<HashMap<String, String>> {
let parsed = self.parse(content)?;
Ok(parsed.versions.into_iter().map(|v| (v.version, v.content)).collect())
}
}
impl Default for ChangelogParser {
fn default() -> Self {
Self::new()
}
}