profile-inspect 0.1.2

Analyze V8 CPU and heap profiles from Node.js/Chrome DevTools
Documentation
use std::path::PathBuf;
use std::process::Command;

use bpaf::Bpaf;

use profile_inspect::analysis::{CpuAnalyzer, HeapAnalyzer};
use profile_inspect::classify::FrameClassifier;
use profile_inspect::ir::ProfileIR;
use profile_inspect::output::{OutputFormat, get_formatter};
use profile_inspect::parser::{CpuProfileParser, HeapProfileParser};

use super::common::{AnalysisOptions, OutputOptions, parse_categories};
use super::common::{analysis_options, output_options};

/// Run a command with profiling enabled
#[derive(Debug, Clone, Bpaf)]
#[bpaf(command("run"))]
pub struct RunCommand {
    /// Enable CPU profiling
    #[bpaf(long, switch)]
    pub cpu: bool,

    /// Enable heap profiling
    #[bpaf(long, switch)]
    pub heap: bool,

    #[bpaf(external(output_options))]
    pub output_options: OutputOptions,

    /// Filter by category (app, deps, node, v8, native)
    #[bpaf(long)]
    pub filter: Option<String>,

    #[bpaf(external(analysis_options))]
    pub analysis_options: AnalysisOptions,

    /// Focus analysis on a specific npm package
    #[bpaf(long, argument("NAME"))]
    pub package: Option<String>,

    /// Directory containing source maps
    #[bpaf(long)]
    pub sourcemap_dir: Option<PathBuf>,

    /// Directory to save profile files (preserves them after analysis)
    #[bpaf(long)]
    pub profile_dir: Option<PathBuf>,

    /// Command to run (specify after all flags)
    #[bpaf(positional("CMD"), many)]
    pub command: Vec<String>,
}

impl RunCommand {
    pub fn run(self) -> Result<(), Box<dyn std::error::Error>> {
        if !self.cpu && !self.heap {
            return Err("At least one of --cpu or --heap must be specified".into());
        }

        if self.command.is_empty() {
            return Err("No command specified".into());
        }

        // Determine profile directory
        let (profile_dir, should_cleanup) = if let Some(ref dir) = self.profile_dir {
            std::fs::create_dir_all(dir)?;
            (dir.clone(), false)
        } else {
            let temp_dir =
                std::env::temp_dir().join(format!("profile-inspect-{}", std::process::id()));
            std::fs::create_dir_all(&temp_dir)?;
            (temp_dir, true)
        };

        // Build NODE_OPTIONS
        let mut node_options = std::env::var("NODE_OPTIONS").unwrap_or_default();

        if self.cpu {
            if !node_options.is_empty() {
                node_options.push(' ');
            }
            node_options.push_str(&format!(
                "--cpu-prof --cpu-prof-dir={}",
                profile_dir.display()
            ));
        }

        if self.heap {
            if !node_options.is_empty() {
                node_options.push(' ');
            }
            node_options.push_str(&format!(
                "--heap-prof --heap-prof-dir={}",
                profile_dir.display()
            ));
        }

        eprintln!("Running with NODE_OPTIONS: {node_options}");
        eprintln!("Command: {}", self.command.join(" "));
        eprintln!();

        // Execute the command
        let status = Command::new(&self.command[0])
            .args(&self.command[1..])
            .env("NODE_OPTIONS", &node_options)
            .status()?;

        eprintln!();
        if !status.success() {
            eprintln!(
                "Command exited with status: {}",
                status.code().unwrap_or(-1)
            );
        }

        // Find profile files
        let cpu_profiles = if self.cpu {
            find_profiles(&profile_dir, "cpuprofile")?
        } else {
            Vec::new()
        };

        let heap_profiles = if self.heap {
            find_profiles(&profile_dir, "heapprofile")?
        } else {
            Vec::new()
        };

        if cpu_profiles.is_empty() && heap_profiles.is_empty() {
            eprintln!("No profile files were generated.");
            eprintln!(
                "Make sure the command runs Node.js directly or through a compatible runner."
            );
            if should_cleanup {
                let _ = std::fs::remove_dir_all(&profile_dir);
            }
            return Ok(());
        }

        // Analyze CPU profiles (merged if multiple)
        if !cpu_profiles.is_empty() {
            eprintln!();
            if cpu_profiles.len() > 1 {
                eprintln!(
                    "Merging {} CPU profiles from multiple processes...",
                    cpu_profiles.len()
                );
            }

            // Parse all profiles
            let mut parsed_profiles = Vec::new();
            for path in &cpu_profiles {
                eprintln!("  Loading: {}", path.display());
                let classifier = FrameClassifier::default();
                let parser = CpuProfileParser::new(classifier);
                parsed_profiles.push(parser.parse_file(path)?);
            }

            // Merge into one
            let mut merged_profile =
                ProfileIR::merge(parsed_profiles).ok_or("Failed to merge profiles")?;

            eprintln!(
                "Merged profile: {} samples, {} unique frames",
                merged_profile.sample_count(),
                merged_profile.frames.len()
            );

            // Resolve sourcemaps if directory provided
            if let Some(ref dir) = self.sourcemap_dir {
                eprintln!("Resolving sourcemaps from: {}", dir.display());
                let resolved = merged_profile.resolve_sourcemaps(vec![dir.clone()]);
                if resolved > 0 {
                    eprintln!("  Resolved {} frames via sourcemaps", resolved);
                } else {
                    eprintln!("  No frames resolved (sourcemap not found or no matches)");
                }
            }

            // Analyze
            self.analyze_cpu_profile(&merged_profile)?;
        }

        // Analyze heap profiles (merged if multiple)
        if !heap_profiles.is_empty() {
            eprintln!();
            if heap_profiles.len() > 1 {
                eprintln!(
                    "Merging {} heap profiles from multiple processes...",
                    heap_profiles.len()
                );
            }

            // Parse all heap profiles
            let mut parsed_profiles = Vec::new();
            for path in &heap_profiles {
                eprintln!("  Loading: {}", path.display());
                let classifier = FrameClassifier::default();
                let parser = HeapProfileParser::new(classifier);
                parsed_profiles.push(parser.parse_file(path)?);
            }

            // Merge into one
            let merged_profile =
                ProfileIR::merge(parsed_profiles).ok_or("Failed to merge heap profiles")?;

            eprintln!(
                "Merged profile: {} allocations, {} unique frames",
                merged_profile.sample_count(),
                merged_profile.frames.len()
            );

            // Analyze
            self.analyze_heap_profile(&merged_profile)?;
        }

        // Clean up profile directory unless --profile-dir was specified
        if should_cleanup {
            let _ = std::fs::remove_dir_all(&profile_dir);
        } else {
            eprintln!();
            eprintln!("Profile files saved in: {}", profile_dir.display());
        }

        Ok(())
    }

