use crate::changelog::{ChangelogEntry, ChangelogSection, ConventionalCommit, SectionType};
use crate::config::ChangelogConfig;
use crate::error::{ChangelogError, ChangelogResult};
use chrono::{DateTime, TimeZone, Utc};
use regex::Regex;
use std::collections::HashMap;
use sublime_git_tools::{Repo, RepoCommit};
#[derive(Debug)]
pub struct ChangelogCollector<'a> {
repo: &'a Repo,
config: &'a ChangelogConfig,
exclude_patterns: Vec<Regex>,
}
impl<'a> ChangelogCollector<'a> {
pub fn new(repo: &'a Repo, config: &'a ChangelogConfig) -> Self {
let exclude_patterns =
config.exclude.patterns.iter().filter_map(|pattern| Regex::new(pattern).ok()).collect();
Self { repo, config, exclude_patterns }
}
pub async fn collect_between_versions(
&self,
from_ref: &str,
to_ref: &str,
relative_path: Option<&str>,
) -> ChangelogResult<Vec<ChangelogSection>> {
let commits = self.get_commits_between(from_ref, to_ref, relative_path)?;
self.process_commits(commits)
}
fn get_commits_between(
&self,
from_ref: &str,
to_ref: &str,
relative_path: Option<&str>,
) -> ChangelogResult<Vec<RepoCommit>> {
let path_option = relative_path.map(String::from);
self.repo.get_commits_between(from_ref, to_ref, &path_option).map_err(|e| {
ChangelogError::GitError {
operation: format!("get commits between {} and {}", from_ref, to_ref),
reason: e.as_ref().to_string(),
}
})
}
pub(crate) fn process_commits(
&self,
commits: Vec<RepoCommit>,
) -> ChangelogResult<Vec<ChangelogSection>> {
let filtered_commits: Vec<&RepoCommit> =
commits.iter().filter(|commit| self.should_include_commit(commit)).collect();
let mut entries: Vec<ChangelogEntry> =
filtered_commits.iter().map(|commit| self.parse_commit(commit)).collect();
entries.sort_by(|a, b| b.date.cmp(&a.date));
let sections = self.group_entries_by_section(entries);
Ok(sections)
}
pub(crate) fn should_include_commit(&self, commit: &RepoCommit) -> bool {
for pattern in &self.exclude_patterns {
if pattern.is_match(&commit.message) {
return false;
}
}
if self.config.exclude.authors.contains(&commit.author_name) {
return false;
}
true
}
pub(crate) fn parse_commit(&self, commit: &RepoCommit) -> ChangelogEntry {
let short_hash =
if commit.hash.len() >= 7 { commit.hash[..7].to_string() } else { commit.hash.clone() };
let date = self.parse_commit_date(&commit.author_date);
if self.config.conventional.enabled
&& let Ok(conventional) = ConventionalCommit::parse(&commit.message)
{
return ChangelogEntry {
description: conventional.description().to_string(),
commit_hash: commit.hash.clone(),
short_hash,
commit_type: Some(conventional.commit_type().to_string()),
scope: conventional.scope().map(String::from),
breaking: conventional.is_breaking(),
author: commit.author_name.clone(),
references: conventional.extract_references().unwrap_or_default(),
date,
};
}
let description = self.extract_first_line(&commit.message);
let references = self.extract_references_from_text(&commit.message);
ChangelogEntry {
description,
commit_hash: commit.hash.clone(),
short_hash,
commit_type: None,
scope: None,
breaking: false,
author: commit.author_name.clone(),
references,
date,
}
}
fn parse_commit_date(&self, date_str: &str) -> DateTime<Utc> {
if let Ok(dt) = DateTime::parse_from_rfc2822(date_str) {
return dt.with_timezone(&Utc);
}
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
return dt.with_timezone(&Utc);
}
if let Ok(timestamp) = date_str.parse::<i64>()
&& let Some(dt) = Utc.timestamp_opt(timestamp, 0).single()
{
return dt;
}
Utc::now()
}
pub(crate) fn extract_first_line(&self, message: &str) -> String {
message.lines().next().unwrap_or(message).trim().to_string()
}
pub(crate) fn extract_references_from_text(&self, text: &str) -> Vec<String> {
let Ok(re) = Regex::new(r"(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)?[:\s]*#(\d+)")
else {
return Vec::new();
};
let mut refs = Vec::new();
for cap in re.captures_iter(text) {
if let Some(num) = cap.get(1) {
refs.push(format!("#{}", num.as_str()));
}
}
refs.sort();
refs.dedup();
refs
}
pub(crate) fn group_entries_by_section(
&self,
entries: Vec<ChangelogEntry>,
) -> Vec<ChangelogSection> {
let mut section_map: HashMap<SectionType, Vec<ChangelogEntry>> = HashMap::new();
for entry in entries {
let section_type = self.determine_section_type(&entry);
section_map.entry(section_type).or_default().push(entry);
}
let mut sections: Vec<ChangelogSection> = section_map
.into_iter()
.map(|(section_type, entries)| {
let mut section = ChangelogSection::new(section_type);
for entry in entries {
section.add_entry(entry);
}
section
})
.collect();
sections.sort_by(|a, b| a.section_type.cmp(&b.section_type));
sections
}
pub(crate) fn determine_section_type(&self, entry: &ChangelogEntry) -> SectionType {
if entry.breaking {
return SectionType::Breaking;
}
if let Some(ref commit_type) = entry.commit_type {
return match commit_type.as_str() {
"feat" => SectionType::Features,
"fix" => SectionType::Fixes,
"perf" => SectionType::Performance,
"deprecate" => SectionType::Deprecations,
"docs" => SectionType::Documentation,
"refactor" => SectionType::Refactoring,
"build" => SectionType::Build,
"ci" => SectionType::CI,
"test" => SectionType::Tests,
_ => SectionType::Other,
};
}
SectionType::Other
}
}