hotspots-core 1.21.1

Core library for static analysis and Local Risk Score (LRS) computation across TypeScript, JavaScript, Go, Python, Rust, Java, and C#
Documentation
//! Reporting and output generation
//!
//! Global invariants enforced:
//! - Deterministic output ordering
//! - Byte-for-byte identical output across runs

use crate::ast::FunctionNode;
use crate::language::Language;
use crate::metrics::RawMetrics;
use crate::risk::{RiskBand, RiskComponents};
use serde::{Deserialize, Serialize};

/// Complete risk report for a function
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct FunctionRiskReport {
    pub file: String,
    pub function: String,
    pub line: u32,
    pub language: Language,
    pub metrics: MetricsReport,
    pub risk: RiskReport,
    pub lrs: f64,
    pub band: RiskBand,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub suppression_reason: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub patterns: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pattern_details: Option<Vec<crate::patterns::PatternDetail>>,
    #[serde(skip, default)]
    pub callees: Vec<String>,
}

/// Metrics in report format
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MetricsReport {
    pub cc: u32,
    pub nd: u32,
    pub fo: u32,
    pub ns: u32,
    pub loc: u32,
}

/// Risk components in report format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskReport {
    #[serde(rename = "r_cc")]
    pub r_cc: f64,
    #[serde(rename = "r_nd")]
    pub r_nd: f64,
    #[serde(rename = "r_fo")]
    pub r_fo: f64,
    #[serde(rename = "r_ns")]
    pub r_ns: f64,
}

/// Grouped analysis results for constructing a FunctionRiskReport
pub struct FunctionAnalysis {
    pub metrics: RawMetrics,
    pub risk: RiskComponents,
    pub lrs: f64,
    pub band: RiskBand,
    pub patterns: Vec<String>,
}

impl FunctionRiskReport {
    /// Create a new function risk report
    pub fn new(
        function: &FunctionNode,
        file: String,
        language: Language,
        analysis: FunctionAnalysis,
        source_map: &swc_common::SourceMap,
    ) -> Self {
        let line = function.start_line(source_map);
        let display_file = std::env::current_dir()
            .ok()
            .and_then(|cwd| {
                std::path::Path::new(&file)
                    .strip_prefix(&cwd)
                    .ok()
                    .map(|p| p.to_string_lossy().into_owned())
            })
            .unwrap_or_else(|| file.clone());
        let function_name = function
            .name
            .as_deref()
            .unwrap_or(&format!("<anonymous>@{}:{}", display_file, line))
            .to_string();

        FunctionRiskReport {
            file,
            function: function_name,
            line,
            language,
            metrics: MetricsReport {
                cc: analysis.metrics.cc as u32,
                nd: analysis.metrics.nd as u32,
                fo: analysis.metrics.fo as u32,
                ns: analysis.metrics.ns as u32,
                loc: analysis.metrics.loc as u32,
            },
            risk: RiskReport {
                r_cc: analysis.risk.r_cc,
                r_nd: analysis.risk.r_nd,
                r_fo: analysis.risk.r_fo,
                r_ns: analysis.risk.r_ns,
            },
            lrs: analysis.lrs,
            band: analysis.band,
            suppression_reason: function.suppression_reason.clone(),
            patterns: analysis.patterns,
            pattern_details: None,
            callees: analysis.metrics.callee_names,
        }
    }
}

/// Sort reports deterministically
pub fn sort_reports(mut reports: Vec<FunctionRiskReport>) -> Vec<FunctionRiskReport> {
    reports.sort_by(|a, b| {
        // 1. LRS descending
        b.lrs
            .partial_cmp(&a.lrs)
            .unwrap_or(std::cmp::Ordering::Equal)
            // 2. File path ascending
            .then_with(|| a.file.cmp(&b.file))
            // 3. Line number ascending
            .then_with(|| a.line.cmp(&b.line))
            // 4. Function name ascending
            .then_with(|| a.function.cmp(&b.function))
    });
    reports
}

/// Render reports as text output
pub fn render_text(reports: &[FunctionRiskReport]) -> String {
    let mut output = String::new();
    let show_patterns = reports.iter().any(|r| !r.patterns.is_empty());

    // Header
    if show_patterns {
        output.push_str(&format!(
            "{:<8} {:<10} {:<20} {:<6} {:<30} {}\n",
            "LRS", "BAND", "FILE", "LINE", "FUNCTION", "PATTERNS"
        ));
    } else {
        output.push_str(&format!(
            "{:<8} {:<20} {:<6} {}\n",
            "LRS", "File", "Line", "Function"
        ));
    }

    // Reports
    for report in reports {
        let lrs_str = format!("{:.2}", report.lrs);
        if show_patterns {
            let patterns_str = if report.patterns.is_empty() {
                "-".to_string()
            } else {
                report.patterns.join(", ")
            };
            output.push_str(&format!(
                "{:<8} {:<10} {:<20} {:<6} {:<30} {}\n",
                lrs_str,
                report.band.as_str(),
                truncate_or_pad(&report.file, 20),
                report.line,
                truncate_or_pad(&report.function, 30),
                patterns_str,
            ));
            if let Some(ref details) = report.pattern_details {
                for d in details {
                    let conds = d
                        .triggered_by
                        .iter()
                        .map(|t| format!("{}={} ({}{})", t.metric, t.value, t.op, t.threshold))
                        .collect::<Vec<_>>()
                        .join(", ");
                    output.push_str(&format!("           {}: {}\n", d.id, conds));
                }
            }
        } else {
            output.push_str(&format!(
                "{:<8} {:<20} {:<6} {}\n",
                lrs_str,
                truncate_or_pad(&report.file, 20),
                report.line,
                report.function
            ));
        }
    }

    output
}

