Skip to main content

hotspots_core/
report.rs

1//! Reporting and output generation
2//!
3//! Global invariants enforced:
4//! - Deterministic output ordering
5//! - Byte-for-byte identical output across runs
6
7use 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/// Complete risk report for a function
15#[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/// Metrics in report format
39#[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/// Risk components in report format
49#[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
61/// Grouped analysis results for constructing a FunctionRiskReport
62pub 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    /// Create a new function risk report
72    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
124/// Sort reports deterministically
125pub fn sort_reports(mut reports: Vec<FunctionRiskReport>) -> Vec<FunctionRiskReport> {
126    reports.sort_by(|a, b| {
127        // 1. LRS descending
128        b.lrs
129            .partial_cmp(&a.lrs)
130            .unwrap_or(std::cmp::Ordering::Equal)
131            // 2. File path ascending
132            .then_with(|| a.file.cmp(&b.file))
133            // 3. Line number ascending
134            .then_with(|| a.line.cmp(&b.line))
135            // 4. Function name ascending
136            .then_with(|| a.function.cmp(&b.function))
137    });
138    reports
139}
140
141/// Render reports as text output
142pub 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    // Header
147    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    // Reports
160    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
202/// Render reports grouped by risk band (CRITICAL → HIGH → MODERATE/LOW).
203///
204/// MODERATE and LOW are omitted unless `limit` is `usize::MAX` (i.e. `--top 0`).
205/// `color` enables ANSI codes — pass `false` when stdout is not a TTY.
206pub 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    // Compute file:line column width across all visible rows for alignment.
236    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
338/// Render reports as JSON output
339pub fn render_json(reports: &[FunctionRiskReport]) -> String {
340    // Use serde_json with sorted keys for deterministic output
341    serde_json::to_string_pretty(reports).unwrap_or_else(|_| "[]".to_string())
342}
343
344/// Truncate or pad string to fixed width
345fn 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}