hprof-analyzer 0.2.0

Fast, low-memory Java HPROF heap-dump analyzer with Eclipse MAT-parity reports (System Overview, Leak Suspects, Top Consumers).
use std::path::Path;
use std::process::Command;

/// Fixtures live in `tests/fixtures/` as plain git blobs (`*.hprof` dumps plus
/// `*_ours.md` / `*_mdgraphs.md` baselines). Each parity case runs the analyzer
/// on a dump and compares stdout to the baseline, ignoring the single
/// `Generated by hprof-analyzer views` line (a timestamp that changes per run).
/// The `_ours.md` baselines cover the default `md` format; the `_mdgraphs.md`
/// baselines cover the `md-graphs` format (same numbers, plus ASCII bars,
/// sparkline, and tree-drawn packages).
///
/// These baselines assert that our Markdown output is *stable across runs* under
/// `--detail default`. They are NOT byte-identical to MAT: our output includes
/// ours-only sections (path-to-GC-root chains, dominator subtree, per-thread
/// local objects, allocation sites) that MAT does not emit. Regenerate a
/// baseline with `REGEN_FIXTURES=1 cargo test --release parity` when the
/// output legitimately changes.
const FIXTURES: &[(&str, &str, &str)] = &[
    (
        "dump_0_fj-kmeans.hprof",
        "dump_0_fj-kmeans_ours.md",
        "dump_0_fj-kmeans_mdgraphs.md",
    ),
    (
        "dump_1_mnemonics.hprof",
        "dump_1_mnemonics_ours.md",
        "dump_1_mnemonics_mdgraphs.md",
    ),
    (
        "dump_2_scala-doku.hprof",
        "dump_2_scala-doku_ours.md",
        "dump_2_scala-doku_mdgraphs.md",
    ),
    (
        "dump_3_par-mnemonics.hprof",
        "dump_3_par-mnemonics_ours.md",
        "dump_3_par-mnemonics_mdgraphs.md",
    ),
    (
        "dump_4_philosophers.hprof",
        "dump_4_philosophers_ours.md",
        "dump_4_philosophers_mdgraphs.md",
    ),
    (
        "dump_6_dec-tree.hprof",
        "dump_6_dec-tree_ours.md",
        "dump_6_dec-tree_mdgraphs.md",
    ),
    (
        "dump_7_gauss-mix.hprof",
        "dump_7_gauss-mix_ours.md",
        "dump_7_gauss-mix_mdgraphs.md",
    ),
    (
        "dump_8_log-regression.hprof",
        "dump_8_log-regression_ours.md",
        "dump_8_log-regression_mdgraphs.md",
    ),
];

fn strip_views_line(s: &str) -> String {
    s.lines()
        .filter(|l| !l.contains("Generated by hprof-analyzer views"))
        .collect::<Vec<_>>()
        .join("\n")
}

#[test]
fn parity_all_fixtures() {
    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
    let regen = std::env::var("REGEN_FIXTURES").is_ok();

    let mut failures = Vec::new();
    let mut ran = 0usize;
    let mut regenned = 0usize;
    for (hprof, baseline_md, baseline_graphs) in FIXTURES {
        let hprof_path = dir.join(hprof);
        // Some dumps are too large to keep in git history and are only present
        // on machines that have them locally. Skip a case when its dump is
        // absent (or an unsmudged LFS pointer) rather than failing.
        let present = std::fs::metadata(&hprof_path)
            .map(|m| m.len() >= 1024)
            .unwrap_or(false);
        if !present {
            eprintln!(
                "skipping parity for {hprof}: dump missing at {}",
                dir.display()
            );
            continue;
        }
        ran += 1;
        // Default `md` format (no --format flag).
        let out = Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
            .arg(&hprof_path)
            .output()
            .unwrap_or_else(|e| panic!("failed to run analyzer on {hprof}: {e}"));
        assert!(
            out.status.success(),
            "{hprof} (md) exited non-zero: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        let got = strip_views_line(&String::from_utf8_lossy(&out.stdout));
        if regen {
            std::fs::write(dir.join(baseline_md), got.as_bytes()).unwrap();
            regenned += 1;
        } else {
            let want = strip_views_line(&std::fs::read_to_string(dir.join(baseline_md)).unwrap());
            if got != want {
                failures.push(format!("{hprof} (md)"));
            }
        }

        // `md-graphs` format.
        let out = Command::new(env!("CARGO_BIN_EXE_hprof-analyzer"))
            .arg(&hprof_path)
            .args(["--format", "md-graphs"])
            .output()
            .unwrap_or_else(|e| panic!("failed to run analyzer on {hprof} (md-graphs): {e}"));
        assert!(
            out.status.success(),
            "{hprof} (md-graphs) exited non-zero: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        let got = strip_views_line(&String::from_utf8_lossy(&out.stdout));
        if regen {
            std::fs::write(dir.join(baseline_graphs), got.as_bytes()).unwrap();
        } else {
            let want =
                strip_views_line(&std::fs::read_to_string(dir.join(baseline_graphs)).unwrap());
            if got != want {
                failures.push(format!("{hprof} (md-graphs)"));
            }
        }
    }
    if regen {
        eprintln!(
            "parity: regenerated {regenned} of {ran} present fixtures ({} total)",
            FIXTURES.len()
        );
    } else {
        assert!(
            failures.is_empty(),
            "parity mismatch for: {failures:?}\n\nRegenerate with: REGEN_FIXTURES=1 cargo test --release parity"
        );
        eprintln!("parity: ran {ran} of {} fixtures", FIXTURES.len());
    }
}