use crate::changelog::version_detection::{VersionTag, find_previous_version, parse_version_tag};
use crate::changelog::{Changelog, ChangelogCollector, ChangelogMetadata};
use crate::config::ChangelogConfig;
use crate::error::{ChangelogError, ChangelogResult};
use crate::types::VersionBump;
use chrono::Utc;
use std::path::{Path, PathBuf};
use sublime_git_tools::Repo;
use sublime_standard_tools::filesystem::{AsyncFileSystem, FileSystemManager};
#[derive(Debug)]
pub struct ChangelogGenerator {
workspace_root: PathBuf,
git_repo: Repo,
fs: FileSystemManager,
config: ChangelogConfig,
}
impl ChangelogGenerator {
pub async fn new(
workspace_root: PathBuf,
git_repo: Repo,
fs: FileSystemManager,
config: ChangelogConfig,
) -> ChangelogResult<Self> {
if !fs.exists(&workspace_root).await {
return Err(ChangelogError::InvalidPath {
path: workspace_root.clone(),
reason: "Workspace root does not exist".to_string(),
});
}
if fs.read_dir(&workspace_root).await.is_err() {
return Err(ChangelogError::InvalidPath {
path: workspace_root.clone(),
reason: "Workspace root is not a directory".to_string(),
});
}
Ok(Self { workspace_root, git_repo, fs, config })
}
#[must_use]
pub fn workspace_root(&self) -> &PathBuf {
&self.workspace_root
}
#[must_use]
pub fn git_repo(&self) -> &Repo {
&self.git_repo
}
#[must_use]
pub fn fs(&self) -> &FileSystemManager {
&self.fs
}
#[must_use]
pub fn config(&self) -> &ChangelogConfig {
&self.config
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn get_repository_url(&self) -> ChangelogResult<Option<String>> {
if let Some(ref url) = self.config.repository_url {
return Ok(Some(url.clone()));
}
Ok(None)
}
pub async fn detect_previous_version(
&self,
package_name: Option<&str>,
current_version: &str,
) -> ChangelogResult<Option<VersionTag>> {
let git_tags = self.git_repo.get_remote_or_local_tags(Some(true)).map_err(|e| {
ChangelogError::GitError {
operation: "get tags".to_string(),
reason: e.as_ref().to_string(),
}
})?;
let tag_names: Vec<String> = git_tags.iter().map(|t| t.tag.clone()).collect();
let format = if package_name.is_some() {
&self.config.version_tag_format
} else {
&self.config.root_tag_format
};
find_previous_version(&tag_names, current_version, package_name, format)
}
pub fn parse_version_tag(
&self,
tag: &str,
package_name: Option<&str>,
) -> ChangelogResult<Option<VersionTag>> {
let format = if package_name.is_some() {
&self.config.version_tag_format
} else {
&self.config.root_tag_format
};
Ok(parse_version_tag(tag, package_name, format))
}
pub async fn get_version_tags(
&self,
package_name: Option<&str>,
) -> ChangelogResult<Vec<VersionTag>> {
let git_tags = self.git_repo.get_remote_or_local_tags(Some(true)).map_err(|e| {
ChangelogError::GitError {
operation: "get tags".to_string(),
reason: e.as_ref().to_string(),
}
})?;
let tag_names: Vec<String> = git_tags.iter().map(|t| t.tag.clone()).collect();
let format = if package_name.is_some() {
&self.config.version_tag_format
} else {
&self.config.root_tag_format
};
let mut version_tags: Vec<VersionTag> = tag_names
.iter()
.filter_map(|tag| parse_version_tag(tag, package_name, format))
.collect();
version_tags.sort_by(|a, b| b.cmp(a));
Ok(version_tags)
}
pub async fn generate_for_version(
&self,
package_name: Option<&str>,
version: &str,
previous_version: Option<&str>,
relative_path: Option<&str>,
) -> ChangelogResult<Changelog> {
let prev_version = if let Some(prev) = previous_version {
Some(prev.to_string())
} else {
self.detect_previous_version(package_name, version)
.await?
.map(|tag| tag.version().to_string())
};
let (from_ref, to_ref) =
self.build_git_refs(package_name, prev_version.as_deref(), version)?;
let collector = ChangelogCollector::new(&self.git_repo, &self.config);
let sections =
collector.collect_between_versions(&from_ref, &to_ref, relative_path).await?;
let metadata = self.build_metadata(
package_name,
version,
prev_version.as_deref(),
&from_ref,
&to_ref,
§ions,
)?;
let mut changelog =
Changelog::new(package_name, version, prev_version.as_deref(), Utc::now());
for section in sections {
changelog.add_section(section);
}
changelog.metadata = metadata;
Ok(changelog)
}
#[must_use]
fn reference_exists(&self, git_ref: &str) -> bool {
self.git_repo.get_diverged_commit(git_ref).is_ok()
}
fn build_git_refs(
&self,
package_name: Option<&str>,
previous_version: Option<&str>,
current_version: &str,
) -> ChangelogResult<(String, String)> {
let format = if package_name.is_some() {
&self.config.version_tag_format
} else {
&self.config.root_tag_format
};
let to_ref_tag = if let Some(pkg_name) = package_name {
format.replace("{name}", pkg_name).replace("{version}", current_version)
} else {
format.replace("{version}", current_version)
};
let to_ref =
if self.reference_exists(&to_ref_tag) { to_ref_tag } else { "HEAD".to_string() };
let from_ref = if let Some(prev_version) = previous_version {
if let Some(pkg_name) = package_name {
format.replace("{name}", pkg_name).replace("{version}", prev_version)
} else {
format.replace("{version}", prev_version)
}
} else {
return Err(ChangelogError::VersionNotFound {
reason: "No previous version found for changelog generation".to_string(),
});
};
Ok((from_ref, to_ref))
}
fn build_metadata(
&self,
_package_name: Option<&str>,
_version: &str,
previous_version: Option<&str>,
from_ref: &str,
to_ref: &str,
sections: &[crate::changelog::ChangelogSection],
) -> ChangelogResult<ChangelogMetadata> {
let total_commits: usize = sections.iter().map(|s| s.entries.len()).sum();
let commit_range = if previous_version.is_some() {
Some(format!("{}..{}", from_ref, to_ref))
} else {
None
};
let bump_type = self.infer_bump_type(sections);
let repository_url = self.get_repository_url()?;
Ok(ChangelogMetadata {
tag: Some(to_ref.to_string()),
commit_range,
total_commits,
repository_url,
bump_type: Some(bump_type),
})
}
fn infer_bump_type(&self, sections: &[crate::changelog::ChangelogSection]) -> VersionBump {
use crate::changelog::SectionType;
for section in sections {
if section.section_type == SectionType::Breaking && !section.is_empty() {
return VersionBump::Major;
}
}
for section in sections {
if section.section_type == SectionType::Features && !section.is_empty() {
return VersionBump::Minor;
}
}
if sections.iter().any(|s| !s.is_empty()) {
return VersionBump::Patch;
}
VersionBump::None
}
pub async fn update_changelog(
&self,
package_path: &Path,
changelog: &Changelog,
dry_run: bool,
) -> ChangelogResult<String> {
let changelog_path = package_path.join(&self.config.filename);
let new_section = changelog.to_markdown(&self.config);
let existing_content = if self.fs.exists(&changelog_path).await {
self.fs.read_file_string(&changelog_path).await.map_err(|e| {
ChangelogError::FileSystemError {
path: changelog_path.clone(),
reason: e.as_ref().to_string(),
}
})?
} else {
let mut header = self.config.template.header.clone();
if !header.is_empty() && !header.ends_with('\n') {
header.push('\n');
}
header.push('\n');
header
};
let updated_content = self.prepend_changelog(&existing_content, &new_section);
if !dry_run {
if let Some(parent) = changelog_path.parent() {
self.fs.create_dir_all(parent).await.map_err(|e| {
ChangelogError::FileSystemError {
path: parent.to_path_buf(),
reason: e.as_ref().to_string(),
}
})?;
}
self.fs.write_file_string(&changelog_path, &updated_content).await.map_err(|e| {
ChangelogError::UpdateFailed {
path: changelog_path.clone(),
reason: e.as_ref().to_string(),
}
})?;
}
Ok(updated_content)
}
pub async fn parse_changelog(
&self,
package_path: &Path,
) -> ChangelogResult<crate::changelog::parser::ParsedChangelog> {
use crate::changelog::parser::ChangelogParser;
let changelog_path = package_path.join(&self.config.filename);
if !self.fs.exists(&changelog_path).await {
return Err(ChangelogError::NotFound { path: changelog_path });
}
let content = self.fs.read_file_string(&changelog_path).await.map_err(|e| {
ChangelogError::FileSystemError {
path: changelog_path.clone(),
reason: e.as_ref().to_string(),
}
})?;
let parser = ChangelogParser::new();
parser.parse(&content)
}
pub(crate) fn prepend_changelog(&self, existing: &str, new_section: &str) -> String {
let lines: Vec<&str> = existing.lines().collect();
let insert_pos =
lines.iter().position(|line| line.starts_with("## ")).unwrap_or(lines.len());
let mut result = String::new();
for (i, line) in lines.iter().enumerate().take(insert_pos) {
result.push_str(line);
result.push('\n');
if i == insert_pos - 1 && insert_pos < lines.len() {
result.push('\n');
}
}
if insert_pos == lines.len() && !result.is_empty() && !result.ends_with("\n\n") {
if !result.ends_with('\n') {
result.push('\n');
}
result.push('\n');
}
result.push_str(new_section);
if insert_pos < lines.len() && !new_section.ends_with("\n\n") {
if !new_section.ends_with('\n') {
result.push('\n');
}
result.push('\n');
}
for line in lines.iter().skip(insert_pos) {
result.push_str(line);
result.push('\n');
}
while result.ends_with("\n\n\n") {
result.pop();
}
result
}
pub async fn generate_from_changeset(
&self,
changeset: &crate::types::Changeset,
version_resolution: &crate::version::VersionResolution,
) -> ChangelogResult<Vec<crate::changelog::GeneratedChangelog>> {
use sublime_standard_tools::monorepo::{MonorepoDetector, MonorepoDetectorTrait};
let monorepo_detector = MonorepoDetector::with_filesystem(self.fs.clone());
let is_monorepo = monorepo_detector
.is_monorepo_root(&self.workspace_root)
.await
.map_err(|e| ChangelogError::FileSystemError {
path: self.workspace_root.clone(),
reason: e.as_ref().to_string(),
})?
.is_some();
let mut generated_changelogs = Vec::new();
match self.config.monorepo_mode {
crate::config::MonorepoMode::PerPackage => {
if is_monorepo {
generated_changelogs.extend(
self.generate_per_package_changelogs(changeset, version_resolution).await?,
);
} else {
generated_changelogs
.extend(self.generate_root_changelog(changeset, version_resolution).await?);
}
}
crate::config::MonorepoMode::Root => {
generated_changelogs
.extend(self.generate_root_changelog(changeset, version_resolution).await?);
}
crate::config::MonorepoMode::Both => {
if is_monorepo {
generated_changelogs.extend(
self.generate_per_package_changelogs(changeset, version_resolution).await?,
);
}
generated_changelogs
.extend(self.generate_root_changelog(changeset, version_resolution).await?);
}
}
Ok(generated_changelogs)
}
async fn generate_per_package_changelogs(
&self,
_changeset: &crate::types::Changeset,
version_resolution: &crate::version::VersionResolution,
) -> ChangelogResult<Vec<crate::changelog::GeneratedChangelog>> {
use crate::changelog::GeneratedChangelog;
use sublime_standard_tools::filesystem::AsyncFileSystem;
let mut changelogs = Vec::new();
for update in &version_resolution.updates {
let package_json_path = update.path.join("package.json");
let package_json_content =
self.fs.read_file_string(&package_json_path).await.map_err(|_e| {
ChangelogError::PackageNotFound { package: update.name.clone() }
})?;
let package_json: package_json::PackageJson =
serde_json::from_str(&package_json_content).map_err(|e| {
ChangelogError::FileSystemError {
path: package_json_path.clone(),
reason: format!("Failed to parse package.json: {}", e),
}
})?;
let package_name = package_json.name.clone();
let relative_path = update
.path
.strip_prefix(&self.workspace_root)
.ok()
.and_then(|p| p.to_str())
.map(String::from);
let previous_version = if let Ok(prev_tag) = self
.detect_previous_version(Some(&package_name), &update.next_version.to_string())
.await
{
prev_tag.map(|t| t.version().to_string())
} else {
None
};
let (sections, from_ref, to_ref) = if let Some(ref prev_version) = previous_version {
let (from_ref, to_ref) = self.build_git_refs(
Some(&package_name),
Some(prev_version.as_str()),
&update.next_version.to_string(),
)?;
let collector = ChangelogCollector::new(&self.git_repo, &self.config);
let sections = collector
.collect_between_versions(&from_ref, &to_ref, relative_path.as_deref())
.await?;
(sections, from_ref, to_ref)
} else {
let collector = ChangelogCollector::new(&self.git_repo, &self.config);
let commits_result = self.git_repo.get_commits_since(None, &relative_path);
let sections = if let Ok(commits) = commits_result {
collector.process_commits(commits)?
} else {
Vec::new()
};
(sections, String::new(), "HEAD".to_string())
};
let metadata = self.build_metadata(
Some(&package_name),
&update.next_version.to_string(),
previous_version.as_deref(),
&from_ref,
&to_ref,
§ions,
)?;
let mut changelog = crate::changelog::Changelog::new(
Some(&package_name),
&update.next_version.to_string(),
previous_version.as_deref(),
chrono::Utc::now(),
);
for section in sections {
changelog.add_section(section);
}
changelog.metadata = metadata;
let content = changelog.to_markdown(&self.config);
let changelog_path = update.path.join(&self.config.filename);
let existing = self.fs.exists(&changelog_path).await;
changelogs.push(GeneratedChangelog::new(
Some(package_name),
update.path.clone(),
changelog,
content,
existing,
changelog_path,
));
}
Ok(changelogs)
}
async fn generate_root_changelog(
&self,
_changeset: &crate::types::Changeset,
version_resolution: &crate::version::VersionResolution,
) -> ChangelogResult<Vec<crate::changelog::GeneratedChangelog>> {
use crate::changelog::GeneratedChangelog;
use sublime_standard_tools::filesystem::AsyncFileSystem;
let version = if let Some(first_update) = version_resolution.updates.first() {
first_update.next_version.to_string()
} else {
return Ok(Vec::new());
};
let previous_version =
if let Ok(prev_tag) = self.detect_previous_version(None, &version).await {
prev_tag.map(|t| t.version().to_string())
} else {
None
};
let collector = ChangelogCollector::new(&self.git_repo, &self.config);
let (sections, from_ref, to_ref) = if let Some(ref prev_version) = previous_version {
let (from_ref, to_ref) =
self.build_git_refs(None, Some(prev_version.as_str()), &version)?;
let sections = collector.collect_between_versions(&from_ref, &to_ref, None).await?;
(sections, from_ref, to_ref)
} else {
let commits_result = self.git_repo.get_commits_since(None, &None);
let sections = if let Ok(commits) = commits_result {
collector.process_commits(commits)?
} else {
Vec::new()
};
(sections, String::new(), "HEAD".to_string())
};
let metadata = self.build_metadata(
None,
&version,
previous_version.as_deref(),
&from_ref,
&to_ref,
§ions,
)?;
let mut changelog = crate::changelog::Changelog::new(
None,
&version,
previous_version.as_deref(),
chrono::Utc::now(),
);
for section in sections {
changelog.add_section(section);
}
changelog.metadata = metadata;
let content = changelog.to_markdown(&self.config);
let changelog_path = self.workspace_root.join(&self.config.filename);
let existing = self.fs.exists(&changelog_path).await;
Ok(vec![GeneratedChangelog::new(
None,
self.workspace_root.clone(),
changelog,
content,
existing,
changelog_path,
)])
}
}