Skip to main content

garbage_code_hunter/radar/
mod.rs

1//! Code Smell Radar — SVG radar chart of code quality dimensions.
2
3use crate::analyzer::{CodeAnalyzer, CodeIssue};
4use crate::common::i18n_ext::t;
5use crate::common::OutputFormat;
6use anyhow::Result;
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Radar chart dimensions.
11#[derive(Debug, Clone)]
12pub struct RadarData {
13    pub complexity: f64,
14    pub duplication: f64,
15    pub naming: f64,
16    pub panic_risk: f64,
17    pub dependency_hell: f64,
18    pub legacy_smell: f64,
19}
20
21impl RadarData {
22    pub fn dimensions(&self) -> [(&str, f64); 6] {
23        [
24            ("Complexity", self.complexity),
25            ("Duplication", self.duplication),
26            ("Naming", self.naming),
27            ("Panic Risk", self.panic_risk),
28            ("Dep Hell", self.dependency_hell),
29            ("Legacy Smell", self.legacy_smell),
30        ]
31    }
32}
33
34/// Run radar analysis and generate SVG.
35pub fn run(
36    path: &Path,
37    format: &OutputFormat,
38    lang: &str,
39    output_path: Option<&Path>,
40) -> Result<String> {
41    let analyzer = CodeAnalyzer::new(&[], lang);
42    let issues = analyzer.analyze_path(path);
43    let data = analyze_dimensions(&issues);
44
45    match format {
46        OutputFormat::Json => Ok(display_json(&data)),
47        OutputFormat::Terminal => {
48            let svg = generate_svg(&data);
49            if let Some(out_path) = output_path {
50                std::fs::write(out_path, &svg)?;
51                Ok(format!(
52                    "\n  Radar chart written to {}\n",
53                    out_path.display()
54                ))
55            } else {
56                Ok(display_terminal(&data, lang))
57            }
58        }
59    }
60}
61
62fn analyze_dimensions(issues: &[CodeIssue]) -> RadarData {
63    let mut counts: HashMap<&str, f64> = HashMap::new();
64
65    for issue in issues {
66        let cat = categorize(&issue.rule_name);
67        *counts.entry(cat).or_insert(0.0) += 1.0;
68    }
69
70    // Normalize to 0-100 scale (higher = worse smell)
71    let normalize = |key: &str| -> f64 {
72        let count = counts.get(key).copied().unwrap_or(0.0);
73        (count * 5.0).min(100.0)
74    };
75
76    RadarData {
77        complexity: normalize("complexity"),
78        duplication: normalize("duplication"),
79        naming: normalize("naming"),
80        panic_risk: normalize("panic_risk"),
81        dependency_hell: normalize("dependency_hell"),
82        legacy_smell: normalize("legacy_smell"),
83    }
84}
85
86fn categorize(rule_name: &str) -> &'static str {
87    let lower = rule_name.to_lowercase();
88    if lower.contains("nest") || lower.contains("complex") || lower.contains("long") {
89        "complexity"
90    } else if lower.contains("duplicat") || lower.contains("copy") {
91        "duplication"
92    } else if lower.contains("name")
93        || lower.contains("single_letter")
94        || lower.contains("meaningless")
95    {
96        "naming"
97    } else if lower.contains("unwrap") {
98        "panic_risk"
99    } else {
100        "legacy_smell"
101    }
102}
103
104/// Generate SVG radar chart.
105fn generate_svg(data: &RadarData) -> String {
106    let dims = data.dimensions();
107    let center_x: f64 = 150.0;
108    let center_y: f64 = 150.0;
109    let radius: f64 = 120.0;
110    let n = dims.len() as f64;
111    let angle_step = 2.0 * std::f64::consts::PI / n;
112
113    let mut svg = String::new();
114    svg.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"300\" height=\"340\" viewBox=\"0 0 300 340\">\n");
115    svg.push_str("  <style>\n");
116    svg.push_str("    text { font-family: sans-serif; font-size: 11px; fill: #333; }\n");
117    svg.push_str("    .title { font-size: 14px; font-weight: bold; fill: #111; }\n");
118    svg.push_str("  </style>\n");
119
120    // Title
121    svg.push_str("  <text x=\"150\" y=\"20\" text-anchor=\"middle\" class=\"title\">Code Smell Radar</text>\n");
122
123    // Background circles
124    for i in 1..=4 {
125        let r = radius * i as f64 / 4.0;
126        svg.push_str(&format!(
127            "  <circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"none\" stroke=\"#ddd\" stroke-width=\"0.5\"/>\n",
128            center_x, center_y, r
129        ));
130    }
131
132    // Axis lines and labels
133    for (i, (label, _)) in dims.iter().enumerate() {
134        let angle = angle_step * i as f64 - std::f64::consts::PI / 2.0;
135        let x2 = center_x + radius * angle.cos();
136        let y2 = center_y + radius * angle.sin();
137        svg.push_str(&format!(
138            "  <line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#ccc\" stroke-width=\"0.5\"/>\n",
139            center_x, center_y, x2, y2
140        ));
141
142        // Label
143        let label_r = radius + 18.0;
144        let lx = center_x + label_r * angle.cos();
145        let ly = center_y + label_r * angle.sin();
146        let anchor = if lx < center_x - 10.0 {
147            "end"
148        } else if lx > center_x + 10.0 {
149            "start"
150        } else {
151            "middle"
152        };
153        svg.push_str(&format!(
154            "  <text x=\"{}\" y=\"{}\" text-anchor=\"{}\">{}</text>\n",
155            lx, ly, anchor, label
156        ));
157    }
158
159    // Data polygon
160    let mut points = Vec::new();
161    for (i, (_, value)) in dims.iter().enumerate() {
162        let angle = angle_step * i as f64 - std::f64::consts::PI / 2.0;
163        let r = radius * (value / 100.0);
164        let x = center_x + r * angle.cos();
165        let y = center_y + r * angle.sin();
166        points.push(format!("{},{}", x, y));
167    }
168    svg.push_str(&format!(
169        "  <polygon points=\"{}\" fill=\"rgba(255,99,71,0.25)\" stroke=\"#e74c3c\" stroke-width=\"2\"/>\n",
170        points.join(" ")
171    ));
172
173    // Data points
174    for (i, (_, value)) in dims.iter().enumerate() {
175        let angle = angle_step * i as f64 - std::f64::consts::PI / 2.0;
176        let r = radius * (value / 100.0);
177        let x = center_x + r * angle.cos();
178        let y = center_y + r * angle.sin();
179        svg.push_str(&format!(
180            "  <circle cx=\"{}\" cy=\"{}\" r=\"3\" fill=\"#e74c3c\"/>\n",
181            x, y
182        ));
183        // Value label
184        svg.push_str(&format!(
185            "  <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#666\">{:.0}</text>\n",
186            x, y - 8.0, value
187        ));
188    }
189
190    // Legend
191    svg.push_str("  <text x=\"150\" y=\"330\" text-anchor=\"middle\" font-size=\"10\" fill=\"#666\">Higher = worse smell</text>\n");
192
193    svg.push_str("</svg>");
194    svg
195}
196
197fn display_terminal(data: &RadarData, lang: &str) -> String {
198    let mut out = String::new();
199    out.push_str(&format!(
200        "\n{}\n",
201        t(lang, "\u{1f4e1} 代码气味雷达", "\u{1f4e1} Code Smell Radar").bold()
202    ));
203    out.push_str(&format!("{}\n\n", "\u{2501}".repeat(40)));
204
205    for (label, value) in data.dimensions() {
206        let bar_len = (value / 5.0) as usize;
207        let bar: String = "\u{2588}".repeat(bar_len);
208        let bar_colored = if value >= 70.0 {
209            bar.red()
210        } else if value >= 40.0 {
211            bar.yellow()
212        } else {
213            bar.green()
214        };
215        out.push_str(&format!("  {:<16} {:>5.0} {}\n", label, value, bar_colored));
216    }
217
218    out.push_str(&format!(
219        "\n  {}\n",
220        t(
221            lang,
222            "使用 --output <file.svg> 生成雷达图 SVG",
223            "Use --output <file.svg> to generate radar chart SVG"
224        )
225    ));
226
227    out
228}
229
230fn display_json(data: &RadarData) -> String {
231    serde_json::json!({
232        "complexity": data.complexity,
233        "duplication": data.duplication,
234        "naming": data.naming,
235        "panic_risk": data.panic_risk,
236        "dependency_hell": data.dependency_hell,
237        "legacy_smell": data.legacy_smell,
238    })
239    .to_string()
240}
241
242use colored::Colorize;
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use std::path::PathBuf;
248
249    // ── categorize ─────────────────────────────────────────────────
250
251    /// Objective: Verify categorize correctly maps rule names to all five categories.
252    /// Invariants: Substring matching is case-insensitive.
253    #[test]
254    fn test_categorize_all_branches() {
255        assert_eq!(
256            categorize("deep_nesting"),
257            "complexity",
258            "nest => complexity"
259        );
260        assert_eq!(
261            categorize("complex_closure"),
262            "complexity",
263            "complex => complexity"
264        );
265        assert_eq!(
266            categorize("long_function"),
267            "complexity",
268            "long => complexity"
269        );
270        assert_eq!(
271            categorize("code_duplication"),
272            "duplication",
273            "duplicat => duplication"
274        );
275        assert_eq!(
276            categorize("cross_file_copy"),
277            "duplication",
278            "copy => duplication"
279        );
280        assert_eq!(categorize("bad_name"), "naming", "name => naming");
281        assert_eq!(
282            categorize("single_letter_variable"),
283            "naming",
284            "single_letter => naming"
285        );
286        assert_eq!(
287            categorize("meaningless_name"),
288            "naming",
289            "meaningless => naming"
290        );
291        assert_eq!(
292            categorize("unwrap_abuse"),
293            "panic_risk",
294            "unwrap => panic_risk"
295        );
296    }
297
298    /// Objective: Verify that unrecognized rule names fall through to "legacy_smell".
299    /// Invariants: Any rule that doesn't match a known keyword goes to legacy_smell.
300    #[test]
301    fn test_categorize_fallback_to_legacy_smell() {
302        assert_eq!(
303            categorize("magic_number"),
304            "legacy_smell",
305            "magic_number should fallback"
306        );
307        assert_eq!(
308            categorize("println_debugging"),
309            "legacy_smell",
310            "println should fallback"
311        );
312        assert_eq!(
313            categorize("commented_code"),
314            "legacy_smell",
315            "commented_code should fallback"
316        );
317    }
318
319    /// Objective: Verify categorize is case-insensitive (lowercases input).
320    #[test]
321    fn test_categorize_case_insensitive() {
322        assert_eq!(
323            categorize("DEEP_NESTING"),
324            "complexity",
325            "UPPER should still match"
326        );
327        assert_eq!(
328            categorize("Unwrap_Abuse"),
329            "panic_risk",
330            "mixed case should still match"
331        );
332    }
333
334    // ── analyze_dimensions ─────────────────────────────────────────
335
336    /// Objective: Verify analyze_dimensions returns all zeros when given an empty issue list.
337    /// Invariants: No crash on empty input. All dimensions start at 0.0.
338    #[test]
339    fn test_analyze_dimensions_empty_issues() {
340        let data = analyze_dimensions(&[]);
341        for (_, v) in data.dimensions() {
342            assert_eq!(v, 0.0, "all dimensions must be 0 for empty issues, got {v}");
343        }
344    }
345
346    /// Objective: Verify that 1 issue produces exactly 5.0 in its dimension (count * 5 = 5).
347    /// Invariants: Each issue contributes 5 points to its category.
348    #[test]
349    fn test_analyze_dimensions_single_issue() {
350        let issues = vec![CodeIssue {
351            file_path: PathBuf::from("test.rs"),
352            line: 1,
353            column: 1,
354            rule_name: "unwrap_abuse".into(),
355            message: String::new(),
356            severity: crate::analyzer::Severity::Nuclear,
357        }];
358        let data = analyze_dimensions(&issues);
359        assert_eq!(
360            data.panic_risk, 5.0,
361            "1 unwrap issue => 5.0, got {}",
362            data.panic_risk
363        );
364        assert_eq!(data.complexity, 0.0, "no complexity issues => 0.0");
365    }
366
367    /// Objective: Verify the normalization cap: 20 issues = 100.0, 21 issues also = 100.0.
368    /// Invariants: Maximum value is clamped to 100.0 regardless of count.
369    #[test]
370    fn test_analyze_dimensions_capped_at_100() {
371        let mut issues = Vec::new();
372        for i in 0..21 {
373            issues.push(CodeIssue {
374                file_path: PathBuf::from("test.rs"),
375                line: i,
376                column: 1,
377                rule_name: "unwrap_abuse".into(),
378                message: String::new(),
379                severity: crate::analyzer::Severity::Nuclear,
380            });
381        }
382        let data = analyze_dimensions(&issues);
383        assert_eq!(
384            data.panic_risk, 100.0,
385            "21 issues => cap at 100, got {}",
386            data.panic_risk
387        );
388        assert_eq!(data.complexity, 0.0, "no complexity issues => 0.0");
389    }
390
391    /// Objective: Verify multiple categories accumulate independently.
392    /// Invariants: Issues in different categories don't interfere with each other's scores.
393    #[test]
394    fn test_analyze_dimensions_multiple_categories() {
395        let issues = vec![
396            CodeIssue {
397                file_path: PathBuf::from("t.rs"),
398                line: 1,
399                column: 1,
400                rule_name: "unwrap_abuse".into(),
401                message: String::new(),
402                severity: crate::analyzer::Severity::Nuclear,
403            },
404            CodeIssue {
405                file_path: PathBuf::from("t.rs"),
406                line: 2,
407                column: 1,
408                rule_name: "deep_nesting".into(),
409                message: String::new(),
410                severity: crate::analyzer::Severity::Nuclear,
411            },
412        ];
413        let data = analyze_dimensions(&issues);
414        assert_eq!(data.panic_risk, 5.0, "1 unwrap => 5");
415        assert_eq!(data.complexity, 5.0, "1 nesting => 5");
416    }
417
418    // ── generate_svg ──────────────────────────────────────────────
419
420    /// Objective: Verify SVG contains all required structural elements.
421    #[test]
422    fn test_generate_svg_structure() {
423        let data = RadarData {
424            complexity: 50.0,
425            duplication: 30.0,
426            naming: 70.0,
427            panic_risk: 20.0,
428            dependency_hell: 10.0,
429            legacy_smell: 40.0,
430        };
431        let svg = generate_svg(&data);
432        assert!(svg.contains("<svg"), "SVG must start with <svg tag");
433        assert!(svg.contains("</svg>"), "SVG must close");
434        assert!(svg.contains("polygon"), "SVG must contain data polygon");
435        assert!(svg.contains("Code Smell Radar"), "SVG must have title");
436    }
437
438    /// Objective: Verify all-zero SVG renders without error (center-point polygon).
439    #[test]
440    fn test_generate_svg_all_zeros_renders() {
441        let data = RadarData {
442            complexity: 0.0,
443            duplication: 0.0,
444            naming: 0.0,
445            panic_risk: 0.0,
446            dependency_hell: 0.0,
447            legacy_smell: 0.0,
448        };
449        let svg = generate_svg(&data);
450        assert!(svg.contains("<svg"), "zero data should produce valid SVG");
451    }
452
453    /// Objective: Verify all-max SVG renders with polygon at full radius.
454    #[test]
455    fn test_generate_svg_all_max_renders() {
456        let data = RadarData {
457            complexity: 100.0,
458            duplication: 100.0,
459            naming: 100.0,
460            panic_risk: 100.0,
461            dependency_hell: 100.0,
462            legacy_smell: 100.0,
463        };
464        let svg = generate_svg(&data);
465        assert!(svg.contains("polygon"), "max data should produce polygon");
466    }
467
468    // ── display_json ──────────────────────────────────────────────
469
470    /// Objective: Verify JSON output contains all 6 dimensions with correct values.
471    /// Invariants: JSON keys match RadarData fields exactly.
472    #[test]
473    fn test_display_json_all_fields_present() {
474        let data = RadarData {
475            complexity: 50.0,
476            duplication: 30.0,
477            naming: 70.0,
478            panic_risk: 20.0,
479            dependency_hell: 10.0,
480            legacy_smell: 40.0,
481        };
482        let json = display_json(&data);
483        let parsed: serde_json::Value = serde_json::from_str(&json).expect("JSON should be valid");
484        assert_eq!(parsed["complexity"], 50.0, "complexity mismatch");
485        assert_eq!(parsed["duplication"], 30.0, "duplication mismatch");
486        assert_eq!(parsed["naming"], 70.0, "naming mismatch");
487        assert_eq!(parsed["panic_risk"], 20.0, "panic_risk mismatch");
488        assert_eq!(parsed["dependency_hell"], 10.0, "dependency_hell mismatch");
489        assert_eq!(parsed["legacy_smell"], 40.0, "legacy_smell mismatch");
490    }
491
492    /// Objective: Verify that zero-value radar data produces valid JSON with all zeros.
493    #[test]
494    fn test_display_json_all_zeros() {
495        let data = RadarData {
496            complexity: 0.0,
497            duplication: 0.0,
498            naming: 0.0,
499            panic_risk: 0.0,
500            dependency_hell: 0.0,
501            legacy_smell: 0.0,
502        };
503        let json = display_json(&data);
504        let parsed: serde_json::Value = serde_json::from_str(&json).expect("JSON should be valid");
505        for key in &[
506            "complexity",
507            "duplication",
508            "naming",
509            "panic_risk",
510            "dependency_hell",
511            "legacy_smell",
512        ] {
513            assert_eq!(parsed[*key], 0.0, "zero radar: {key} should be 0.0");
514        }
515    }
516}