use cargo_metadata::{DependencyKind, MetadataCommand};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use thiserror::Error;
use super::allocator::AllocatorDetector;
use super::heavy_deps::{get_heavy_dependency_info, AlternativeType};
pub use super::deps_types::{DependencyIssue, DependencyReport, IssueSeverity};
#[derive(Error, Debug)]
pub enum DependencyAnalysisError {
#[error("Failed to run cargo metadata: {0}")]
MetadataCommand(#[from] cargo_metadata::Error),
#[error("No dependency resolution found in cargo metadata")]
NoResolution,
#[error("No root package found in cargo metadata")]
NoRootPackage,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
pub struct DependencyAnalyzer {
project_root: std::path::PathBuf,
}
impl DependencyAnalyzer {
pub fn new(project_root: impl AsRef<Path>) -> Self {
Self {
project_root: project_root.as_ref().to_path_buf(),
}
}
#[must_use = "Analysis results should be used or printed"]
pub fn analyze(&self) -> Result<DependencyReport, DependencyAnalysisError> {
let metadata = MetadataCommand::new()
.current_dir(&self.project_root)
.exec()?;
let mut issues = Vec::new();
let mut duplicates: HashMap<String, Vec<String>> = HashMap::new();
let workspace_members: HashSet<_> = metadata.workspace_members.iter().collect();
for package in &metadata.packages {
if workspace_members.contains(&package.id) {
continue;
}
if let Some(heavy_info) = get_heavy_dependency_info(&package.name) {
let severity = self.determine_severity(heavy_info.bundle_percent);
for alternative in &heavy_info.alternatives {
issues.push(DependencyIssue {
package: package.name.to_string(),
version: package.version.to_string(),
severity,
issue: heavy_info.reason.to_string(),
suggestion: self.format_suggestion(alternative),
size_impact_kb: alternative.size_kb.map(|(current_min, current_max)| {
let saved_min = heavy_info.size_kb.0.saturating_sub(current_max);
let saved_max = heavy_info.size_kb.1.saturating_sub(current_min);
(saved_min, saved_max)
}),
savings_percent: Some(alternative.savings_percent),
});
}
}
duplicates
.entry(package.name.to_string())
.or_default()
.push(package.version.to_string());
}
duplicates.retain(|_, versions| {
versions.sort();
versions.dedup();
versions.len() > 1
});
let allocator_detector = AllocatorDetector::new(&self.project_root);
if let Ok(Some(allocator_issue)) =
allocator_detector.check_allocator_optimization_with_metadata(&metadata)
{
issues.push(allocator_issue);
}
let resolve = metadata
.resolve
.as_ref()
.ok_or(DependencyAnalysisError::NoResolution)?;
let total_deps = resolve.nodes.len();
let root_package = metadata
.root_package()
.ok_or(DependencyAnalysisError::NoRootPackage)?;
let direct_deps = root_package
.dependencies
.iter()
.filter(|d| matches!(d.kind, DependencyKind::Normal))
.count();
Ok(DependencyReport {
total_deps,
direct_deps,
issues,
duplicates,
})
}
fn determine_severity(&self, bundle_percent: Option<(u8, u8)>) -> IssueSeverity {
match bundle_percent {
Some((_, max)) if max >= 30 => IssueSeverity::Critical,
Some((_, max)) if max >= 20 => IssueSeverity::High,
Some((_, max)) if max >= 10 => IssueSeverity::Medium,
_ => IssueSeverity::Low,
}
}
fn format_suggestion(&self, alternative: &super::heavy_deps::DependencyAlternative) -> String {
match alternative.alt_type {
AlternativeType::Replacement => {
format!(
"Replace with {} ({})",
alternative.crate_name.unwrap_or("alternative"),
alternative.description
)
}
AlternativeType::FeatureMinimization => {
format!("Use default-features = false ({})", alternative.description)
}
AlternativeType::Split => {
format!("Split into components ({})", alternative.description)
}
AlternativeType::Optional => {
format!(
"Make optional via feature flag ({})",
alternative.description
)
}
AlternativeType::WasmFix => {
format!("Enable WASM features ({})", alternative.description)
}
}
}
}
#[cfg(test)]
mod edge_case_tests {
use super::*;
#[test]
fn test_analyze_deps_with_workspace_members() {
let analyzer = DependencyAnalyzer::new(".");
let result = analyzer.analyze();
assert!(result.is_ok());
}
#[test]
fn test_analyze_deps_with_missing_metadata() {
let analyzer = DependencyAnalyzer::new("/nonexistent/path");
let result = analyzer.analyze();
assert!(result.is_err());
}
#[test]
fn test_determine_severity_edge_cases() {
let analyzer = DependencyAnalyzer::new(".");
assert_eq!(
analyzer.determine_severity(Some((30, 30))),
IssueSeverity::Critical
);
assert_eq!(
analyzer.determine_severity(Some((0, 0))),
IssueSeverity::Low
);
assert_eq!(
analyzer.determine_severity(Some((100, 200))),
IssueSeverity::Critical
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_determine_severity_returns_correct_level_for_size_ranges() {
let analyzer = DependencyAnalyzer::new(".");
assert_eq!(
analyzer.determine_severity(Some((30, 40))),
IssueSeverity::Critical
);
assert_eq!(
analyzer.determine_severity(Some((20, 25))),
IssueSeverity::High
);
assert_eq!(
analyzer.determine_severity(Some((10, 15))),
IssueSeverity::Medium
);
assert_eq!(
analyzer.determine_severity(Some((5, 8))),
IssueSeverity::Low
);
assert_eq!(analyzer.determine_severity(None), IssueSeverity::Low);
}
}