use super::types::{DepFile, DepIssue, Severity};
use colored::Colorize;
#[derive(Debug)]
pub struct DepStats {
pub total_deps: usize,
pub dev_deps: usize,
pub optional_deps: usize,
pub git_deps: usize,
pub issue_count: usize,
pub critical_count: usize,
pub high_count: usize,
pub medium_count: usize,
pub low_count: usize,
pub score: f64,
}
pub fn build_stats(dep_files: &[DepFile], issues: &[DepIssue]) -> DepStats {
let total_deps: usize = dep_files.iter().map(|f| f.dependencies.len()).sum();
let dev_deps: usize = dep_files
.iter()
.flat_map(|f| &f.dependencies)
.filter(|d| d.is_dev)
.count();
let optional_deps: usize = dep_files
.iter()
.flat_map(|f| &f.dependencies)
.filter(|d| d.is_optional)
.count();
let git_deps: usize = dep_files
.iter()
.flat_map(|f| &f.dependencies)
.filter(|d| matches!(d.source, super::types::DepSource::Git { .. }))
.count();
let mut critical_count = 0;
let mut high_count = 0;
let mut medium_count = 0;
let mut low_count = 0;
for issue in issues {
match issue.severity {
Severity::Critical => critical_count += 1,
Severity::High => high_count += 1,
Severity::Medium => medium_count += 1,
Severity::Low => low_count += 1,
Severity::Info => {}
}
}
let penalty: f64 = issues.iter().map(|i| i.severity.penalty()).sum();
let score = (100.0 - penalty).max(0.0);
DepStats {
total_deps,
dev_deps,
optional_deps,
git_deps,
issue_count: issues.len(),
critical_count,
high_count,
medium_count,
low_count,
score,
}
}
pub fn format_terminal(dep_files: &[DepFile], issues: &[DepIssue]) -> String {
let stats = build_stats(dep_files, issues);
let mut out = String::new();
out.push_str(&format!(
"\n{}\n",
"\u{1f4e6} Dependency Shame Report \u{1f4e6}".bold()
));
out.push_str(&format!("{}\n\n", "\u{2501}".repeat(40)));
for dep_file in dep_files {
out.push_str(&format!(
" {} {}: {} dependencies\n",
"\u{1f4c1}",
dep_file.ecosystem.display_name().cyan(),
dep_file.dependencies.len()
));
}
out.push('\n');
let mut by_severity: Vec<(&Severity, &DepIssue)> =
issues.iter().map(|i| (&i.severity, i)).collect();
by_severity.sort_by(|a, b| a.0.cmp(b.0));
if !issues.is_empty() {
let critical: Vec<_> = by_severity
.iter()
.filter(|(s, _)| **s == Severity::Critical)
.collect();
if !critical.is_empty() {
out.push_str(&format!(
"{} {} ({})\n",
"\u{1f480}",
"Critical".red().bold(),
critical.len()
));
for (_, issue) in &critical {
let dep_info = issue
.dep_name
.as_ref()
.map(|n| format!(" [{}]", n))
.unwrap_or_default();
out.push_str(&format!(
" {} {}{}\n",
"\u{2022}",
issue.message,
dep_info.dimmed()
));
}
out.push('\n');
}
let high: Vec<_> = by_severity
.iter()
.filter(|(s, _)| **s == Severity::High)
.collect();
if !high.is_empty() {
out.push_str(&format!(
"{} {} ({})\n",
"\u{1f621}",
"High".red(),
high.len()
));
for (_, issue) in &high {
let dep_info = issue
.dep_name
.as_ref()
.map(|n| format!(" [{}]", n))
.unwrap_or_default();
out.push_str(&format!(
" {} {}{}\n",
"\u{2022}",
issue.message,
dep_info.dimmed()
));
}
out.push('\n');
}
let medium: Vec<_> = by_severity
.iter()
.filter(|(s, _)| **s == Severity::Medium)
.collect();
if !medium.is_empty() {
out.push_str(&format!(
"{} {} ({})\n",
"\u{26a0}\u{fe0f}",
"Medium".yellow(),
medium.len()
));
for (_, issue) in &medium {
let dep_info = issue
.dep_name
.as_ref()
.map(|n| format!(" [{}]", n))
.unwrap_or_default();
out.push_str(&format!(
" {} {}{}\n",
"\u{2022}",
issue.message,
dep_info.dimmed()
));
}
out.push('\n');
}
let low: Vec<_> = by_severity
.iter()
.filter(|(s, _)| **s == Severity::Low)
.collect();
if !low.is_empty() {
out.push_str(&format!(
"{} {} ({})\n",
"\u{1f4a7}",
"Low".blue(),
low.len()
));
for (_, issue) in &low {
let dep_info = issue
.dep_name
.as_ref()
.map(|n| format!(" [{}]", n))
.unwrap_or_default();
out.push_str(&format!(
" {} {}{}\n",
"\u{2022}",
issue.message,
dep_info.dimmed()
));
}
out.push('\n');
}
}
out.push_str(&format!("{}\n", "\u{1f4ca} Statistics".bold()));
out.push_str(&format!("{}\n", "\u{2500}".repeat(30)));
out.push_str(&format!(
" Total dependencies: {}\n",
stats.total_deps.to_string().cyan()
));
out.push_str(&format!(
" Dev dependencies: {}\n",
stats.dev_deps.to_string().cyan()
));
out.push_str(&format!(
" Optional deps: {}\n",
stats.optional_deps.to_string().cyan()
));
out.push_str(&format!(
" Git dependencies: {}\n",
stats.git_deps.to_string().yellow()
));
out.push_str(&format!(
" Issues found: {}\n",
stats.issue_count.to_string().red()
));
out.push('\n');
let score_str = if stats.score >= 80.0 {
format!("{:.0}/100", stats.score).green().bold()
} else if stats.score >= 60.0 {
format!("{:.0}/100", stats.score).yellow().bold()
} else {
format!("{:.0}/100", stats.score).red().bold()
};
out.push_str(&format!(
"{} Dependency Health Score: {}\n",
"\u{1f3af}", score_str
));
if issues.is_empty() {
out.push_str(&format!(
"\n{}\n",
"\u{2728} No dependency issues found. Your deps are clean!"
.green()
.bold()
));
}
out
}
pub fn format_json(dep_files: &[DepFile], issues: &[DepIssue]) -> String {
let stats = build_stats(dep_files, issues);
let json_output = serde_json::json!({
"score": stats.score,
"total_deps": stats.total_deps,
"dev_deps": stats.dev_deps,
"optional_deps": stats.optional_deps,
"git_deps": stats.git_deps,
"issues": issues.iter().map(|i| {
serde_json::json!({
"rule_id": i.rule_id,
"severity": format!("{:?}", i.severity),
"message": i.message,
"dep_name": i.dep_name,
})
}).collect::<Vec<_>>(),
"files": dep_files.iter().map(|f| {
serde_json::json!({
"path": f.path,
"ecosystem": format!("{:?}", f.ecosystem),
"dependency_count": f.dependencies.len(),
})
}).collect::<Vec<_>>(),
});
serde_json::to_string_pretty(&json_output).unwrap_or_else(|_| "{}".to_string())
}
#[cfg(test)]
mod tests {
use super::super::types::{DepSource, Dependency, Ecosystem};
use super::*;
fn sample_dep_file() -> DepFile {
DepFile {
path: "Cargo.toml".to_string(),
ecosystem: Ecosystem::Rust,
dependencies: vec![
Dependency {
name: "serde".to_string(),
version: "1.0".to_string(),
source: DepSource::Registry,
is_dev: false,
is_optional: false,
},
Dependency {
name: "tempfile".to_string(),
version: "3.0".to_string(),
source: DepSource::Registry,
is_dev: true,
is_optional: false,
},
],
}
}
#[test]
fn test_build_stats_counts() {
let dep_file = sample_dep_file();
let issues = vec![
DepIssue {
rule_id: "test".to_string(),
severity: Severity::High,
message: "test".to_string(),
dep_name: None,
},
DepIssue {
rule_id: "test".to_string(),
severity: Severity::Low,
message: "test".to_string(),
dep_name: None,
},
];
let stats = build_stats(&[dep_file], &issues);
assert_eq!(stats.total_deps, 2);
assert_eq!(stats.dev_deps, 1);
assert_eq!(stats.issue_count, 2);
assert_eq!(stats.high_count, 1);
assert_eq!(stats.low_count, 1);
assert!(stats.score < 100.0);
}
#[test]
fn test_format_terminal_empty() {
let dep_file = DepFile {
path: "Cargo.toml".to_string(),
ecosystem: Ecosystem::Rust,
dependencies: vec![],
};
let output = format_terminal(&[dep_file], &[]);
assert!(output.contains("Dependency Shame Report"));
assert!(output.contains("No dependency issues found"));
}
#[test]
fn test_format_terminal_with_issues() {
let dep_file = sample_dep_file();
let issues = vec![DepIssue {
rule_id: "wildcard-version".to_string(),
severity: Severity::High,
message: "Version '*' for 'tokio'".to_string(),
dep_name: Some("tokio".to_string()),
}];
let output = format_terminal(&[dep_file], &issues);
assert!(output.contains("High"));
assert!(output.contains("tokio"));
}
#[test]
fn test_format_json_valid() {
let dep_file = sample_dep_file();
let issues = vec![DepIssue {
rule_id: "test".to_string(),
severity: Severity::Medium,
message: "test issue".to_string(),
dep_name: Some("serde".to_string()),
}];
let json = format_json(&[dep_file], &issues);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["score"].as_f64().is_some());
assert!(parsed["issues"].as_array().unwrap().len() == 1);
}
}