/// Render reports grouped by file, with a footer showing counts and usage hints.
///
/// `limit` is the top-N that was requested so the footer can hint at using --top.
pub fn render_text_grouped(reports: &[FunctionRiskReport], limit: usize) -> String {
    use std::collections::BTreeMap;
    let mut output = String::new();
    let cwd = std::env::current_dir().ok();

    let rel_path = |p: &str| -> String {
        cwd.as_ref()
            .and_then(|cwd| {
                std::path::Path::new(p)
                    .strip_prefix(cwd)
                    .ok()
                    .map(|r| r.to_string_lossy().into_owned())
            })
            .unwrap_or_else(|| p.to_string())
    };

    let mut by_file: BTreeMap<String, Vec<&FunctionRiskReport>> = BTreeMap::new();
    let mut file_order: Vec<String> = Vec::new();
    for r in reports {
        let key = rel_path(&r.file);
        if !by_file.contains_key(&key) {
            file_order.push(key.clone());
        }
        by_file.entry(key).or_default().push(r);
    }

    for file_key in &file_order {
        let fns = &by_file[file_key];
        output.push_str(&format!("{}\n", file_key));
        for r in fns.iter() {
            let lrs_str = format!("{:.2}", r.lrs);
            let band = r.band.as_str();
            let patterns_str = if r.patterns.is_empty() {
                String::new()
            } else {
                format!("  [{}]", r.patterns.join(", "))
            };
            output.push_str(&format!(
                "  {:<6}  {:<8}  {}  line {}{}",
                lrs_str, band, r.function, r.line, patterns_str
            ));
            output.push('\n');
            if let Some(ref details) = r.pattern_details {
                for d in details {
                    let conds = d
                        .triggered_by
                        .iter()
                        .map(|t| format!("{}={} ({}{})", t.metric, t.value, t.op, t.threshold))
                        .collect::<Vec<_>>()
                        .join(", ");
                    output.push_str(&format!("             {}: {}\n", d.id, conds));
                }
            }
        }
        output.push('\n');
    }

    let shown = reports.len();
    let sep = "โ”€".repeat(60);
    output.push_str(&sep);
    output.push('\n');
    output.push_str(&format!(
        "Top {} functions across {} files\n",
        shown,
        file_order.len()
    ));
    output.push_str(&format!(
        "Use --top N to see more (default {})  ยท  --format json for full output\n",
        limit
    ));
    output
}

/// Render reports as JSON output
pub fn render_json(reports: &[FunctionRiskReport]) -> String {
    // Use serde_json with sorted keys for deterministic output
    serde_json::to_string_pretty(reports).unwrap_or_else(|_| "[]".to_string())
}

/// Truncate or pad string to fixed width
fn truncate_or_pad(s: &str, width: usize) -> String {
    if s.len() > width {
        format!("{}...", &s[..width.saturating_sub(3)])
    } else {
        format!("{:<width$}", s, width = width)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::language::Language;
    use crate::risk::RiskBand;

    fn make_report(file: &str, function: &str, line: u32, lrs: f64) -> FunctionRiskReport {
        FunctionRiskReport {
            file: file.to_string(),
            function: function.to_string(),
            line,
            language: Language::TypeScript,
            metrics: MetricsReport {
                cc: 5,
                nd: 1,
                fo: 2,
                ns: 0,
                loc: 20,
            },
            risk: RiskReport {
                r_cc: 1.0,
                r_nd: 0.5,
                r_fo: 0.5,
                r_ns: 0.0,
            },
            lrs,
            band: RiskBand::High,
            suppression_reason: None,
            patterns: vec![],
            pattern_details: None,
            callees: vec![],
        }
    }

    #[test]
    fn test_render_text_grouped_groups_by_file() {
        let reports = vec![
            make_report("/repo/src/a.ts", "foo", 10, 8.0),
            make_report("/repo/src/b.ts", "bar", 20, 7.0),
            make_report("/repo/src/a.ts", "baz", 30, 6.0),
        ];
        let out = render_text_grouped(&reports, 10);
        // a.ts section appears before b.ts (rank order)
        let a_pos = out.find("src/a.ts").unwrap_or(usize::MAX);
        let b_pos = out.find("src/b.ts").unwrap_or(usize::MAX);
        assert!(a_pos < b_pos, "a.ts should appear before b.ts");
        // both functions from a.ts are present
        assert!(out.contains("foo"), "should contain foo");
        assert!(out.contains("baz"), "should contain baz");
        assert!(out.contains("bar"), "should contain bar");
    }

    #[test]
    fn test_render_text_grouped_footer_counts() {
        let reports = vec![
            make_report("/repo/src/a.ts", "foo", 10, 8.0),
            make_report("/repo/src/b.ts", "bar", 20, 7.0),
        ];
        let out = render_text_grouped(&reports, 10);
        assert!(
            out.contains("Top 2 functions across 2 files"),
            "footer should show correct counts"
        );
        assert!(
            out.contains("default 10"),
            "footer should show the default limit"
        );
    }

    #[test]
    fn test_render_text_grouped_patterns_shown() {
        let mut r = make_report("/repo/src/a.ts", "foo", 10, 8.0);
        r.patterns = vec!["god_function".to_string(), "exit_heavy".to_string()];
        let out = render_text_grouped(&[r], 10);
        assert!(out.contains("[god_function, exit_heavy]"));
    }

    #[test]
    fn test_render_text_grouped_empty() {
        let out = render_text_grouped(&[], 10);
        assert!(out.contains("Top 0 functions across 0 files"));
    }
}