    fn analyze_cpu_profile(&self, profile: &ProfileIR) -> Result<(), Box<dyn std::error::Error>> {
        // Build analyzer with options
        let mut analyzer = CpuAnalyzer::new()
            .include_internals(self.analysis_options.include_internals)
            .min_percent(self.analysis_options.min_percent)
            .top_n(self.analysis_options.top);

        // Apply category filter
        if let Some(filter_str) = &self.filter {
            let categories = parse_categories(filter_str);
            analyzer = analyzer.filter_categories(categories);
        }

        // Apply package filter
        if let Some(pkg) = &self.package {
            analyzer = analyzer.filter_package(pkg.clone());
        }

        // Run analysis
        let analysis = analyzer.analyze(profile);

        // Output to each format
        for fmt_str in &self.output_options.formats() {
            let format = OutputFormat::from_str(fmt_str)
                .ok_or_else(|| format!("Unknown format: {fmt_str}"))?;

            let formatter = get_formatter(format);

            // Determine output path
            let output_path = if let Some(dir) = &self.output_options.output {
                std::fs::create_dir_all(dir)?;
                Some(dir.join(format.default_filename()))
            } else {
                None
            };

            // Write output
            if let Some(path) = output_path {
                let file = std::fs::File::create(&path)?;
                let mut writer = std::io::BufWriter::new(file);
                formatter.write_cpu_analysis(profile, &analysis, &mut writer)?;
                eprintln!("Wrote: {}", path.display());
            } else {
                let stdout = std::io::stdout();
                let mut writer = stdout.lock();
                formatter.write_cpu_analysis(profile, &analysis, &mut writer)?;
            }
        }

        Ok(())
    }

    fn analyze_heap_profile(&self, profile: &ProfileIR) -> Result<(), Box<dyn std::error::Error>> {
        // Build analyzer with options
        let analyzer = HeapAnalyzer::new()
            .include_internals(self.analysis_options.include_internals)
            .min_percent(self.analysis_options.min_percent)
            .top_n(self.analysis_options.top);

        // Run analysis
        let analysis = analyzer.analyze(profile);

        // Output to each format
        for fmt_str in &self.output_options.formats() {
            let format = OutputFormat::from_str(fmt_str)
                .ok_or_else(|| format!("Unknown format: {fmt_str}"))?;

            let formatter = get_formatter(format);

            // Determine output path (use heap-specific filenames)
            let output_path = if let Some(dir) = &self.output_options.output {
                std::fs::create_dir_all(dir)?;
                let filename = match format {
                    OutputFormat::Markdown => "heap-analysis.md",
                    OutputFormat::Text => "heap-analysis.txt",
                    OutputFormat::Json => "heap-analysis.json",
                    OutputFormat::Speedscope => "heap.speedscope.json",
                    OutputFormat::Collapsed => "heap.collapsed.txt",
                };
                Some(dir.join(filename))
            } else {
                None
            };

            // Write output
            if let Some(path) = output_path {
                let file = std::fs::File::create(&path)?;
                let mut writer = std::io::BufWriter::new(file);
                formatter.write_heap_analysis(profile, &analysis, &mut writer)?;
                eprintln!("Wrote: {}", path.display());
            } else {
                let stdout = std::io::stdout();
                let mut writer = stdout.lock();
                formatter.write_heap_analysis(profile, &analysis, &mut writer)?;
            }
        }

        Ok(())
    }
}

/// Find all profile files in a directory
fn find_profiles(
    dir: &PathBuf,
    extension: &str,
) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
    let mut profiles: Vec<(PathBuf, u64)> = Vec::new();

    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();

        if path.extension().is_some_and(|ext| ext == extension) {
            let size = path.metadata().map(|m| m.len()).unwrap_or(0);
            profiles.push((path, size));
        }
    }

    // Sort by size descending (largest first)
    profiles.sort_by(|a, b| b.1.cmp(&a.1));

    Ok(profiles.into_iter().map(|(path, _)| path).collect())
}