profile-inspect 0.1.2

Analyze V8 CPU and heap profiles from Node.js/Chrome DevTools
Documentation
use std::collections::HashMap;

use crate::ir::{FrameCategory, FrameId, ProfileIR};

/// Statistics for a single allocation site
#[derive(Debug, Clone)]
pub struct AllocationStats {
    /// Frame ID
    pub frame_id: FrameId,
    /// Function name
    pub name: String,
    /// Source location
    pub location: String,
    /// Category
    pub category: FrameCategory,
    /// Self size (bytes allocated directly by this frame)
    pub self_size: u64,
    /// Total size (including callees)
    pub total_size: u64,
    /// Number of allocations
    pub allocation_count: u32,
}

impl AllocationStats {
    /// Calculate self size as percentage of total
    #[expect(clippy::cast_precision_loss)]
    pub fn self_percent(&self, total: u64) -> f64 {
        if total == 0 {
            0.0
        } else {
            (self.self_size as f64 / total as f64) * 100.0
        }
    }

    /// Format size as human-readable string
    pub fn format_size(bytes: u64) -> String {
        const KB: u64 = 1024;
        const MB: u64 = KB * 1024;
        const GB: u64 = MB * 1024;

        if bytes >= GB {
            format!("{:.2} GB", bytes as f64 / GB as f64)
        } else if bytes >= MB {
            format!("{:.2} MB", bytes as f64 / MB as f64)
        } else if bytes >= KB {
            format!("{:.2} KB", bytes as f64 / KB as f64)
        } else {
            format!("{bytes} B")
        }
    }
}

/// Result of heap profile analysis
#[derive(Debug)]
pub struct HeapAnalysis {
    /// Total allocated bytes
    pub total_size: u64,
    /// Total number of allocations
    pub total_allocations: usize,
    /// Per-function allocation statistics
    pub functions: Vec<AllocationStats>,
    /// Size breakdown by category
    pub category_breakdown: CategorySizeBreakdown,
}

/// Size breakdown by category
#[derive(Debug, Clone, Default)]
pub struct CategorySizeBreakdown {
    pub app: u64,
    pub deps: u64,
    pub node_internal: u64,
    pub v8_internal: u64,
    pub native: u64,
}

impl CategorySizeBreakdown {
    /// Get total size
    pub fn total(&self) -> u64 {
        self.app + self.deps + self.node_internal + self.v8_internal + self.native
    }
}

/// Analyzer for heap profiles
pub struct HeapAnalyzer {
    /// Minimum percentage to include in results
    min_percent: f64,
    /// Maximum number of functions to return
    top_n: usize,
    /// Whether to include internal frames
    include_internals: bool,
}

impl HeapAnalyzer {
    /// Create a new analyzer with default settings
    pub fn new() -> Self {
        Self {
            min_percent: 0.0,
            top_n: 50,
            include_internals: false,
        }
    }

    /// Set minimum percentage threshold
    pub fn min_percent(mut self, percent: f64) -> Self {
        self.min_percent = percent;
        self
    }

    /// Set maximum number of results
    pub fn top_n(mut self, n: usize) -> Self {
        self.top_n = n;
        self
    }

    /// Include internal frames
    pub fn include_internals(mut self, include: bool) -> Self {
        self.include_internals = include;
        self
    }

    /// Analyze a heap profile
    #[expect(clippy::cast_precision_loss)]
    pub fn analyze(&self, profile: &ProfileIR) -> HeapAnalysis {
        let total_size = profile.total_weight();
        let total_allocations = profile.sample_count();

        // Aggregate sizes per frame
        let mut self_sizes: HashMap<FrameId, u64> = HashMap::new();
        let mut total_sizes: HashMap<FrameId, u64> = HashMap::new();
        let mut alloc_counts: HashMap<FrameId, u32> = HashMap::new();
        let mut category_breakdown = CategorySizeBreakdown::default();

        for sample in &profile.samples {
            let size = sample.weight;

            if let Some(stack) = profile.get_stack(sample.stack_id) {
                // Leaf frame gets self size
                if let Some(&leaf_frame) = stack.frames.last() {
                    *self_sizes.entry(leaf_frame).or_default() += size;
                    *alloc_counts.entry(leaf_frame).or_default() += 1;

                    // Attribute to category based on leaf frame
                    if let Some(frame) = profile.get_frame(leaf_frame) {
                        match frame.category {
                            FrameCategory::App => category_breakdown.app += size,
                            FrameCategory::Deps => category_breakdown.deps += size,
                            FrameCategory::NodeInternal => {
                                category_breakdown.node_internal += size;
                            }
                            FrameCategory::V8Internal => category_breakdown.v8_internal += size,
                            FrameCategory::Native => category_breakdown.native += size,
                        }
                    }
                }

                // All frames get total size
                for &frame_id in &stack.frames {
                    *total_sizes.entry(frame_id).or_default() += size;
                }
            }
        }

        // Build allocation stats
        let mut functions: Vec<AllocationStats> = profile
            .frames
            .iter()
            .filter_map(|frame| {
                let self_size = self_sizes.get(&frame.id).copied().unwrap_or(0);
                let total_size = total_sizes.get(&frame.id).copied().unwrap_or(0);

                // Skip if no allocations
                if self_size == 0 && total_size == 0 {
                    return None;
                }

                // Apply internal filter
                if !self.include_internals && frame.category.is_internal() {
                    return None;
                }

                // Apply min percent filter
                let self_pct = if total_size > 0 {
                    (self_size as f64 / total_size as f64) * 100.0
                } else {
                    0.0
                };
                if self_pct < self.min_percent && self.min_percent > 0.0 {
                    return None;
                }

                Some(AllocationStats {
                    frame_id: frame.id,
                    name: frame.display_name().to_string(),
                    location: frame.location(),
                    category: frame.category,
                    self_size,
                    total_size,
                    allocation_count: alloc_counts.get(&frame.id).copied().unwrap_or(0),
                })
            })
            .collect();

        // Sort by self size descending
        functions.sort_by(|a, b| b.self_size.cmp(&a.self_size));
        functions.truncate(self.top_n);

        HeapAnalysis {
            total_size,
            total_allocations,
            functions,
            category_breakdown,
        }
    }
}

impl Default for HeapAnalyzer {
    fn default() -> Self {
        Self::new()
    }
}