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};
#[derive(Debug, Clone, Bpaf)]
#[bpaf(command("run"))]
pub struct RunCommand {
#[bpaf(long, switch)]
pub cpu: bool,
#[bpaf(long, switch)]
pub heap: bool,
#[bpaf(external(output_options))]
pub output_options: OutputOptions,
#[bpaf(long)]
pub filter: Option<String>,
#[bpaf(external(analysis_options))]
pub analysis_options: AnalysisOptions,
#[bpaf(long, argument("NAME"))]
pub package: Option<String>,
#[bpaf(long)]
pub sourcemap_dir: Option<PathBuf>,
#[bpaf(long)]
pub profile_dir: Option<PathBuf>,
#[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());
}
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)
};
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!();
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)
);
}
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(());
}
if !cpu_profiles.is_empty() {
eprintln!();
if cpu_profiles.len() > 1 {
eprintln!(
"Merging {} CPU profiles from multiple processes...",
cpu_profiles.len()
);
}
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)?);
}
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()
);
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)");
}
}
self.analyze_cpu_profile(&merged_profile)?;
}
if !heap_profiles.is_empty() {
eprintln!();
if heap_profiles.len() > 1 {
eprintln!(
"Merging {} heap profiles from multiple processes...",
heap_profiles.len()
);
}
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)?);
}
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()
);
self.analyze_heap_profile(&merged_profile)?;
}
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>> {
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);
if let Some(filter_str) = &self.filter {
let categories = parse_categories(filter_str);
analyzer = analyzer.filter_categories(categories);
}
if let Some(pkg) = &self.package {
analyzer = analyzer.filter_package(pkg.clone());
}
let analysis = analyzer.analyze(profile);
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);
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
};
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>> {
let analyzer = HeapAnalyzer::new()
.include_internals(self.analysis_options.include_internals)
.min_percent(self.analysis_options.min_percent)
.top_n(self.analysis_options.top);
let analysis = analyzer.analyze(profile);
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);
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
};
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(())
}
}
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));
}
}
profiles.sort_by(|a, b| b.1.cmp(&a.1));
Ok(profiles.into_iter().map(|(path, _)| path).collect())
}