Skip to main content

profile_inspect/output/
text.rs

1use std::io::Write;
2
3use crate::analysis::{AllocationStats, CpuAnalysis, HeapAnalysis};
4use crate::ir::ProfileIR;
5
6use super::{Formatter, OutputError, format_time_us};
7
8/// Plain text output formatter with ASCII tables
9pub 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        // Header
21        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        // Metadata
31        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        // Executive Summary
55        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        // Key takeaways
73        Self::write_key_takeaways(writer, analysis)?;
74        writeln!(writer)?;
75
76        // Top functions by self time
77        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        // Top functions by inclusive time
109        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        // Hot paths
140        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        // File stats
176        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        // Package stats
202        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        // Signals
227        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        // Recommendations
265        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        // Header
335        writeln!(writer, "HEAP PROFILE ANALYSIS")?;
336        writeln!(writer, "=====================")?;
337        writeln!(writer)?;
338
339        // Metadata
340        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        // Category breakdown
353        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        // Top allocations by self size
371        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        // Recommendations
399        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        // Calculate what each category triggers
497        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            // Check what's triggering native work
520            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}