Skip to main content

garbage_code_hunter/deps_shamer/
report.rs

1//! Report generation for dependency analysis.
2
3use super::types::{DepFile, DepIssue, Severity};
4use colored::Colorize;
5
6/// Summary statistics for a dependency analysis.
7#[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
21/// Build statistics from dependency files and issues.
22pub 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
72/// Format analysis report for terminal output.
73pub 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    // File summaries
84    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    // Group issues by severity
95    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        // Critical issues
101        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        // High issues
129        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        // Medium issues
157        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        // Low issues
185        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    // Statistics
214    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    // Score
238    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
263/// Format analysis report as JSON.
264pub 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}