use crate::ast::FunctionNode;
use crate::language::Language;
use crate::metrics::RawMetrics;
use crate::risk::{RiskBand, RiskComponents};
use serde::{Deserialize, Serialize};
#[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>,
}
#[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,
}
#[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,
}
pub struct FunctionAnalysis {
pub metrics: RawMetrics,
pub risk: RiskComponents,
pub lrs: f64,
pub band: RiskBand,
pub patterns: Vec<String>,
}
impl FunctionRiskReport {
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,
}
}
}
pub fn sort_reports(mut reports: Vec<FunctionRiskReport>) -> Vec<FunctionRiskReport> {
reports.sort_by(|a, b| {
b.lrs
.partial_cmp(&a.lrs)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.function.cmp(&b.function))
});
reports
}
pub fn render_text(reports: &[FunctionRiskReport]) -> String {
let mut output = String::new();
let show_patterns = reports.iter().any(|r| !r.patterns.is_empty());
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"
));
}
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
}
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
}
pub fn render_json(reports: &[FunctionRiskReport]) -> String {
serde_json::to_string_pretty(reports).unwrap_or_else(|_| "[]".to_string())
}
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);
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");
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"));
}
}