stackwise 0.3.1

Drop-in Rust stack usage analysis with JSON reports and an interactive local UI
Documentation
mod cargo_mode;
mod cli;
mod config;
mod progress;
mod server;

use std::ffi::OsString;
use std::fs;

use anyhow::{bail, Context};
use camino::{Utf8Path, Utf8PathBuf};
use clap::{CommandFactory, Parser};
use schemars::schema_for;
use stackwise_core::{analyze_artifact, AnalyzeOptions, BuildInfo, ExactMode, StackwiseReport};

use crate::cargo_mode::{run_cargo_analysis, CargoAnalysisRequest};
use crate::cli::{Cli, Commands, ExactModeArg};
use crate::config::StackwiseConfig;
use crate::progress::AnalysisProgress;

pub fn run<I, T>(args: I) -> anyhow::Result<()>
where
    I: IntoIterator<Item = T>,
    T: Into<OsString> + Clone,
{
    let cli = Cli::parse_from(args);

    match cli.command {
        Some(Commands::Analyze(command)) => {
            if command.open || command.serve {
                let report_path = command
                    .json
                    .clone()
                    .unwrap_or_else(|| default_artifact_report_path(&command.artifact));
                let report =
                    analyze_to_file(&command.artifact, command.build_info(), &report_path)?;
                print_summary(&report);
                server::serve_report(report_path, command.open)?;
            } else {
                let report = match command.json.as_deref() {
                    Some(path) => analyze_to_file(&command.artifact, command.build_info(), path)?,
                    None => analyze_to_stdout(&command.artifact, command.build_info())?,
                };
                print_summary(&report);
            }
        }
        Some(Commands::Open(command)) => {
            server::serve_report(command.report, !command.serve)?;
        }
        Some(Commands::Check(command)) => {
            let report = read_report(&command.report)?;
            run_check(
                &report,
                command.max_own_frame,
                command.max_measured_path,
                command.fail_on_unmeasured,
            )?;
        }
        Some(Commands::Doctor) => {
            print_doctor();
        }
        Some(Commands::Schema(command)) => {
            let schema = schema_for!(StackwiseReport);
            if command.json {
                println!("{}", serde_json::to_string_pretty(&schema)?);
            } else {
                Cli::command().print_help()?;
                println!();
            }
        }
        Some(Commands::Init) => {
            config::init_config()?;
        }
        None => {
            let config = StackwiseConfig::load_from_current_dir().unwrap_or_default();
            let report_path = run_cargo_analysis(CargoAnalysisRequest::from_cli(&cli, config)?)?;
            if cli.open || cli.serve {
                server::serve_report(report_path, cli.open)?;
            }
        }
    }

    Ok(())
}

fn write_report_to_file(report: &StackwiseReport, path: &Utf8Path) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create report directory {parent}"))?;
    }
    fs::write(path, serde_json::to_vec_pretty(report)?)
        .with_context(|| format!("failed to write report {path}"))?;
    Ok(())
}

pub(crate) fn default_artifact_report_path(artifact: &Utf8Path) -> Utf8PathBuf {
    let artifact_text = artifact.as_str();
    let has_trailing_separator = artifact_text.ends_with('/') || artifact_text.ends_with('\\');
    let report_name = if has_trailing_separator {
        "report.stackwise.json".to_owned()
    } else {
        artifact
            .file_stem()
            .filter(|stem| !stem.is_empty())
            .map(|stem| format!("{stem}.stackwise.json"))
            .unwrap_or_else(|| "report.stackwise.json".to_owned())
    };
    if has_trailing_separator {
        return artifact.join(report_name);
    }
    artifact
        .parent()
        .map(|parent| parent.join(&report_name))
        .unwrap_or_else(|| Utf8PathBuf::from(report_name))
}

fn read_report(path: &Utf8Path) -> anyhow::Result<StackwiseReport> {
    let data = fs::read(path).with_context(|| format!("failed to read report {path}"))?;
    serde_json::from_slice(&data).with_context(|| format!("failed to parse report {path}"))
}

fn run_check(
    report: &StackwiseReport,
    max_own_frame: Option<u64>,
    max_measured_path: Option<u64>,
    fail_on_unmeasured: bool,
) -> anyhow::Result<()> {
    let mut failures = Vec::new();

    if let Some(limit) = max_own_frame {
        for symbol in &report.symbols {
            if symbol.own_frame.bytes.is_some_and(|bytes| bytes > limit) {
                failures.push(format!(
                    "own frame {} bytes exceeds {} in {}",
                    symbol.own_frame.bytes.unwrap_or_default(),
                    limit,
                    symbol.demangled
                ));
            }
        }
    }

    if let Some(limit) = max_measured_path {
        for symbol in &report.symbols {
            if symbol.worst_path.bytes.is_some_and(|bytes| bytes > limit) {
                failures.push(format!(
                    "measured path {} bytes exceeds {} from {}",
                    symbol.worst_path.bytes.unwrap_or_default(),
                    limit,
                    symbol.demangled
                ));
            }
        }
    }

    if fail_on_unmeasured && report.summary.unknown_frame_count > 0 {
        failures.push(format!(
            "{} symbols have unmeasured frame sizes",
            report.summary.unknown_frame_count
        ));
    }

    if !failures.is_empty() {
        for failure in &failures {
            eprintln!("check failed: {failure}");
        }
        bail!("stackwise check failed with {} issue(s)", failures.len());
    }

    println!("stackwise check passed");
    Ok(())
}

