use std::io::Write;
use crate::analysis::{AllocationStats, CpuAnalysis, HeapAnalysis};
use crate::ir::ProfileIR;
use super::{Formatter, OutputError, format_time_us};
pub struct TextFormatter;
impl Formatter for TextFormatter {
#[expect(clippy::cast_precision_loss)]
#[expect(clippy::too_many_lines)]
fn write_cpu_analysis(
&self,
profile: &ProfileIR,
analysis: &CpuAnalysis,
writer: &mut dyn Write,
) -> Result<(), OutputError> {
if let Some(ref pkg) = analysis.metadata.focus_package {
writeln!(writer, "CPU PROFILE ANALYSIS (Package: {pkg})")?;
writeln!(writer, "{}", "=".repeat(50 + pkg.len()))?;
} else {
writeln!(writer, "CPU PROFILE ANALYSIS")?;
writeln!(writer, "====================")?;
}
writeln!(writer)?;
if let Some(ref source) = profile.source_file {
writeln!(writer, "File: {source}")?;
}
let duration_str = format_time_us(analysis.total_time);
writeln!(
writer,
"Duration: {} | Samples: {} | Interval: ~{:.2} ms",
duration_str, analysis.total_samples, analysis.metadata.sample_interval_ms
)?;
if let Some(ref pkg) = analysis.metadata.focus_package {
writeln!(writer, "Package filter: {pkg}")?;
} else {
let internals = if analysis.metadata.internals_filtered {
"hidden"
} else {
"shown"
};
writeln!(writer, "Node/V8 internals: {internals}")?;
}
writeln!(writer)?;
writeln!(writer, "EXECUTIVE SUMMARY")?;
writeln!(writer, "-----------------")?;
let breakdown = &analysis.category_breakdown;
let total = breakdown.total();
Self::write_category_line(writer, "App code", breakdown.app, total)?;
Self::write_category_line(writer, "Dependencies", breakdown.deps, total)?;
Self::write_category_line(writer, "Node.js internals", breakdown.node_internal, total)?;
Self::write_category_line(
writer,
"V8/Native",
breakdown.v8_internal + breakdown.native,
total,
)?;
writeln!(writer)?;
Self::write_key_takeaways(writer, analysis)?;
writeln!(writer)?;
writeln!(writer, "TOP FUNCTIONS BY SELF TIME")?;
writeln!(writer, "--------------------------")?;
writeln!(
writer,
"{:>4} {:>10} {:>6} {:>7} {:>10} {:<30} {}",
"#", "Self", "%", "Samples", "Inclusive", "Function", "Location"
)?;
writeln!(writer, "{:-<120}", "")?;
for (i, func) in analysis.functions.iter().take(25).enumerate() {
let self_ms = func.self_time as f64 / 1000.0;
let total_ms = func.total_time as f64 / 1000.0;
let pct = func.self_percent(analysis.total_time);
let name = Self::truncate(&func.name, 30);
let location = Self::truncate(&func.location, 50);
writeln!(
writer,
"{:>4} {:>8.2}ms {:>5.1}% {:>7} {:>8.2}ms {:<30} {}",
i + 1,
self_ms,
pct,
func.self_samples,
total_ms,
name,
location
)?;
}
writeln!(writer)?;
writeln!(writer, "TOP FUNCTIONS BY INCLUSIVE TIME")?;
writeln!(writer, "-------------------------------")?;
writeln!(
writer,
"{:>4} {:>10} {:>6} {:>10} {:<30} {}",
"#", "Inclusive", "%", "Self", "Function", "Location"
)?;
writeln!(writer, "{:-<110}", "")?;
for (i, func) in analysis.functions_by_total.iter().take(15).enumerate() {
let self_ms = func.self_time as f64 / 1000.0;
let total_ms = func.total_time as f64 / 1000.0;
let pct = func.total_percent(analysis.total_time);
let name = Self::truncate(&func.name, 30);
let location = Self::truncate(&func.location, 50);
writeln!(
writer,
"{:>4} {:>8.2}ms {:>5.1}% {:>8.2}ms {:<30} {}",
i + 1,
total_ms,
pct,
self_ms,
name,
location
)?;
}
writeln!(writer)?;
if !analysis.hot_paths.is_empty() {
writeln!(writer, "HOT PATHS")?;
writeln!(writer, "---------")?;
for (i, path) in analysis.hot_paths.iter().take(5).enumerate() {
let time_str = format_time_us(path.time);
writeln!(
writer,
"Path #{} — {:.1}% ({}, {} samples)",
i + 1,
path.percent,
time_str,
path.sample_count
)?;
for (j, frame_id) in path.frames.iter().enumerate() {
if let Some(frame) = profile.get_frame(*frame_id) {
let indent = " ".repeat(j.min(4));
let arrow = if j > 0 { "-> " } else { "" };
let hotspot = if j == path.frames.len() - 1 {
" [HOTSPOT]"
} else {
""
};
writeln!(
writer,
" {indent}{arrow}{}{hotspot}",
Self::truncate(&frame.display_name(), 60)
)?;
}
}
writeln!(writer)?;
}
}
if !analysis.file_stats.is_empty() {
writeln!(writer, "BY SOURCE FILE")?;
writeln!(writer, "--------------")?;
writeln!(
writer,
"{:<60} {:>10} {:>10} {:>8}",
"File", "Self", "Total", "Samples"
)?;
writeln!(writer, "{:-<92}", "")?;
for fs in analysis.file_stats.iter().take(15) {
let self_ms = fs.self_time as f64 / 1000.0;
let total_ms = fs.total_time as f64 / 1000.0;
writeln!(
writer,
"{:<60} {:>8.2}ms {:>8.2}ms {:>8}",
Self::truncate(&fs.file, 60),
self_ms,
total_ms,
fs.call_count
)?;
}
writeln!(writer)?;
}
if !analysis.package_stats.is_empty() {
writeln!(writer, "BY DEPENDENCY PACKAGE")?;
writeln!(writer, "---------------------")?;
writeln!(
writer,
"{:<30} {:>10} {:>8} {:<30}",
"Package", "Time", "% Deps", "Top Function"
)?;
writeln!(writer, "{:-<82}", "")?;
for pkg in &analysis.package_stats {
let time_ms = pkg.time as f64 / 1000.0;
writeln!(
writer,
"{:<30} {:>8.2}ms {:>6.1}% {:<30}",
Self::truncate(&pkg.package, 30),
time_ms,
pkg.percent_of_deps,
Self::truncate(&pkg.top_function, 30)
)?;
}
writeln!(writer)?;
}
if analysis.gc_time > 0 || analysis.native_time > 0 {
writeln!(writer, "SIGNALS")?;
writeln!(writer, "-------")?;
if analysis.gc_time > 0 {
let gc_time = format_time_us(analysis.gc_time);
let gc_pct = if analysis.total_time > 0 {
(analysis.gc_time as f64 / analysis.total_time as f64) * 100.0
} else {
0.0
};
let assessment = if gc_pct > 10.0 {
"HIGH - investigate allocation patterns"
} else if gc_pct > 5.0 {
"MODERATE - may warrant investigation"
} else {
"NORMAL"
};
writeln!(
writer,
"GC time: {} ({:.1}%) — {}",
gc_time, gc_pct, assessment
)?;
}
if analysis.native_time > 0 {
let native_time = format_time_us(analysis.native_time);
let native_pct = if analysis.total_time > 0 {
(analysis.native_time as f64 / analysis.total_time as f64) * 100.0
} else {
0.0
};
writeln!(writer, "Native time: {} ({:.1}%)", native_time, native_pct)?;
}
writeln!(writer)?;
}
writeln!(writer, "RECOMMENDATIONS")?;
writeln!(writer, "---------------")?;
let critical: Vec<_> = analysis
.functions
.iter()
.filter(|f| {
f.self_percent(analysis.total_time) >= 20.0
|| f.total_percent(analysis.total_time) >= 35.0
})
.collect();
let high: Vec<_> = analysis
.functions
.iter()
.filter(|f| {
let self_pct = f.self_percent(analysis.total_time);
let total_pct = f.total_percent(analysis.total_time);
(self_pct >= 10.0 && self_pct < 20.0) || (total_pct >= 20.0 && total_pct < 35.0)
})
.collect();
if !critical.is_empty() {
writeln!(writer, "CRITICAL (>=20% self or >=35% inclusive):")?;
for func in &critical {
let self_pct = func.self_percent(analysis.total_time);
let total_pct = func.total_percent(analysis.total_time);
writeln!(
writer,
" * {} — {:.1}% self, {:.1}% inclusive",
func.name, self_pct, total_pct
)?;
writeln!(writer, " at {}", Self::truncate(&func.location, 70))?;
}
writeln!(writer)?;
}
if !high.is_empty() {
writeln!(writer, "HIGH (>=10% self or >=20% inclusive):")?;
for func in &high {
let self_pct = func.self_percent(analysis.total_time);
let total_pct = func.total_percent(analysis.total_time);
writeln!(
writer,
" * {} — {:.1}% self, {:.1}% inclusive",
func.name, self_pct, total_pct
)?;
}
writeln!(writer)?;
}
if critical.is_empty() && high.is_empty() {
writeln!(writer, "No critical or high-impact functions detected.")?;
writeln!(
writer,
"CPU time is well-distributed. Consider profiling under higher load."
)?;
}
Ok(())
}
#[expect(clippy::cast_precision_loss)]
fn write_heap_analysis(
&self,
profile: &ProfileIR,
analysis: &HeapAnalysis,
writer: &mut dyn Write,
) -> Result<(), OutputError> {
writeln!(writer, "HEAP PROFILE ANALYSIS")?;
writeln!(writer, "=====================")?;
writeln!(writer)?;
if let Some(ref source) = profile.source_file {
writeln!(writer, "File: {source}")?;
}
writeln!(
writer,
"Total allocated: {} ({} allocations)",
AllocationStats::format_size(analysis.total_size),
analysis.total_allocations
)?;
writeln!(writer)?;
writeln!(writer, "ALLOCATION BY CATEGORY")?;
writeln!(writer, "----------------------")?;
let breakdown = &analysis.category_breakdown;
let total = breakdown.total();
Self::write_size_line(writer, "App code", breakdown.app, total)?;
Self::write_size_line(writer, "Dependencies", breakdown.deps, total)?;
Self::write_size_line(writer, "Node.js internals", breakdown.node_internal, total)?;
Self::write_size_line(
writer,
"V8/Native",
breakdown.v8_internal + breakdown.native,
total,
)?;
writeln!(writer)?;
writeln!(writer, "TOP ALLOCATIONS BY SELF SIZE")?;
writeln!(writer, "----------------------------")?;
writeln!(
writer,
" # Self % Allocs Inclusive Function Location"
)?;
writeln!(writer, "{}", "-".repeat(120))?;
for (i, func) in analysis.functions.iter().enumerate() {
let self_pct = func.self_percent(analysis.total_size);
let self_str = AllocationStats::format_size(func.self_size);
let total_str = AllocationStats::format_size(func.total_size);
writeln!(
writer,
"{:>4} {:>10} {:>5.1}% {:>6} {:>10} {:<30} {}",
i + 1,
self_str,
self_pct,
func.allocation_count,
total_str,
Self::truncate(&func.name, 30),
Self::truncate(&func.location, 50)
)?;
}
writeln!(writer)?;
writeln!(writer, "RECOMMENDATIONS")?;
writeln!(writer, "---------------")?;
let large_allocators: Vec<_> = analysis
.functions
.iter()
.filter(|f| f.self_percent(analysis.total_size) >= 10.0)
.collect();
if !large_allocators.is_empty() {
writeln!(writer, "Large allocators (>=10% of total):")?;
for func in &large_allocators {
let pct = func.self_percent(analysis.total_size);
writeln!(
writer,
" * {} — {:.1}% ({})",
func.name,
pct,
AllocationStats::format_size(func.self_size)
)?;
}
} else {
writeln!(writer, "No single function dominates allocations.")?;
writeln!(writer, "Memory is well-distributed across the codebase.")?;
}
Ok(())
}
}
impl TextFormatter {
#[expect(clippy::cast_precision_loss)]
fn write_category_line(
writer: &mut dyn Write,
name: &str,
time: u64,
total: u64,
) -> Result<(), OutputError> {
let time_str = format_time_us(time);
let pct = if total > 0 {
(time as f64 / total as f64) * 100.0
} else {
0.0
};
let assessment = if pct < 20.0 {
"normal"
} else if pct < 50.0 {
"notable"
} else {
"dominant"
};
writeln!(
writer,
" {name:<25} {time_str:>12} ({pct:>5.1}%) [{assessment}]"
)?;
Ok(())
}
#[expect(clippy::cast_precision_loss)]
fn write_size_line(
writer: &mut dyn Write,
name: &str,
size: u64,
total: u64,
) -> Result<(), OutputError> {
let size_str = AllocationStats::format_size(size);
let pct = if total > 0 {
(size as f64 / total as f64) * 100.0
} else {
0.0
};
writeln!(writer, " {name:<25} {size_str:>12} ({pct:>5.1}%)")?;
Ok(())
}
#[expect(clippy::cast_precision_loss)]
fn write_key_takeaways(
writer: &mut dyn Write,
analysis: &CpuAnalysis,
) -> Result<(), OutputError> {
use crate::ir::FrameCategory;
let breakdown = &analysis.category_breakdown;
let flow = &analysis.category_call_flow;
let total = breakdown.total();
if total == 0 {
return Ok(());
}
let app_pct = (breakdown.app as f64 / total as f64) * 100.0;
let deps_pct = (breakdown.deps as f64 / total as f64) * 100.0;
let native_pct = ((breakdown.v8_internal + breakdown.native) as f64 / total as f64) * 100.0;
let deps_triggers: u64 = flow
.callees_for(FrameCategory::Deps)
.iter()
.map(|(_, t)| *t)
.sum();
writeln!(writer, "Key takeaways:")?;
if app_pct > 50.0 {
writeln!(
writer,
" - App code dominates ({:.0}%) — focus optimization on your code",
app_pct
)?;
} else if deps_pct > 20.0 || deps_triggers > total / 2 {
let deps_total_pct = ((breakdown.deps + deps_triggers) as f64 / total as f64) * 100.0;
writeln!(
writer,
" - Dependencies drive {:.0}% of work — check which packages are expensive",
deps_total_pct.min(100.0)
)?;
} else if native_pct > 70.0 {
let node_to_native: u64 = flow
.callees_for(FrameCategory::NodeInternal)
.iter()
.filter(|(cat, _)| {
*cat == FrameCategory::Native || *cat == FrameCategory::V8Internal
})
.map(|(_, t)| *t)
.sum();
let app_to_native: u64 = flow
.callees_for(FrameCategory::App)
.iter()
.filter(|(cat, _)| {
*cat == FrameCategory::Native || *cat == FrameCategory::V8Internal
})
.map(|(_, t)| *t)
.sum();
if node_to_native > app_to_native {
writeln!(
writer,
" - V8/Native dominates ({:.0}%) via Node.js — likely module loading/compilation",
native_pct
)?;
} else {
writeln!(
writer,
" - V8/Native dominates ({:.0}%) — check for native addon work or compilation",
native_pct
)?;
}
}
if let Some(top) = analysis.functions.first() {
let pct = top.self_percent(analysis.total_time);
if pct > 5.0 {
writeln!(
writer,
" - Top bottleneck: {} at {:.1}% self time",
top.name, pct
)?;
}
}
if analysis.gc_time > 0 {
let gc_pct = (analysis.gc_time as f64 / analysis.total_time as f64) * 100.0;
if gc_pct > 5.0 {
writeln!(
writer,
" - GC overhead at {:.1}% — may indicate allocation pressure",
gc_pct
)?;
}
}
Ok(())
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
}