use crate::audit::issue::{AuditIssue, IssueCategory, IssueSeverity};
use crate::audit::sections::dependencies::VersionUsage;
use crate::config::PackageToolsConfig;
use crate::error::{AuditError, AuditResult};
use crate::types::PackageInfo;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionConsistencyAuditSection {
pub inconsistencies: Vec<VersionInconsistency>,
pub issues: Vec<AuditIssue>,
}
impl VersionConsistencyAuditSection {
#[must_use]
pub fn empty() -> Self {
Self { inconsistencies: Vec::new(), issues: Vec::new() }
}
#[must_use]
pub fn has_inconsistencies(&self) -> bool {
!self.inconsistencies.is_empty()
}
#[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 info_issue_count(&self) -> usize {
self.issues.iter().filter(|issue| issue.is_info()).count()
}
#[must_use]
pub fn inconsistency_for_package(&self, package_name: &str) -> Option<&VersionInconsistency> {
self.inconsistencies.iter().find(|i| i.package_name == package_name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VersionInconsistency {
pub package_name: String,
pub versions_used: Vec<VersionUsage>,
pub recommended_version: String,
}
impl VersionInconsistency {
#[must_use]
pub fn version_count(&self) -> usize {
self.versions_used.len()
}
#[must_use]
pub fn describe(&self) -> String {
let version_details: Vec<String> = self
.versions_used
.iter()
.map(|v| format!("{} ({})", v.package_name, v.version_spec))
.collect();
format!(
"Package '{}' is used with {} different versions: {}",
self.package_name,
self.version_count(),
version_details.join(", ")
)
}
#[must_use]
pub fn unique_versions(&self) -> Vec<String> {
let mut versions: Vec<String> =
self.versions_used.iter().map(|v| v.version_spec.clone()).collect();
versions.sort();
versions.dedup();
versions
}
}
pub async fn audit_version_consistency(
packages: &[PackageInfo],
internal_package_names: &std::collections::HashSet<String>,
config: &PackageToolsConfig,
) -> AuditResult<VersionConsistencyAuditSection> {
if !config.audit.sections.version_consistency {
return Err(AuditError::SectionDisabled { section: "version_consistency".to_string() });
}
let internal_usage = collect_internal_dependency_usage(packages, internal_package_names);
let inconsistencies = detect_inconsistencies(internal_usage);
let issues = generate_issues(&inconsistencies, config);
Ok(VersionConsistencyAuditSection { inconsistencies, issues })
}
fn collect_internal_dependency_usage(
packages: &[PackageInfo],
internal_package_names: &std::collections::HashSet<String>,
) -> HashMap<String, Vec<VersionUsage>> {
let mut usage_map: HashMap<String, Vec<VersionUsage>> = HashMap::new();
for package in packages {
let package_name = package.name();
let mut all_deps: Vec<(String, String)> = Vec::new();
if let Some(deps) = &package.package_json().dependencies {
all_deps.extend(deps.iter().map(|(k, v)| (k.clone(), v.clone())));
}
if let Some(deps) = &package.package_json().dev_dependencies {
all_deps.extend(deps.iter().map(|(k, v)| (k.clone(), v.clone())));
}
if let Some(deps) = &package.package_json().peer_dependencies {
all_deps.extend(deps.iter().map(|(k, v)| (k.clone(), v.clone())));
}
if let Some(deps) = &package.package_json().optional_dependencies {
all_deps.extend(deps.iter().map(|(k, v)| (k.clone(), v.clone())));
}
for (dep_name, version_spec) in all_deps {
if internal_package_names.contains(&dep_name) {
if dep_name == package_name {
continue;
}
usage_map.entry(dep_name.clone()).or_default().push(VersionUsage {
package_name: package_name.to_string(),
version_spec: version_spec.clone(),
});
}
}
}
usage_map
}
fn detect_inconsistencies(
usage_map: HashMap<String, Vec<VersionUsage>>,
) -> Vec<VersionInconsistency> {
let mut inconsistencies = Vec::new();
for (package_name, usages) in usage_map {
let unique_versions: std::collections::HashSet<String> =
usages.iter().map(|u| u.version_spec.clone()).collect();
if unique_versions.len() > 1 {
let recommended_version = determine_recommended_version(&usages, &unique_versions);
inconsistencies.push(VersionInconsistency {
package_name,
versions_used: usages,
recommended_version,
});
}
}
inconsistencies.sort_by(|a, b| a.package_name.cmp(&b.package_name));
inconsistencies
}
fn determine_recommended_version(
usages: &[VersionUsage],
unique_versions: &std::collections::HashSet<String>,
) -> String {
if unique_versions.contains("workspace:*") {
return "workspace:*".to_string();
}
for version in unique_versions {
if version.starts_with("workspace:") {
return version.clone();
}
}
let mut version_counts: HashMap<String, usize> = HashMap::new();
for usage in usages {
*version_counts.entry(usage.version_spec.clone()).or_insert(0) += 1;
}
let most_common =
version_counts.iter().max_by_key(|(_, count)| *count).map(|(version, _)| version.clone());
most_common.unwrap_or_else(|| {
let mut versions: Vec<String> = unique_versions.iter().cloned().collect();
versions.sort();
versions.first().cloned().unwrap_or_default()
})
}
fn generate_issues(
inconsistencies: &[VersionInconsistency],
config: &PackageToolsConfig,
) -> Vec<AuditIssue> {
let mut issues = Vec::new();
let severity = if config.audit.version_consistency.fail_on_inconsistency {
IssueSeverity::Critical
} else if config.audit.version_consistency.warn_on_inconsistency {
IssueSeverity::Warning
} else {
return issues;
};
for inconsistency in inconsistencies {
let mut issue = AuditIssue::new(
severity,
IssueCategory::VersionConsistency,
format!("Inconsistent versions for internal package '{}'", inconsistency.package_name),
format!(
"The internal package '{}' is referenced with {} different version specifications across the workspace. \
This can lead to confusion and potential runtime issues.",
inconsistency.package_name,
inconsistency.version_count()
),
);
for usage in &inconsistency.versions_used {
issue.add_affected_package(usage.package_name.clone());
}
issue.set_suggestion(format!(
"Update all references to '{}' to use '{}' for consistency. \
The workspace protocol (workspace:*) is recommended for internal dependencies in monorepos.",
inconsistency.package_name, inconsistency.recommended_version
));
issue.add_metadata("internal_package".to_string(), inconsistency.package_name.clone());
issue.add_metadata(
"recommended_version".to_string(),
inconsistency.recommended_version.clone(),
);
issue.add_metadata(
"unique_version_count".to_string(),
inconsistency.unique_versions().len().to_string(),
);
for (idx, usage) in inconsistency.versions_used.iter().enumerate() {
issue.add_metadata(format!("version_{}_package", idx), usage.package_name.clone());
issue.add_metadata(format!("version_{}_spec", idx), usage.version_spec.clone());
}
issues.push(issue);
}
issues
}