1use 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#[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
34pub 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 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
104fn 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 svg.push_str(" <text x=\"150\" y=\"20\" text-anchor=\"middle\" class=\"title\">Code Smell Radar</text>\n");
122
123 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}