garbage_code_hunter/deps_shamer/
report.rs1use super::types::{DepFile, DepIssue, Severity};
4use colored::Colorize;
5
6#[derive(Debug)]
8pub struct DepStats {
9 pub total_deps: usize,
10 pub dev_deps: usize,
11 pub optional_deps: usize,
12 pub git_deps: usize,
13 pub issue_count: usize,
14 pub critical_count: usize,
15 pub high_count: usize,
16 pub medium_count: usize,
17 pub low_count: usize,
18 pub score: f64,
19}
20
21pub fn build_stats(dep_files: &[DepFile], issues: &[DepIssue]) -> DepStats {
23 let total_deps: usize = dep_files.iter().map(|f| f.dependencies.len()).sum();
24 let dev_deps: usize = dep_files
25 .iter()
26 .flat_map(|f| &f.dependencies)
27 .filter(|d| d.is_dev)
28 .count();
29 let optional_deps: usize = dep_files
30 .iter()
31 .flat_map(|f| &f.dependencies)
32 .filter(|d| d.is_optional)
33 .count();
34 let git_deps: usize = dep_files
35 .iter()
36 .flat_map(|f| &f.dependencies)
37 .filter(|d| matches!(d.source, super::types::DepSource::Git { .. }))
38 .count();
39
40 let mut critical_count = 0;
41 let mut high_count = 0;
42 let mut medium_count = 0;
43 let mut low_count = 0;
44
45 for issue in issues {
46 match issue.severity {
47 Severity::Critical => critical_count += 1,
48 Severity::High => high_count += 1,
49 Severity::Medium => medium_count += 1,
50 Severity::Low => low_count += 1,
51 Severity::Info => {}
52 }
53 }
54
55 let penalty: f64 = issues.iter().map(|i| i.severity.penalty()).sum();
56 let score = (100.0 - penalty).max(0.0);
57
58 DepStats {
59 total_deps,
60 dev_deps,
61 optional_deps,
62 git_deps,
63 issue_count: issues.len(),
64 critical_count,
65 high_count,
66 medium_count,
67 low_count,
68 score,
69 }
70}
71
72pub fn format_terminal(dep_files: &[DepFile], issues: &[DepIssue]) -> String {
74 let stats = build_stats(dep_files, issues);
75 let mut out = String::new();
76
77 out.push_str(&format!(
78 "\n{}\n",
79 "\u{1f4e6} Dependency Shame Report \u{1f4e6}".bold()
80 ));
81 out.push_str(&format!("{}\n\n", "\u{2501}".repeat(40)));
82
83 for dep_file in dep_files {
85 out.push_str(&format!(
86 " {} {}: {} dependencies\n",
87 "\u{1f4c1}",
88 dep_file.ecosystem.display_name().cyan(),
89 dep_file.dependencies.len()
90 ));
91 }
92 out.push('\n');
93
94 let mut by_severity: Vec<(&Severity, &DepIssue)> =
96 issues.iter().map(|i| (&i.severity, i)).collect();
97 by_severity.sort_by(|a, b| a.0.cmp(b.0));
98
99 if !issues.is_empty() {
100 let critical: Vec<_> = by_severity
102 .iter()
103 .filter(|(s, _)| **s == Severity::Critical)
104 .collect();
105 if !critical.is_empty() {
106 out.push_str(&format!(
107 "{} {} ({})\n",
108 "\u{1f480}",
109 "Critical".red().bold(),
110 critical.len()
111 ));
112 for (_, issue) in &critical {
113 let dep_info = issue
114 .dep_name
115 .as_ref()
116 .map(|n| format!(" [{}]", n))
117 .unwrap_or_default();
118 out.push_str(&format!(
119 " {} {}{}\n",
120 "\u{2022}",
121 issue.message,
122 dep_info.dimmed()
123 ));
124 }
125 out.push('\n');
126 }
127
128 let high: Vec<_> = by_severity
130 .iter()
131 .filter(|(s, _)| **s == Severity::High)
132 .collect();
133 if !high.is_empty() {
134 out.push_str(&format!(
135 "{} {} ({})\n",
136 "\u{1f621}",
137 "High".red(),
138 high.len()
139 ));
140 for (_, issue) in &high {
141 let dep_info = issue
142 .dep_name
143 .as_ref()
144 .map(|n| format!(" [{}]", n))
145 .unwrap_or_default();
146 out.push_str(&format!(
147 " {} {}{}\n",
148 "\u{2022}",
149 issue.message,
150 dep_info.dimmed()
151 ));
152 }
153 out.push('\n');
154 }
155
156 let medium: Vec<_> = by_severity
158 .iter()
159 .filter(|(s, _)| **s == Severity::Medium)
160 .collect();
161 if !medium.is_empty() {
162 out.push_str(&format!(
163 "{} {} ({})\n",
164 "\u{26a0}\u{fe0f}",
165 "Medium".yellow(),
166 medium.len()
167 ));
168 for (_, issue) in &medium {
169 let dep_info = issue
170 .dep_name
171 .as_ref()
172 .map(|n| format!(" [{}]", n))
173 .unwrap_or_default();
174 out.push_str(&format!(
175 " {} {}{}\n",
176 "\u{2022}",
177 issue.message,
178 dep_info.dimmed()
179 ));
180 }
181 out.push('\n');
182 }
183
184 let low: Vec<_> = by_severity
186 .iter()
187 .filter(|(s, _)| **s == Severity::Low)
188 .collect();
189 if !low.is_empty() {
190 out.push_str(&format!(
191 "{} {} ({})\n",
192 "\u{1f4a7}",
193 "Low".blue(),
194 low.len()
195 ));
196 for (_, issue) in &low {
197 let dep_info = issue
198 .dep_name
199 .as_ref()
200 .map(|n| format!(" [{}]", n))
201 .unwrap_or_default();
202 out.push_str(&format!(
203 " {} {}{}\n",
204 "\u{2022}",
205 issue.message,
206 dep_info.dimmed()
207 ));
208 }
209 out.push('\n');
210 }
211 }
212
213 out.push_str(&format!("{}\n", "\u{1f4ca} Statistics".bold()));
215 out.push_str(&format!("{}\n", "\u{2500}".repeat(30)));
216 out.push_str(&format!(
217 " Total dependencies: {}\n",
218 stats.total_deps.to_string().cyan()
219 ));
220 out.push_str(&format!(
221 " Dev dependencies: {}\n",
222 stats.dev_deps.to_string().cyan()
223 ));
224 out.push_str(&format!(
225 " Optional deps: {}\n",
226 stats.optional_deps.to_string().cyan()
227 ));
228 out.push_str(&format!(
229 " Git dependencies: {}\n",
230 stats.git_deps.to_string().yellow()
231 ));
232 out.push_str(&format!(
233 " Issues found: {}\n",
234 stats.issue_count.to_string().red()
235 ));
236
237 out.push('\n');
239 let score_str = if stats.score >= 80.0 {
240 format!("{:.0}/100", stats.score).green().bold()
241 } else if stats.score >= 60.0 {
242 format!("{:.0}/100", stats.score).yellow().bold()
243 } else {
244 format!("{:.0}/100", stats.score).red().bold()
245 };
246 out.push_str(&format!(
247 "{} Dependency Health Score: {}\n",
248 "\u{1f3af}", score_str
249 ));
250
251 if issues.is_empty() {
252 out.push_str(&format!(
253 "\n{}\n",
254 "\u{2728} No dependency issues found. Your deps are clean!"
255 .green()
256 .bold()
257 ));
258 }
259
260 out
261}
262
263pub fn format_json(dep_files: &[DepFile], issues: &[DepIssue]) -> String {
265 let stats = build_stats(dep_files, issues);
266 let json_output = serde_json::json!({
267 "score": stats.score,
268 "total_deps": stats.total_deps,
269 "dev_deps": stats.dev_deps,
270 "optional_deps": stats.optional_deps,
271 "git_deps": stats.git_deps,
272 "issues": issues.iter().map(|i| {
273 serde_json::json!({
274 "rule_id": i.rule_id,
275 "severity": format!("{:?}", i.severity),
276 "message": i.message,
277 "dep_name": i.dep_name,
278 })
279 }).collect::<Vec<_>>(),
280 "files": dep_files.iter().map(|f| {
281 serde_json::json!({
282 "path": f.path,
283 "ecosystem": format!("{:?}", f.ecosystem),
284 "dependency_count": f.dependencies.len(),
285 })
286 }).collect::<Vec<_>>(),
287 });
288
289 serde_json::to_string_pretty(&json_output).unwrap_or_else(|_| "{}".to_string())
290}
291
292#[cfg(test)]
293mod tests {
294 use super::super::types::{DepSource, Dependency, Ecosystem};
295 use super::*;
296
297 fn sample_dep_file() -> DepFile {
298 DepFile {
299 path: "Cargo.toml".to_string(),
300 ecosystem: Ecosystem::Rust,
301 dependencies: vec![
302 Dependency {
303 name: "serde".to_string(),
304 version: "1.0".to_string(),
305 source: DepSource::Registry,
306 is_dev: false,
307 is_optional: false,
308 },
309 Dependency {
310 name: "tempfile".to_string(),
311 version: "3.0".to_string(),
312 source: DepSource::Registry,
313 is_dev: true,
314 is_optional: false,
315 },
316 ],
317 }
318 }
319
320 #[test]
321 fn test_build_stats_counts() {
322 let dep_file = sample_dep_file();
323 let issues = vec![
324 DepIssue {
325 rule_id: "test".to_string(),
326 severity: Severity::High,
327 message: "test".to_string(),
328 dep_name: None,
329 },
330 DepIssue {
331 rule_id: "test".to_string(),
332 severity: Severity::Low,
333 message: "test".to_string(),
334 dep_name: None,
335 },
336 ];
337
338 let stats = build_stats(&[dep_file], &issues);
339 assert_eq!(stats.total_deps, 2);
340 assert_eq!(stats.dev_deps, 1);
341 assert_eq!(stats.issue_count, 2);
342 assert_eq!(stats.high_count, 1);
343 assert_eq!(stats.low_count, 1);
344 assert!(stats.score < 100.0);
345 }
346
347 #[test]
348 fn test_format_terminal_empty() {
349 let dep_file = DepFile {
350 path: "Cargo.toml".to_string(),
351 ecosystem: Ecosystem::Rust,
352 dependencies: vec![],
353 };
354 let output = format_terminal(&[dep_file], &[]);
355 assert!(output.contains("Dependency Shame Report"));
356 assert!(output.contains("No dependency issues found"));
357 }
358
359 #[test]
360 fn test_format_terminal_with_issues() {
361 let dep_file = sample_dep_file();
362 let issues = vec![DepIssue {
363 rule_id: "wildcard-version".to_string(),
364 severity: Severity::High,
365 message: "Version '*' for 'tokio'".to_string(),
366 dep_name: Some("tokio".to_string()),
367 }];
368 let output = format_terminal(&[dep_file], &issues);
369 assert!(output.contains("High"));
370 assert!(output.contains("tokio"));
371 }
372
373 #[test]
374 fn test_format_json_valid() {
375 let dep_file = sample_dep_file();
376 let issues = vec![DepIssue {
377 rule_id: "test".to_string(),
378 severity: Severity::Medium,
379 message: "test issue".to_string(),
380 dep_name: Some("serde".to_string()),
381 }];
382 let json = format_json(&[dep_file], &issues);
383 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
384 assert!(parsed["score"].as_f64().is_some());
385 assert!(parsed["issues"].as_array().unwrap().len() == 1);
386 }
387}