use crate::audit::issue::{AuditIssue, IssueCategory, IssueSeverity};
use crate::config::PackageToolsConfig;
use crate::error::{AuditError, AuditResult};
use crate::types::{CircularDependency, DependencyType, PackageInfo};
use crate::version::DependencyGraph;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyAuditSection {
pub circular_dependencies: Vec<CircularDependency>,
pub version_conflicts: Vec<VersionConflict>,
pub issues: Vec<AuditIssue>,
}
impl DependencyAuditSection {
#[must_use]
pub fn empty() -> Self {
Self {
circular_dependencies: Vec::new(),
version_conflicts: Vec::new(),
issues: Vec::new(),
}
}
#[must_use]
pub fn has_circular_dependencies(&self) -> bool {
!self.circular_dependencies.is_empty()
}
#[must_use]
pub fn has_version_conflicts(&self) -> bool {
!self.version_conflicts.is_empty()
}
#[must_use]
pub fn critical_issue_count(&self) -> usize {
self.issues.iter().filter(|i| i.is_critical()).count()
}
#[must_use]
pub fn warning_issue_count(&self) -> usize {
self.issues.iter().filter(|i| i.is_warning()).count()
}
#[must_use]
pub fn info_issue_count(&self) -> usize {
self.issues.iter().filter(|i| i.is_info()).count()
}
#[must_use]
pub fn circular_dependencies_for_package(
&self,
package_name: &str,
) -> Vec<&CircularDependency> {
self.circular_dependencies.iter().filter(|cd| cd.involves(package_name)).collect()
}
#[must_use]
pub fn version_conflicts_for_dependency(
&self,
dependency_name: &str,
) -> Option<&VersionConflict> {
self.version_conflicts.iter().find(|vc| vc.dependency_name == dependency_name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VersionConflict {
pub dependency_name: String,
pub versions: Vec<VersionUsage>,
}
impl VersionConflict {
#[must_use]
pub fn version_count(&self) -> usize {
self.versions.len()
}
#[must_use]
pub fn describe(&self) -> String {
let version_details: Vec<String> = self
.versions
.iter()
.map(|v| format!("{} ({})", v.package_name, v.version_spec))
.collect();
format!("{} used by: {}", self.dependency_name, version_details.join(", "))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VersionUsage {
pub package_name: String,
pub version_spec: String,
}
pub async fn audit_dependencies(
_workspace_root: &std::path::Path,
packages: &[PackageInfo],
config: &PackageToolsConfig,
) -> AuditResult<DependencyAuditSection> {
let mut section = DependencyAuditSection::empty();
if !config.audit.sections.dependencies {
return Ok(section);
}
let graph = DependencyGraph::from_packages(packages).map_err(|e| {
AuditError::DependencyGraphFailed {
reason: format!("Failed to build dependency graph: {}", e),
}
})?;
if config.audit.dependencies.check_circular {
section.circular_dependencies = graph.detect_cycles();
for circular_dep in §ion.circular_dependencies {
let mut issue = AuditIssue::new(
IssueSeverity::Critical,
IssueCategory::Dependencies,
"Circular dependency detected".to_string(),
format!(
"A circular dependency exists in the workspace: {}. \
This can cause issues with version resolution and may lead to infinite loops.",
circular_dep.display_cycle()
),
);
for package_name in &circular_dep.cycle {
issue.add_affected_package(package_name.clone());
}
issue.set_suggestion(
"Break the circular dependency by restructuring package dependencies. \
Consider extracting shared functionality into a separate package or \
using dependency inversion."
.to_string(),
);
issue.add_metadata("cycle".to_string(), circular_dep.display_cycle());
issue.add_metadata("cycle_length".to_string(), circular_dep.len().to_string());
section.issues.push(issue);
}
}
if config.audit.dependencies.check_version_conflicts {
section.version_conflicts = detect_version_conflicts(packages);
for conflict in §ion.version_conflicts {
let mut issue = AuditIssue::new(
IssueSeverity::Warning,
IssueCategory::Dependencies,
format!("Version conflict for dependency '{}'", conflict.dependency_name),
format!(
"Multiple packages depend on '{}' with different version specifications. \
This may cause unexpected behavior or installation issues. {}",
conflict.dependency_name,
conflict.describe()
),
);
for version_usage in &conflict.versions {
issue.add_affected_package(version_usage.package_name.clone());
}
issue.set_suggestion(format!(
"Align version specifications for '{}' across all packages. \
Consider using workspace protocol (workspace:*) for internal dependencies \
or ensure compatible version ranges for external dependencies.",
conflict.dependency_name
));
issue.add_metadata("dependency".to_string(), conflict.dependency_name.clone());
issue.add_metadata("conflict_count".to_string(), conflict.version_count().to_string());
for (idx, version_usage) in conflict.versions.iter().enumerate() {
issue.add_metadata(
format!("version_{}", idx),
format!("{}={}", version_usage.package_name, version_usage.version_spec),
);
}
section.issues.push(issue);
}
}
Ok(section)
}
fn detect_version_conflicts(packages: &[PackageInfo]) -> Vec<VersionConflict> {
let mut dependency_usage: HashMap<String, Vec<(String, String)>> = HashMap::new();
for package in packages {
let package_name = package.name().to_string();
for (dep_name, version_spec, dep_type) in package.all_dependencies() {
if is_workspace_or_local_protocol(&version_spec) {
continue;
}
let is_internal = packages.iter().any(|p| p.name() == dep_name);
if is_internal {
continue;
}
if matches!(dep_type, DependencyType::Regular | DependencyType::Peer) {
dependency_usage
.entry(dep_name)
.or_default()
.push((package_name.clone(), version_spec));
}
}
}
let mut conflicts = Vec::new();
for (dep_name, usages) in dependency_usage {
let mut version_map: HashMap<String, Vec<String>> = HashMap::new();
for (package_name, version_spec) in usages {
version_map.entry(version_spec.clone()).or_default().push(package_name);
}
if version_map.len() > 1 {
let mut versions = Vec::new();
for (version_spec, package_names) in version_map {
for package_name in package_names {
versions
.push(VersionUsage { package_name, version_spec: version_spec.clone() });
}
}
versions.sort_by(|a, b| {
a.package_name
.cmp(&b.package_name)
.then_with(|| a.version_spec.cmp(&b.version_spec))
});
conflicts.push(VersionConflict { dependency_name: dep_name, versions });
}
}
conflicts.sort_by(|a, b| a.dependency_name.cmp(&b.dependency_name));
conflicts
}
fn is_workspace_or_local_protocol(version_spec: &str) -> bool {
version_spec.starts_with("workspace:")
|| version_spec.starts_with("file:")
|| version_spec.starts_with("link:")
|| version_spec.starts_with("portal:")
}