1use std::io::Write;
2
3use crate::analysis::{AllocationStats, CpuAnalysis, HeapAnalysis};
4use crate::ir::ProfileIR;
5
6use super::{Formatter, OutputError, format_time_us};
7
8pub struct TextFormatter;
10
11impl Formatter for TextFormatter {
12 #[expect(clippy::cast_precision_loss)]
13 #[expect(clippy::too_many_lines)]
14 fn write_cpu_analysis(
15 &self,
16 profile: &ProfileIR,
17 analysis: &CpuAnalysis,
18 writer: &mut dyn Write,
19 ) -> Result<(), OutputError> {
20 if let Some(ref pkg) = analysis.metadata.focus_package {
22 writeln!(writer, "CPU PROFILE ANALYSIS (Package: {pkg})")?;
23 writeln!(writer, "{}", "=".repeat(50 + pkg.len()))?;
24 } else {
25 writeln!(writer, "CPU PROFILE ANALYSIS")?;
26 writeln!(writer, "====================")?;
27 }
28 writeln!(writer)?;
29
30 if let Some(ref source) = profile.source_file {
32 writeln!(writer, "File: {source}")?;
33 }
34
35 let duration_str = format_time_us(analysis.total_time);
36 writeln!(
37 writer,
38 "Duration: {} | Samples: {} | Interval: ~{:.2} ms",
39 duration_str, analysis.total_samples, analysis.metadata.sample_interval_ms
40 )?;
41
42 if let Some(ref pkg) = analysis.metadata.focus_package {
43 writeln!(writer, "Package filter: {pkg}")?;
44 } else {
45 let internals = if analysis.metadata.internals_filtered {
46 "hidden"
47 } else {
48 "shown"
49 };
50 writeln!(writer, "Node/V8 internals: {internals}")?;
51 }
52 writeln!(writer)?;
53
54 writeln!(writer, "EXECUTIVE SUMMARY")?;
56 writeln!(writer, "-----------------")?;
57
58 let breakdown = &analysis.category_breakdown;
59 let total = breakdown.total();
60
61 Self::write_category_line(writer, "App code", breakdown.app, total)?;
62 Self::write_category_line(writer, "Dependencies", breakdown.deps, total)?;
63 Self::write_category_line(writer, "Node.js internals", breakdown.node_internal, total)?;
64 Self::write_category_line(
65 writer,
66 "V8/Native",
67 breakdown.v8_internal + breakdown.native,
68 total,
69 )?;
70 writeln!(writer)?;
71
72 Self::write_key_takeaways(writer, analysis)?;
74 writeln!(writer)?;
75
76 writeln!(writer, "TOP FUNCTIONS BY SELF TIME")?;
78 writeln!(writer, "--------------------------")?;
79 writeln!(
80 writer,
81 "{:>4} {:>10} {:>6} {:>7} {:>10} {:<30} {}",
82 "#", "Self", "%", "Samples", "Inclusive", "Function", "Location"
83 )?;
84 writeln!(writer, "{:-<120}", "")?;
85
86 for (i, func) in analysis.functions.iter().take(25).enumerate() {
87 let self_ms = func.self_time as f64 / 1000.0;
88 let total_ms = func.total_time as f64 / 1000.0;
89 let pct = func.self_percent(analysis.total_time);
90
91 let name = Self::truncate(&func.name, 30);
92 let location = Self::truncate(&func.location, 50);
93
94 writeln!(
95 writer,
96 "{:>4} {:>8.2}ms {:>5.1}% {:>7} {:>8.2}ms {:<30} {}",
97 i + 1,
98 self_ms,
99 pct,
100 func.self_samples,
101 total_ms,
102 name,
103 location
104 )?;
105 }
106 writeln!(writer)?;
107
108 writeln!(writer, "TOP FUNCTIONS BY INCLUSIVE TIME")?;
110 writeln!(writer, "-------------------------------")?;
111 writeln!(
112 writer,
113 "{:>4} {:>10} {:>6} {:>10} {:<30} {}",
114 "#", "Inclusive", "%", "Self", "Function", "Location"
115 )?;
116 writeln!(writer, "{:-<110}", "")?;
117
118 for (i, func) in analysis.functions_by_total.iter().take(15).enumerate() {
119 let self_ms = func.self_time as f64 / 1000.0;
120 let total_ms = func.total_time as f64 / 1000.0;
121 let pct = func.total_percent(analysis.total_time);
122
123 let name = Self::truncate(&func.name, 30);
124 let location = Self::truncate(&func.location, 50);
125
126 writeln!(
127 writer,
128 "{:>4} {:>8.2}ms {:>5.1}% {:>8.2}ms {:<30} {}",
129 i + 1,
130 total_ms,
131 pct,
132 self_ms,
133 name,
134 location
135 )?;
136 }
137 writeln!(writer)?;
138
139 if !analysis.hot_paths.is_empty() {
141 writeln!(writer, "HOT PATHS")?;
142 writeln!(writer, "---------")?;
143
144 for (i, path) in analysis.hot_paths.iter().take(5).enumerate() {
145 let time_str = format_time_us(path.time);
146 writeln!(
147 writer,
148 "Path #{} — {:.1}% ({}, {} samples)",
149 i + 1,
150 path.percent,
151 time_str,
152 path.sample_count
153 )?;
154
155 for (j, frame_id) in path.frames.iter().enumerate() {
156 if let Some(frame) = profile.get_frame(*frame_id) {
157 let indent = " ".repeat(j.min(4));
158 let arrow = if j > 0 { "-> " } else { "" };
159 let hotspot = if j == path.frames.len() - 1 {
160 " [HOTSPOT]"
161 } else {
162 ""
163 };
164 writeln!(
165 writer,
166 " {indent}{arrow}{}{hotspot}",
167 Self::truncate(&frame.display_name(), 60)
168 )?;
169 }
170 }
171 writeln!(writer)?;
172 }
173 }
174
175 if !analysis.file_stats.is_empty() {
177 writeln!(writer, "BY SOURCE FILE")?;
178 writeln!(writer, "--------------")?;
179 writeln!(
180 writer,
181 "{:<60} {:>10} {:>10} {:>8}",
182 "File", "Self", "Total", "Samples"
183 )?;
184 writeln!(writer, "{:-<92}", "")?;
185
186 for fs in analysis.file_stats.iter().take(15) {
187 let self_ms = fs.self_time as f64 / 1000.0;
188 let total_ms = fs.total_time as f64 / 1000.0;
189 writeln!(
190 writer,
191 "{:<60} {:>8.2}ms {:>8.2}ms {:>8}",
192 Self::truncate(&fs.file, 60),
193 self_ms,
194 total_ms,
195 fs.call_count
196 )?;
197 }
198 writeln!(writer)?;
199 }
200
201 if !analysis.package_stats.is_empty() {
203 writeln!(writer, "BY DEPENDENCY PACKAGE")?;
204 writeln!(writer, "---------------------")?;
205 writeln!(
206 writer,
207 "{:<30} {:>10} {:>8} {:<30}",
208 "Package", "Time", "% Deps", "Top Function"
209 )?;
210 writeln!(writer, "{:-<82}", "")?;
211
212 for pkg in &analysis.package_stats {
213 let time_ms = pkg.time as f64 / 1000.0;
214 writeln!(
215 writer,
216 "{:<30} {:>8.2}ms {:>6.1}% {:<30}",
217 Self::truncate(&pkg.package, 30),
218 time_ms,
219 pkg.percent_of_deps,
220 Self::truncate(&pkg.top_function, 30)
221 )?;
222 }
223 writeln!(writer)?;
224 }
225
226 if analysis.gc_time > 0 || analysis.native_time > 0 {
228 writeln!(writer, "SIGNALS")?;
229 writeln!(writer, "-------")?;
230
231 if analysis.gc_time > 0 {
232 let gc_time = format_time_us(analysis.gc_time);
233 let gc_pct = if analysis.total_time > 0 {
234 (analysis.gc_time as f64 / analysis.total_time as f64) * 100.0
235 } else {
236 0.0
237 };
238 let assessment = if gc_pct > 10.0 {
239 "HIGH - investigate allocation patterns"
240 } else if gc_pct > 5.0 {
241 "MODERATE - may warrant investigation"
242 } else {
243 "NORMAL"
244 };
245 writeln!(
246 writer,
247 "GC time: {} ({:.1}%) — {}",
248 gc_time, gc_pct, assessment
249 )?;
250 }
251
252 if analysis.native_time > 0 {
253 let native_time = format_time_us(analysis.native_time);
254 let native_pct = if analysis.total_time > 0 {
255 (analysis.native_time as f64 / analysis.total_time as f64) * 100.0
256 } else {
257 0.0
258 };
259 writeln!(writer, "Native time: {} ({:.1}%)", native_time, native_pct)?;
260 }
261 writeln!(writer)?;
262 }
263
264 writeln!(writer, "RECOMMENDATIONS")?;
266 writeln!(writer, "---------------")?;
267
268 let critical: Vec<_> = analysis
269 .functions
270 .iter()
271 .filter(|f| {
272 f.self_percent(analysis.total_time) >= 20.0
273 || f.total_percent(analysis.total_time) >= 35.0
274 })
275 .collect();
276
277 let high: Vec<_> = analysis
278 .functions
279 .iter()
280 .filter(|f| {
281 let self_pct = f.self_percent(analysis.total_time);
282 let total_pct = f.total_percent(analysis.total_time);
283 (self_pct >= 10.0 && self_pct < 20.0) || (total_pct >= 20.0 && total_pct < 35.0)
284 })
285 .collect();
286
287 if !critical.is_empty() {
288 writeln!(writer, "CRITICAL (>=20% self or >=35% inclusive):")?;
289 for func in &critical {
290 let self_pct = func.self_percent(analysis.total_time);
291 let total_pct = func.total_percent(analysis.total_time);
292 writeln!(
293 writer,
294 " * {} — {:.1}% self, {:.1}% inclusive",
295 func.name, self_pct, total_pct
296 )?;
297 writeln!(writer, " at {}", Self::truncate(&func.location, 70))?;
298 }
299 writeln!(writer)?;
300 }
301
302 if !high.is_empty() {
303 writeln!(writer, "HIGH (>=10% self or >=20% inclusive):")?;
304 for func in &high {
305 let self_pct = func.self_percent(analysis.total_time);
306 let total_pct = func.total_percent(analysis.total_time);
307 writeln!(
308 writer,
309 " * {} — {:.1}% self, {:.1}% inclusive",
310 func.name, self_pct, total_pct
311 )?;
312 }
313 writeln!(writer)?;
314 }
315
316 if critical.is_empty() && high.is_empty() {
317 writeln!(writer, "No critical or high-impact functions detected.")?;
318 writeln!(
319 writer,
320 "CPU time is well-distributed. Consider profiling under higher load."
321 )?;
322 }
323
324 Ok(())
325 }
326
327 #[expect(clippy::cast_precision_loss)]
328 fn write_heap_analysis(
329 &self,
330 profile: &ProfileIR,
331 analysis: &HeapAnalysis,
332 writer: &mut dyn Write,
333 ) -> Result<(), OutputError> {
334 writeln!(writer, "HEAP PROFILE ANALYSIS")?;
336 writeln!(writer, "=====================")?;
337 writeln!(writer)?;
338
339 if let Some(ref source) = profile.source_file {
341 writeln!(writer, "File: {source}")?;
342 }
343
344 writeln!(
345 writer,
346 "Total allocated: {} ({} allocations)",
347 AllocationStats::format_size(analysis.total_size),
348 analysis.total_allocations
349 )?;
350 writeln!(writer)?;
351
352 writeln!(writer, "ALLOCATION BY CATEGORY")?;
354 writeln!(writer, "----------------------")?;
355
356 let breakdown = &analysis.category_breakdown;
357 let total = breakdown.total();
358
359 Self::write_size_line(writer, "App code", breakdown.app, total)?;
360 Self::write_size_line(writer, "Dependencies", breakdown.deps, total)?;
361 Self::write_size_line(writer, "Node.js internals", breakdown.node_internal, total)?;
362 Self::write_size_line(
363 writer,
364 "V8/Native",
365 breakdown.v8_internal + breakdown.native,
366 total,
367 )?;
368 writeln!(writer)?;
369
370 writeln!(writer, "TOP ALLOCATIONS BY SELF SIZE")?;
372 writeln!(writer, "----------------------------")?;
373 writeln!(
374 writer,
375 " # Self % Allocs Inclusive Function Location"
376 )?;
377 writeln!(writer, "{}", "-".repeat(120))?;
378
379 for (i, func) in analysis.functions.iter().enumerate() {
380 let self_pct = func.self_percent(analysis.total_size);
381 let self_str = AllocationStats::format_size(func.self_size);
382 let total_str = AllocationStats::format_size(func.total_size);
383
384 writeln!(
385 writer,
386 "{:>4} {:>10} {:>5.1}% {:>6} {:>10} {:<30} {}",
387 i + 1,
388 self_str,
389 self_pct,
390 func.allocation_count,
391 total_str,
392 Self::truncate(&func.name, 30),
393 Self::truncate(&func.location, 50)
394 )?;
395 }
396 writeln!(writer)?;
397
398 writeln!(writer, "RECOMMENDATIONS")?;
400 writeln!(writer, "---------------")?;
401
402 let large_allocators: Vec<_> = analysis
403 .functions
404 .iter()
405 .filter(|f| f.self_percent(analysis.total_size) >= 10.0)
406 .collect();
407
408 if !large_allocators.is_empty() {
409 writeln!(writer, "Large allocators (>=10% of total):")?;
410 for func in &large_allocators {
411 let pct = func.self_percent(analysis.total_size);
412 writeln!(
413 writer,
414 " * {} — {:.1}% ({})",
415 func.name,
416 pct,
417 AllocationStats::format_size(func.self_size)
418 )?;
419 }
420 } else {
421 writeln!(writer, "No single function dominates allocations.")?;
422 writeln!(writer, "Memory is well-distributed across the codebase.")?;
423 }
424
425 Ok(())
426 }
427}
428
429impl TextFormatter {
430 #[expect(clippy::cast_precision_loss)]
431 fn write_category_line(
432 writer: &mut dyn Write,
433 name: &str,
434 time: u64,
435 total: u64,
436 ) -> Result<(), OutputError> {
437 let time_str = format_time_us(time);
438 let pct = if total > 0 {
439 (time as f64 / total as f64) * 100.0
440 } else {
441 0.0
442 };
443
444 let assessment = if pct < 20.0 {
445 "normal"
446 } else if pct < 50.0 {
447 "notable"
448 } else {
449 "dominant"
450 };
451
452 writeln!(
453 writer,
454 " {name:<25} {time_str:>12} ({pct:>5.1}%) [{assessment}]"
455 )?;
456 Ok(())
457 }
458
459 #[expect(clippy::cast_precision_loss)]
460 fn write_size_line(
461 writer: &mut dyn Write,
462 name: &str,
463 size: u64,
464 total: u64,
465 ) -> Result<(), OutputError> {
466 let size_str = AllocationStats::format_size(size);
467 let pct = if total > 0 {
468 (size as f64 / total as f64) * 100.0
469 } else {
470 0.0
471 };
472
473 writeln!(writer, " {name:<25} {size_str:>12} ({pct:>5.1}%)")?;
474 Ok(())
475 }
476
477 #[expect(clippy::cast_precision_loss)]
478 fn write_key_takeaways(
479 writer: &mut dyn Write,
480 analysis: &CpuAnalysis,
481 ) -> Result<(), OutputError> {
482 use crate::ir::FrameCategory;
483
484 let breakdown = &analysis.category_breakdown;
485 let flow = &analysis.category_call_flow;
486 let total = breakdown.total();
487
488 if total == 0 {
489 return Ok(());
490 }
491
492 let app_pct = (breakdown.app as f64 / total as f64) * 100.0;
493 let deps_pct = (breakdown.deps as f64 / total as f64) * 100.0;
494 let native_pct = ((breakdown.v8_internal + breakdown.native) as f64 / total as f64) * 100.0;
495
496 let deps_triggers: u64 = flow
498 .callees_for(FrameCategory::Deps)
499 .iter()
500 .map(|(_, t)| *t)
501 .sum();
502
503 writeln!(writer, "Key takeaways:")?;
504
505 if app_pct > 50.0 {
506 writeln!(
507 writer,
508 " - App code dominates ({:.0}%) — focus optimization on your code",
509 app_pct
510 )?;
511 } else if deps_pct > 20.0 || deps_triggers > total / 2 {
512 let deps_total_pct = ((breakdown.deps + deps_triggers) as f64 / total as f64) * 100.0;
513 writeln!(
514 writer,
515 " - Dependencies drive {:.0}% of work — check which packages are expensive",
516 deps_total_pct.min(100.0)
517 )?;
518 } else if native_pct > 70.0 {
519 let node_to_native: u64 = flow
521 .callees_for(FrameCategory::NodeInternal)
522 .iter()
523 .filter(|(cat, _)| {
524 *cat == FrameCategory::Native || *cat == FrameCategory::V8Internal
525 })
526 .map(|(_, t)| *t)
527 .sum();
528 let app_to_native: u64 = flow
529 .callees_for(FrameCategory::App)
530 .iter()
531 .filter(|(cat, _)| {
532 *cat == FrameCategory::Native || *cat == FrameCategory::V8Internal
533 })
534 .map(|(_, t)| *t)
535 .sum();
536
537 if node_to_native > app_to_native {
538 writeln!(
539 writer,
540 " - V8/Native dominates ({:.0}%) via Node.js — likely module loading/compilation",
541 native_pct
542 )?;
543 } else {
544 writeln!(
545 writer,
546 " - V8/Native dominates ({:.0}%) — check for native addon work or compilation",
547 native_pct
548 )?;
549 }
550 }
551
552 if let Some(top) = analysis.functions.first() {
553 let pct = top.self_percent(analysis.total_time);
554 if pct > 5.0 {
555 writeln!(
556 writer,
557 " - Top bottleneck: {} at {:.1}% self time",
558 top.name, pct
559 )?;
560 }
561 }
562
563 if analysis.gc_time > 0 {
564 let gc_pct = (analysis.gc_time as f64 / analysis.total_time as f64) * 100.0;
565 if gc_pct > 5.0 {
566 writeln!(
567 writer,
568 " - GC overhead at {:.1}% — may indicate allocation pressure",
569 gc_pct
570 )?;
571 }
572 }
573
574 Ok(())
575 }
576
577 fn truncate(s: &str, max_len: usize) -> String {
578 if s.len() <= max_len {
579 s.to_string()
580 } else {
581 format!("{}...", &s[..max_len - 3])
582 }
583 }
584}