1use crate::ast::FunctionNode;
8use crate::language::Language;
9use crate::metrics::RawMetrics;
10use crate::risk::{RiskBand, RiskComponents};
11use owo_colors::OwoColorize;
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub struct FunctionRiskReport {
18 pub file: String,
19 pub function: String,
20 pub line: u32,
21 pub language: Language,
22 pub metrics: MetricsReport,
23 pub risk: RiskReport,
24 pub lrs: f64,
25 pub band: RiskBand,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub suppression_reason: Option<String>,
28 #[serde(skip_serializing_if = "Vec::is_empty", default)]
29 pub patterns: Vec<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub pattern_details: Option<Vec<crate::patterns::PatternDetail>>,
32 #[serde(skip, default)]
33 pub callees: Vec<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub explanation: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct MetricsReport {
41 pub cc: u32,
42 pub nd: u32,
43 pub fo: u32,
44 pub ns: u32,
45 pub loc: u32,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct RiskReport {
51 #[serde(rename = "r_cc")]
52 pub r_cc: f64,
53 #[serde(rename = "r_nd")]
54 pub r_nd: f64,
55 #[serde(rename = "r_fo")]
56 pub r_fo: f64,
57 #[serde(rename = "r_ns")]
58 pub r_ns: f64,
59}
60
61pub struct FunctionAnalysis {
63 pub metrics: RawMetrics,
64 pub risk: RiskComponents,
65 pub lrs: f64,
66 pub band: RiskBand,
67 pub patterns: Vec<String>,
68}
69
70impl FunctionRiskReport {
71 pub fn new(
73 function: &FunctionNode,
74 file: String,
75 language: Language,
76 analysis: FunctionAnalysis,
77 source_map: &swc_common::SourceMap,
78 ) -> Self {
79 let line = function.start_line(source_map);
80 let display_file = std::env::current_dir()
81 .ok()
82 .and_then(|cwd| {
83 std::path::Path::new(&file)
84 .strip_prefix(&cwd)
85 .ok()
86 .map(|p| p.to_string_lossy().into_owned())
87 })
88 .unwrap_or_else(|| file.clone());
89 let function_name = function
90 .name
91 .as_deref()
92 .unwrap_or(&format!("<anonymous>@{}:{}", display_file, line))
93 .to_string();
94
95 FunctionRiskReport {
96 file,
97 function: function_name,
98 line,
99 language,
100 metrics: MetricsReport {
101 cc: analysis.metrics.cc as u32,
102 nd: analysis.metrics.nd as u32,
103 fo: analysis.metrics.fo as u32,
104 ns: analysis.metrics.ns as u32,
105 loc: analysis.metrics.loc as u32,
106 },
107 risk: RiskReport {
108 r_cc: analysis.risk.r_cc,
109 r_nd: analysis.risk.r_nd,
110 r_fo: analysis.risk.r_fo,
111 r_ns: analysis.risk.r_ns,
112 },
113 lrs: analysis.lrs,
114 band: analysis.band,
115 suppression_reason: function.suppression_reason.clone(),
116 patterns: analysis.patterns,
117 pattern_details: None,
118 callees: analysis.metrics.callee_names,
119 explanation: None,
120 }
121 }
122}
123
124pub fn sort_reports(mut reports: Vec<FunctionRiskReport>) -> Vec<FunctionRiskReport> {
126 reports.sort_by(|a, b| {
127 b.lrs
129 .partial_cmp(&a.lrs)
130 .unwrap_or(std::cmp::Ordering::Equal)
131 .then_with(|| a.file.cmp(&b.file))
133 .then_with(|| a.line.cmp(&b.line))
135 .then_with(|| a.function.cmp(&b.function))
137 });
138 reports
139}
140
141pub fn render_text(reports: &[FunctionRiskReport]) -> String {
143 let mut output = String::new();
144 let show_patterns = reports.iter().any(|r| !r.patterns.is_empty());
145
146 if show_patterns {
148 output.push_str(&format!(
149 "{:<8} {:<10} {:<20} {:<6} {:<30} {}\n",
150 "LRS", "BAND", "FILE", "LINE", "FUNCTION", "PATTERNS"
151 ));
152 } else {
153 output.push_str(&format!(
154 "{:<8} {:<20} {:<6} {}\n",
155 "LRS", "File", "Line", "Function"
156 ));
157 }
158
159 for report in reports {
161 let lrs_str = format!("{:.2}", report.lrs);
162 if show_patterns {
163 let patterns_str = if report.patterns.is_empty() {
164 "-".to_string()
165 } else {
166 report.patterns.join(", ")
167 };
168 output.push_str(&format!(
169 "{:<8} {:<10} {:<20} {:<6} {:<30} {}\n",
170 lrs_str,
171 report.band.as_str(),
172 truncate_or_pad(&report.file, 20),
173 report.line,
174 truncate_or_pad(&report.function, 30),
175 patterns_str,
176 ));
177 if let Some(ref details) = report.pattern_details {
178 for d in details {
179 let conds = d
180 .triggered_by
181 .iter()
182 .map(|t| format!("{}={} ({}{})", t.metric, t.value, t.op, t.threshold))
183 .collect::<Vec<_>>()
184 .join(", ");
185 output.push_str(&format!(" {}: {}\n", d.id, conds));
186 }
187 }
188 } else {
189 output.push_str(&format!(
190 "{:<8} {:<20} {:<6} {}\n",
191 lrs_str,
192 truncate_or_pad(&report.file, 20),
193 report.line,
194 report.function
195 ));
196 }
197 }
198
199 output
200}
201
202pub fn render_text_grouped(reports: &[FunctionRiskReport], limit: usize, color: bool) -> String {
207 let show_all = limit == usize::MAX;
208 let mut output = String::new();
209 let cwd = std::env::current_dir().ok();
210
211 let rel_path = |p: &str| -> String {
212 cwd.as_ref()
213 .and_then(|cwd| {
214 std::path::Path::new(p)
215 .strip_prefix(cwd)
216 .ok()
217 .map(|r| r.to_string_lossy().into_owned())
218 })
219 .unwrap_or_else(|| p.to_string())
220 };
221
222 let critical: Vec<&FunctionRiskReport> = reports
223 .iter()
224 .filter(|r| r.band == RiskBand::Critical)
225 .collect();
226 let high: Vec<&FunctionRiskReport> = reports
227 .iter()
228 .filter(|r| r.band == RiskBand::High)
229 .collect();
230 let lower: Vec<&FunctionRiskReport> = reports
231 .iter()
232 .filter(|r| matches!(r.band, RiskBand::Moderate | RiskBand::Low))
233 .collect();
234
235 let visible: Vec<&&FunctionRiskReport> = critical
237 .iter()
238 .chain(high.iter())
239 .chain(if show_all { lower.iter() } else { [].iter() })
240 .collect();
241 let col_width = visible
242 .iter()
243 .map(|r| format!("{}:{}", rel_path(&r.file), r.line).len())
244 .max()
245 .unwrap_or(30)
246 .min(55);
247
248 let render_section = |header: &str,
249 rows: &[&FunctionRiskReport],
250 col_w: usize,
251 rel: &dyn Fn(&str) -> String,
252 paint: &dyn Fn(&str) -> String|
253 -> String {
254 let mut s = String::new();
255 if rows.is_empty() {
256 return s;
257 }
258 s.push_str(&format!("{} ({})\n", paint(header), rows.len()));
259 for r in rows {
260 let loc = format!("{}:{}", rel(&r.file), r.line);
261 let patterns_str = if r.patterns.is_empty() {
262 String::new()
263 } else {
264 format!(" [{}]", r.patterns.join(", "))
265 };
266 s.push_str(&format!(
267 " {:.2} {:<col_w$} {}{}",
268 r.lrs,
269 loc,
270 r.function,
271 patterns_str,
272 col_w = col_w
273 ));
274 s.push('\n');
275 if let Some(exp) = &r.explanation {
276 s.push_str(&format!(" \u{2726} {}\n", exp));
277 }
278 }
279 s.push('\n');
280 s
281 };
282
283 let red = |s: &str| -> String {
284 if color {
285 s.red().bold().to_string()
286 } else {
287 s.to_string()
288 }
289 };
290 let yellow = |s: &str| -> String {
291 if color {
292 s.yellow().bold().to_string()
293 } else {
294 s.to_string()
295 }
296 };
297 let green = |s: &str| -> String {
298 if color {
299 s.green().to_string()
300 } else {
301 s.to_string()
302 }
303 };
304
305 output.push_str(&render_section(
306 "CRITICAL", &critical, col_width, &rel_path, &red,
307 ));
308 output.push_str(&render_section(
309 "HIGH", &high, col_width, &rel_path, &yellow,
310 ));
311 if show_all {
312 output.push_str(&render_section(
313 "MEDIUM / LOW",
314 &lower,
315 col_width,
316 &rel_path,
317 &green,
318 ));
319 }
320
321 let sep = "─".repeat(60);
322 output.push_str(&sep);
323 output.push('\n');
324 if show_all {
325 output.push_str(&format!("{} functions total\n", reports.len()));
326 } else {
327 let shown = critical.len() + high.len();
328 let hidden = lower.len();
329 output.push_str(&format!(
330 "{} functions shown ({} medium/low omitted)\n",
331 shown, hidden
332 ));
333 output.push_str("Use --top 0 to show all · --top N for a different limit · --format json for full output\n");
334 }
335 output
336}
337
338pub fn render_json(reports: &[FunctionRiskReport]) -> String {
340 serde_json::to_string_pretty(reports).unwrap_or_else(|_| "[]".to_string())
342}
343
344fn truncate_or_pad(s: &str, width: usize) -> String {
346 if s.len() > width {
347 format!("{}...", &s[..width.saturating_sub(3)])
348 } else {
349 format!("{:<width$}", s, width = width)
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::language::Language;
357 use crate::risk::RiskBand;
358
359 fn make_report(file: &str, function: &str, line: u32, lrs: f64) -> FunctionRiskReport {
360 FunctionRiskReport {
361 file: file.to_string(),
362 function: function.to_string(),
363 line,
364 language: Language::TypeScript,
365 metrics: MetricsReport {
366 cc: 5,
367 nd: 1,
368 fo: 2,
369 ns: 0,
370 loc: 20,
371 },
372 risk: RiskReport {
373 r_cc: 1.0,
374 r_nd: 0.5,
375 r_fo: 0.5,
376 r_ns: 0.0,
377 },
378 lrs,
379 band: RiskBand::High,
380 suppression_reason: None,
381 patterns: vec![],
382 pattern_details: None,
383 callees: vec![],
384 explanation: None,
385 }
386 }
387
388 #[test]
389 fn test_render_text_grouped_groups_by_band() {
390 let mut critical = make_report("/repo/src/a.ts", "foo", 10, 12.0);
391 critical.band = RiskBand::Critical;
392 let mut high = make_report("/repo/src/b.ts", "bar", 20, 7.0);
393 high.band = RiskBand::High;
394 let reports = vec![critical, high];
395 let out = render_text_grouped(&reports, 20, false);
396 let crit_pos = out.find("CRITICAL").unwrap_or(usize::MAX);
397 let high_pos = out.find("HIGH").unwrap_or(usize::MAX);
398 assert!(
399 crit_pos < high_pos,
400 "CRITICAL section should appear before HIGH"
401 );
402 assert!(out.contains("foo"), "should contain foo");
403 assert!(out.contains("bar"), "should contain bar");
404 }
405
406 #[test]
407 fn test_render_text_grouped_footer_counts() {
408 let mut r1 = make_report("/repo/src/a.ts", "foo", 10, 12.0);
409 r1.band = RiskBand::Critical;
410 let mut r2 = make_report("/repo/src/b.ts", "bar", 20, 7.0);
411 r2.band = RiskBand::High;
412 let out = render_text_grouped(&[r1, r2], 20, false);
413 assert!(
414 out.contains("2 functions shown"),
415 "footer should show shown count"
416 );
417 }
418
419 #[test]
420 fn test_render_text_grouped_lower_omitted_by_default() {
421 let mut r = make_report("/repo/src/a.ts", "foo", 10, 2.0);
422 r.band = RiskBand::Low;
423 let out = render_text_grouped(&[r], 20, false);
424 assert!(
425 !out.contains("foo"),
426 "low band should be omitted by default"
427 );
428 assert!(
429 out.contains("medium/low omitted"),
430 "footer should note omission"
431 );
432 }
433
434 #[test]
435 fn test_render_text_grouped_lower_shown_when_show_all() {
436 let mut r = make_report("/repo/src/a.ts", "foo", 10, 2.0);
437 r.band = RiskBand::Low;
438 let out = render_text_grouped(&[r], usize::MAX, false);
439 assert!(
440 out.contains("foo"),
441 "low band should be shown with show-all"
442 );
443 assert!(out.contains("MEDIUM / LOW"));
444 }
445
446 #[test]
447 fn test_render_text_grouped_patterns_shown() {
448 let mut r = make_report("/repo/src/a.ts", "foo", 10, 12.0);
449 r.band = RiskBand::Critical;
450 r.patterns = vec!["god_function".to_string(), "exit_heavy".to_string()];
451 let out = render_text_grouped(&[r], 20, false);
452 assert!(out.contains("[god_function, exit_heavy]"));
453 }
454
455 #[test]
456 fn test_render_text_grouped_empty() {
457 let out = render_text_grouped(&[], 20, false);
458 assert!(out.contains("0 functions shown"));
459 }
460
461 #[test]
462 fn test_render_text_grouped_color_emits_ansi() {
463 let mut r = make_report("/repo/src/a.ts", "critical_fn", 1, 12.0);
464 r.band = RiskBand::Critical;
465 let out = render_text_grouped(&[r], 10, true);
466 assert!(
467 out.contains("\x1b["),
468 "color=true should emit ANSI escape codes"
469 );
470 }
471
472 #[test]
473 fn test_render_text_grouped_no_color_plain() {
474 let mut r = make_report("/repo/src/a.ts", "critical_fn", 1, 12.0);
475 r.band = RiskBand::Critical;
476 let out = render_text_grouped(&[r], 10, false);
477 assert!(
478 !out.contains("\x1b["),
479 "color=false must not emit ANSI escape codes"
480 );
481 }
482}