use crate::util::truncate_string;
pub(crate) fn print_file_risk_output(
file_risk: &[hotspots_core::aggregates::FileRiskView],
top: Option<usize>,
) -> anyhow::Result<()> {
if file_risk.is_empty() {
println!("No files to display.");
return Ok(());
}
let total = file_risk.len();
let display_count = top.map(|n| n.min(total)).unwrap_or(total);
let title = if display_count < total {
format!("Top {} Files by Risk Score", display_count)
} else {
"All Files by Risk Score".to_string()
};
println!("{}", title);
println!("{}", "=".repeat(80));
println!();
for (i, view) in file_risk.iter().take(display_count).enumerate() {
println!("#{} {}", i + 1, view.file);
println!(
" Functions: {} | LOC: {} | Max CC: {} | Avg CC: {:.1}",
view.function_count, view.loc, view.max_cc, view.avg_cc
);
println!(" Risk Score: {:.2}", view.file_risk_score);
if view.file_churn > 0 {
println!(" Churn: {} lines changed (30 days)", view.file_churn);
}
if view.critical_count > 0 {
println!(" Critical functions: {}", view.critical_count);
}
println!();
}
println!("{}", "-".repeat(80));
println!("Showing {}/{} files", display_count, total);
Ok(())
}
pub(crate) fn print_module_output(
modules: &[hotspots_core::aggregates::ModuleInstability],
top: Option<usize>,
) -> anyhow::Result<()> {
if modules.is_empty() {
println!("No modules to display (import resolution produced no in-project edges).");
return Ok(());
}
let total = modules.len();
let display_count = top.map(|n| n.min(total)).unwrap_or(total);
let title = if display_count < total {
format!("Top {} Modules by Instability Risk", display_count)
} else {
"All Modules by Instability".to_string()
};
println!("{}", title);
println!("{}", "=".repeat(80));
println!();
println!(
"{:<3} {:<40} {:>5} {:>5} {:>7} {:>9} {:>9} {:>11} {:>5}",
"#", "module", "files", "fns", "avg_cc", "afferent", "efferent", "instability", "risk"
);
println!("{}", "-".repeat(98));
for (i, m) in modules.iter().take(display_count).enumerate() {
println!(
"{:<3} {:<40} {:>5} {:>5} {:>7.1} {:>9} {:>9} {:>11.3} {:>5}",
i + 1,
truncate_string(&m.module, 40),
m.file_count,
m.function_count,
m.avg_complexity,
m.afferent,
m.efferent,
m.instability,
m.module_risk,
);
}
println!("{}", "-".repeat(98));
println!("Showing {}/{} modules", display_count, total);
let high_risk_count = modules
.iter()
.take(display_count)
.filter(|m| m.module_risk == "high")
.count();
if high_risk_count > 0 {
println!(
"High-risk modules (low instability + high complexity): {}",
high_risk_count
);
}
Ok(())
}
pub(crate) fn print_explain_output(
snapshot: &hotspots_core::snapshot::Snapshot,
total_count: usize,
color: bool,
) -> anyhow::Result<()> {
use hotspots_core::risk::RiskBand;
use owo_colors::OwoColorize;
let funcs = &snapshot.functions;
if funcs.is_empty() {
println!("No functions to display.");
return Ok(());
}
let show_all = funcs.len() >= total_count;
let critical: Vec<_> = funcs
.iter()
.filter(|f| f.band == RiskBand::Critical)
.collect();
let high: Vec<_> = funcs.iter().filter(|f| f.band == RiskBand::High).collect();
let lower: Vec<_> = funcs
.iter()
.filter(|f| matches!(f.band, RiskBand::Moderate | RiskBand::Low))
.collect();
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 visible_iter: Box<dyn Iterator<Item = &&hotspots_core::snapshot::FunctionSnapshot>> =
if show_all {
Box::new(critical.iter().chain(high.iter()).chain(lower.iter()))
} else {
Box::new(critical.iter().chain(high.iter()))
};
let col_width = visible_iter
.map(|f| format!("{}:{}", rel_path(&f.file), f.line).len())
.max()
.unwrap_or(30)
.min(55);
let paint = |s: &str, paint_fn: &dyn Fn(&str) -> String| -> String {
if color {
paint_fn(s)
} else {
s.to_string()
}
};
let red = |s: &str| s.red().bold().to_string();
let yellow = |s: &str| s.yellow().bold().to_string();
let green = |s: &str| s.green().to_string();
let print_section = |header: &str,
rows: &[&hotspots_core::snapshot::FunctionSnapshot],
col_w: usize,
painter: &dyn Fn(&str) -> String| {
if rows.is_empty() {
return;
}
println!("{} ({})", paint(header, painter), rows.len());
for f in rows {
let score = f.activity_risk.unwrap_or(f.lrs);
let name = f.function_id.split("::").last().unwrap_or(&f.function_id);
let loc = format!("{}:{}", rel_path(&f.file), f.line);
let patterns_str = if f.patterns.is_empty() {
String::new()
} else {
format!(" [{}]", f.patterns.join(", "))
};
println!(
" {:.2} {:<col_w$} {}{}",
score,
loc,
name,
patterns_str,
col_w = col_w
);
}
println!();
};
print_section("CRITICAL", &critical, col_width, &red);
print_section("HIGH", &high, col_width, &yellow);
if show_all {
print_section("MEDIUM / LOW", &lower, col_width, &green);
}
println!("{}", "─".repeat(60));
if show_all {
println!("{} functions total", funcs.len());
} else {
let shown = critical.len() + high.len();
let hidden = lower.len();
println!("{} functions shown ({} medium/low omitted)", shown, hidden);
println!("Use --top 0 to show all · --top N for a different limit · --format json for full output");
}
Ok(())
}