use crate::audit::issue::{AuditIssue, IssueCategory, IssueSeverity};
use crate::changelog::ConventionalCommit;
use crate::changes::{ChangesAnalyzer, CommitInfo};
use crate::config::BreakingChangesAuditConfig;
use crate::error::{AuditError, AuditResult};
use crate::types::Changeset;
use crate::types::{Version, VersionBump};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use sublime_standard_tools::filesystem::AsyncFileSystem;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreakingChangesAuditSection {
pub packages_with_breaking: Vec<PackageBreakingChanges>,
pub total_breaking_changes: usize,
pub issues: Vec<AuditIssue>,
}
impl BreakingChangesAuditSection {
#[must_use]
pub fn empty() -> Self {
Self { packages_with_breaking: Vec::new(), total_breaking_changes: 0, issues: Vec::new() }
}
#[must_use]
pub fn has_breaking_changes(&self) -> bool {
self.total_breaking_changes > 0
}
#[must_use]
pub fn affected_package_count(&self) -> usize {
self.packages_with_breaking.len()
}
#[must_use]
pub fn critical_issue_count(&self) -> usize {
self.issues.iter().filter(|issue| issue.is_critical()).count()
}
#[must_use]
pub fn warning_issue_count(&self) -> usize {
self.issues.iter().filter(|issue| issue.is_warning()).count()
}
#[must_use]
pub fn breaking_changes_for_package(
&self,
package_name: &str,
) -> Option<&PackageBreakingChanges> {
self.packages_with_breaking.iter().find(|p| p.package_name == package_name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PackageBreakingChanges {
pub package_name: String,
pub current_version: Option<Version>,
pub next_version: Option<Version>,
pub breaking_changes: Vec<BreakingChange>,
}
impl PackageBreakingChanges {
#[must_use]
pub fn is_major_bump(&self) -> bool {
match (&self.current_version, &self.next_version) {
(Some(current), Some(next)) => next.major() > current.major(),
_ => false,
}
}
#[must_use]
pub fn breaking_change_count(&self) -> usize {
self.breaking_changes.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BreakingChange {
pub description: String,
pub commit_hash: Option<String>,
pub source: BreakingChangeSource,
}
impl BreakingChange {
#[must_use]
pub fn has_commit(&self) -> bool {
self.commit_hash.is_some()
}
#[must_use]
pub fn is_from_conventional_commit(&self) -> bool {
matches!(self.source, BreakingChangeSource::ConventionalCommit)
}
#[must_use]
pub fn is_from_changeset(&self) -> bool {
matches!(self.source, BreakingChangeSource::Changeset)
}
#[must_use]
pub fn is_from_changelog(&self) -> bool {
matches!(self.source, BreakingChangeSource::Changelog)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BreakingChangeSource {
ConventionalCommit,
Changelog,
Changeset,
}
pub async fn audit_breaking_changes<FS: AsyncFileSystem + Clone + Send + Sync>(
changes_analyzer: &ChangesAnalyzer<FS>,
commit_from: &str,
commit_to: &str,
changeset: Option<&Changeset>,
config: &BreakingChangesAuditConfig,
) -> AuditResult<BreakingChangesAuditSection> {
if !config.check_conventional_commits && !config.check_changelog {
return Ok(BreakingChangesAuditSection::empty());
}
let changes_report = changes_analyzer
.analyze_commit_range(commit_from, commit_to)
.await
.map_err(|e| AuditError::BreakingChangesDetectionFailed {
reason: format!("Failed to analyze commit range: {}", e),
})?;
let mut packages_map: HashMap<String, PackageBreakingChanges> = HashMap::new();
for package_changes in &changes_report.packages {
if !package_changes.has_changes {
continue;
}
let mut breaking_changes_list = Vec::new();
if config.check_conventional_commits {
breaking_changes_list
.extend(detect_breaking_from_commits(&package_changes.commits).await?);
}
if let Some(cs) = changeset {
breaking_changes_list
.extend(detect_breaking_from_changeset(package_changes.package_name(), cs)?);
}
if !breaking_changes_list.is_empty() {
packages_map.insert(
package_changes.package_name().to_string(),
PackageBreakingChanges {
package_name: package_changes.package_name().to_string(),
current_version: package_changes.current_version.clone(),
next_version: package_changes.next_version.clone(),
breaking_changes: breaking_changes_list,
},
);
}
}
let total_breaking_changes: usize =
packages_map.values().map(|p| p.breaking_changes.len()).sum();
let issues = generate_breaking_change_issues(&packages_map);
Ok(BreakingChangesAuditSection {
packages_with_breaking: packages_map.into_values().collect(),
total_breaking_changes,
issues,
})
}
async fn detect_breaking_from_commits(commits: &[CommitInfo]) -> AuditResult<Vec<BreakingChange>> {
let mut breaking_changes = Vec::new();
for commit in commits {
match ConventionalCommit::parse(&commit.full_message) {
Ok(conventional) => {
if conventional.is_breaking() {
let description = if conventional.body().is_some() {
let breaking_footer = conventional
.footers()
.iter()
.find(|f| f.key == "BREAKING CHANGE" || f.key == "BREAKING-CHANGE");
if let Some(footer) = breaking_footer {
footer.value.clone()
} else {
format!(
"{}: {}",
conventional.commit_type(),
conventional.description()
)
}
} else {
format!("{}: {}", conventional.commit_type(), conventional.description())
};
breaking_changes.push(BreakingChange {
description,
commit_hash: Some(commit.short_hash.clone()),
source: BreakingChangeSource::ConventionalCommit,
});
}
}
Err(_) => {
continue;
}
}
}
Ok(breaking_changes)
}
fn detect_breaking_from_changeset(
package_name: &str,
changeset: &Changeset,
) -> AuditResult<Vec<BreakingChange>> {
let mut breaking_changes = Vec::new();
if changeset.packages.iter().any(|p| p == package_name) {
if matches!(changeset.bump, VersionBump::Major) {
let description =
if changeset.changes.is_empty() {
format!("Major version bump planned for {}", package_name)
} else {
changeset.changes.first().map(|c| c.to_string()).unwrap_or_else(|| {
format!("Major version bump planned for {}", package_name)
})
};
breaking_changes.push(BreakingChange {
description,
commit_hash: None,
source: BreakingChangeSource::Changeset,
});
}
}
Ok(breaking_changes)
}
fn generate_breaking_change_issues(
packages_map: &HashMap<String, PackageBreakingChanges>,
) -> Vec<AuditIssue> {
let mut issues = Vec::new();
for package_breaking in packages_map.values() {
let severity = if package_breaking.is_major_bump() {
IssueSeverity::Critical
} else {
IssueSeverity::Warning
};
let version_info = match (&package_breaking.current_version, &package_breaking.next_version)
{
(Some(current), Some(next)) => format!(" ({} → {})", current, next),
(Some(current), None) => format!(" (current: {})", current),
(None, Some(next)) => format!(" (next: {})", next),
(None, None) => String::new(),
};
let title = format!(
"Breaking changes detected in {}{}",
package_breaking.package_name, version_info
);
let description = if package_breaking.breaking_changes.len() == 1 {
format!(
"1 breaking change detected in {}. {}",
package_breaking.package_name, package_breaking.breaking_changes[0].description
)
} else {
let changes_list = package_breaking
.breaking_changes
.iter()
.map(|bc| format!(" - {}", bc.description))
.collect::<Vec<_>>()
.join("\n");
format!(
"{} breaking changes detected in {}:\n{}",
package_breaking.breaking_changes.len(),
package_breaking.package_name,
changes_list
)
};
let suggestion = if package_breaking.is_major_bump() {
Some(format!(
"Review breaking changes for {} and update documentation. Ensure major version bump is intentional.",
package_breaking.package_name
))
} else {
Some(format!(
"Review breaking changes for {} and ensure appropriate version bump (major) is planned.",
package_breaking.package_name
))
};
let mut metadata = HashMap::new();
metadata.insert("package".to_string(), package_breaking.package_name.clone());
metadata.insert(
"breaking_change_count".to_string(),
package_breaking.breaking_changes.len().to_string(),
);
if let Some(current) = &package_breaking.current_version {
metadata.insert("current_version".to_string(), current.to_string());
}
if let Some(next) = &package_breaking.next_version {
metadata.insert("next_version".to_string(), next.to_string());
}
issues.push(AuditIssue {
severity,
category: IssueCategory::BreakingChanges,
title,
description,
affected_packages: vec![package_breaking.package_name.clone()],
suggestion,
metadata,
});
}
issues
}