pub(crate) fn print_summary(report: &StackwiseReport) {
    println!(
        "Analyzed {} symbols, {} edges, {} measured frames, {} unmeasured frames",
        report.summary.symbol_count,
        report.summary.edge_count,
        report.summary.known_frame_count,
        report.summary.unknown_frame_count
    );

    if let Some(max) = &report.summary.max_own_frame {
        println!(
            "Largest own frame: {} bytes in {}",
            max.bytes, max.demangled
        );
    }

    if let Some(max) = &report.summary.max_worst_path {
        println!(
            "Largest measured path: {} bytes from {}",
            max.bytes, max.demangled
        );
    }

    for diagnostic in &report.diagnostics {
        println!(
            "{:?}: {} - {}",
            diagnostic.level, diagnostic.code, diagnostic.message
        );
    }
}

fn print_doctor() {
    println!("Stackwise doctor");
    println!("version: {}", env!("CARGO_PKG_VERSION"));
    println!(
        "current_dir: {}",
        std::env::current_dir()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|_| "<unknown>".to_owned())
    );
    println!("rustc: {}", command_version("rustc", &["--version"]));
    println!("cargo: {}", command_version("cargo", &["--version"]));
    println!(
        "rustup nightly: {}",
        command_version("rustup", &["which", "rustc", "--toolchain", "nightly"])
    );
    println!(
        "llvm-readobj: {}",
        command_version("llvm-readobj", &["--version"])
    );
    println!("note: exact Rust stack-size metadata currently requires nightly rustc, ELF output, and preserved .stack_sizes sections.");
}

fn command_version(command: &str, args: &[&str]) -> String {
    match std::process::Command::new(command).args(args).output() {
        Ok(output) if output.status.success() => {
            let text = String::from_utf8_lossy(&output.stdout);
            text.lines().next().unwrap_or("<empty>").to_owned()
        }
        Ok(output) => {
            let text = String::from_utf8_lossy(&output.stderr);
            text.lines().next().unwrap_or("<failed>").to_owned()
        }
        Err(error) => format!("not found ({error})"),
    }
}

pub(crate) fn analyze_and_write(
    artifact: &Utf8Path,
    build: BuildInfo,
    json_path: Utf8PathBuf,
) -> anyhow::Result<StackwiseReport> {
    let exact_required = build.exact_mode == ExactMode::Required;

    let progress = AnalysisProgress::new();
    progress.set_stage("Analyzing artifact...");
    let report = analyze_artifact(artifact, AnalyzeOptions { build: Some(build) })?;
    if exact_required && report.summary.confidence != stackwise_core::Confidence::Exact {
        bail!(
            "exact stack data was required, but this artifact only produced {:?} confidence; use an ELF artifact with preserved .stack_sizes or omit --exact",
            report.summary.confidence
        );
    }

    progress.set_stage("Writing report...");
    write_report_to_file(&report, &json_path)?;
    progress.finish();

    println!("Wrote {}", json_path);
    print_summary(&report);
    Ok(report)
}

fn analyze_to_file(
    artifact: &Utf8Path,
    build: Option<BuildInfo>,
    json_path: &Utf8Path,
) -> anyhow::Result<StackwiseReport> {
    let progress = AnalysisProgress::new();
    progress.set_stage("Analyzing artifact...");
    let report = analyze_artifact(artifact, AnalyzeOptions { build })?;

    progress.set_stage("Writing report...");
    write_report_to_file(&report, json_path)?;
    progress.finish();

    println!("Wrote {}", json_path);
    Ok(report)
}

fn analyze_to_stdout(
    artifact: &Utf8Path,
    build: Option<BuildInfo>,
) -> anyhow::Result<StackwiseReport> {
    let report = analyze_artifact(artifact, AnalyzeOptions { build })?;
    println!("{}", serde_json::to_string_pretty(&report)?);
    Ok(report)
}

pub(crate) fn exact_mode_from_arg(arg: ExactModeArg) -> ExactMode {
    match arg {
        ExactModeArg::Off => ExactMode::Off,
        ExactModeArg::Auto => ExactMode::Auto,
        ExactModeArg::Required => ExactMode::Required,
    }
}

#[cfg(test)]
mod tests {
    use super::default_artifact_report_path;
    use camino::Utf8Path;

    #[test]
    fn default_artifact_report_path_uses_artifact_parent_and_stem() {
        let artifact = Utf8Path::new(r"C:\tmp\app.exe");

        assert_eq!(
            default_artifact_report_path(artifact).as_str(),
            r"C:\tmp\app.stackwise.json"
        );
    }

    #[test]
    fn default_artifact_report_path_falls_back_for_empty_stem() {
        let artifact = Utf8Path::new(r"C:\tmp\");

        assert_eq!(
            default_artifact_report_path(artifact).as_str(),
            r"C:\tmp\report.stackwise.json"
        );
    }
}