use std::collections::BTreeMap;
use std::io::{self, Read};
use crate::md::{Align, Table};
use crate::opts::OutputFormat;
use crate::report::{self, Report};
const TOP_N: usize = 25;
fn load_report(path: &str) -> io::Result<Report> {
let json = if path == "-" {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
buf
} else {
std::fs::read_to_string(path).map_err(|e| {
if e.kind() == io::ErrorKind::InvalidData {
let lower = path.to_ascii_lowercase();
let is_hprof_ext = lower.ends_with(".hprof")
|| lower.ends_with(".hprof.gz")
|| lower.ends_with(".hprof.zip")
|| lower.ends_with(".tar.gz")
|| lower.ends_with(".tgz");
let is_hprof_magic = std::fs::File::open(path)
.ok()
.and_then(|mut f| {
let mut head = [0u8; 12];
f.read_exact(&mut head).ok().map(|_| head)
})
.is_some_and(|h| h.starts_with(b"JAVA PROFILE"));
if is_hprof_ext || is_hprof_magic {
return io::Error::new(
io::ErrorKind::InvalidData,
format!(
"'{path}' looks like a heap dump, not a Report JSON. \
`compare reports` needs pre-built Report JSON files \
(produced by: hprof-analyzer {path} --format json > report.json)"
),
);
}
return io::Error::new(
io::ErrorKind::InvalidData,
format!("'{path}' is not valid UTF-8; expected a Report JSON file"),
);
}
e
})?
};
let report: Report = serde_json::from_str(&json).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid report JSON ({path}): {e}"),
)
})?;
if report.schema_version > report::SCHEMA_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"report {} schema_version {} is newer than supported version {}; update hprof-analyzer",
path,
report.schema_version,
report::SCHEMA_VERSION
),
));
}
Ok(report)
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SeriesClassRow {
pub pretty_class: String,
pub retained: Vec<u64>,
pub instances: Vec<u64>,
pub delta_retained: i64,
pub delta_instances: i64,
pub peak_retained: u64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct TypeEdgeDiff {
pub src_class: String,
pub dst_class: String,
pub count_first: u64,
pub count_last: u64,
pub delta_count: i64,
pub weight_first: u64,
pub weight_last: u64,
pub delta_weight: i64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SeriesSuspectRow {
pub pretty_class: String,
pub retained: Vec<u64>,
pub delta_retained: i64,
pub is_new: bool,
pub is_gone: bool,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SeriesDiffResult {
pub labels: Vec<String>,
pub total_objects: Vec<u64>,
pub total_shallow: Vec<u64>,
pub delta_total_objects: i64,
pub delta_total_shallow: i64,
pub net_delta_retained: i64,
pub gross_growth_retained: i64,
pub gross_shrink_retained: i64,
pub growth_leaders: Vec<SeriesClassRow>,
pub spike_leaders: Vec<SeriesClassRow>,
pub new_classes: Vec<SeriesClassRow>,
pub removed_classes: Vec<SeriesClassRow>,
pub grown_suspects: Vec<SeriesSuspectRow>,
pub shrunk_suspects: Vec<SeriesSuspectRow>,
pub gone_suspects: Vec<SeriesSuspectRow>,
pub tpfg_diff: Vec<TypeEdgeDiff>,
}
pub fn diff_series(reports: &[Report]) -> SeriesDiffResult {
let n = reports.len();
let last = n.saturating_sub(1);
let labels: Vec<String> = reports
.iter()
.enumerate()
.map(|(i, r)| {
if r.overview.source_name.is_empty() {
format!("report {}", i + 1)
} else {
r.overview.source_name.clone()
}
})
.collect();
let total_objects: Vec<u64> = reports.iter().map(|r| r.overview.total_objects).collect();
let total_shallow: Vec<u64> = reports.iter().map(|r| r.overview.total_shallow).collect();
let delta_total_objects = if n == 0 {
0
} else {
total_objects[last] as i64 - total_objects[0] as i64
};
let delta_total_shallow = if n == 0 {
0
} else {
total_shallow[last] as i64 - total_shallow[0] as i64
};
let mut joined: BTreeMap<&str, Vec<Option<(u64, u64)>>> = BTreeMap::new();
for (i, r) in reports.iter().enumerate() {
for row in &r.overview.histogram {
let e = joined
.entry(row.pretty_class.as_str())
.or_insert_with(|| vec![None; n]);
e[i] = Some((row.instances, row.retained));
}
}
let mut all_rows: Vec<SeriesClassRow> = Vec::with_capacity(joined.len());
let mut new_classes: Vec<SeriesClassRow> = Vec::new();
let mut removed_classes: Vec<SeriesClassRow> = Vec::new();
let mut net_delta_retained: i64 = 0;
let mut gross_growth_retained: i64 = 0;
let mut gross_shrink_retained: i64 = 0;
for (name, cells) in &joined {
let instances: Vec<u64> = cells.iter().map(|c| c.map_or(0, |(i, _)| i)).collect();
let retained: Vec<u64> = cells.iter().map(|c| c.map_or(0, |(_, r)| r)).collect();
let first_present = n > 0 && cells[0].is_some();
let last_present = n > 0 && cells[last].is_some();
let delta_retained = if n == 0 {
0
} else {
retained[last] as i64 - retained[0] as i64
};
let delta_instances = if n == 0 {
0
} else {
instances[last] as i64 - instances[0] as i64
};
let peak_retained = retained.iter().copied().max().unwrap_or(0);
net_delta_retained += delta_retained;
if delta_retained > 0 {
gross_growth_retained += delta_retained;
} else if delta_retained < 0 {
gross_shrink_retained += delta_retained;
}
let row = SeriesClassRow {
pretty_class: (*name).to_string(),
retained,
instances,
delta_retained,
delta_instances,
peak_retained,
};
if last_present && !first_present {
new_classes.push(row.clone());
} else if first_present && !last_present {
removed_classes.push(row.clone());
}
all_rows.push(row);
}
let first_retained_for =
|row: &SeriesClassRow| -> u64 { row.retained.first().copied().unwrap_or(0) };
let peak_delta =
|row: &SeriesClassRow| -> i64 { row.peak_retained as i64 - first_retained_for(row) as i64 };
let mut growth_leaders: Vec<SeriesClassRow> = all_rows
.iter()
.filter(|c| peak_delta(c) > 0)
.cloned()
.collect();
growth_leaders.sort_by(|x, y| {
peak_delta(y)
.cmp(&peak_delta(x))
.then_with(|| x.pretty_class.cmp(&y.pretty_class))
});
growth_leaders.truncate(TOP_N);
let spike_leaders: Vec<SeriesClassRow> = if n >= 3 {
let mut spikes: Vec<SeriesClassRow> = all_rows
.iter()
.filter(|c| {
let first = first_retained_for(c);
c.peak_retained > first && c.peak_retained > c.retained.last().copied().unwrap_or(0)
})
.cloned()
.collect();
spikes.sort_by(|x, y| {
peak_delta(y)
.cmp(&peak_delta(x))
.then_with(|| x.pretty_class.cmp(&y.pretty_class))
});
spikes.truncate(TOP_N);
spikes
} else {
vec![]
};
new_classes.sort_by(|x, y| {
y.retained[last]
.cmp(&x.retained[last])
.then_with(|| x.pretty_class.cmp(&y.pretty_class))
});
new_classes.truncate(TOP_N);
removed_classes.sort_by(|x, y| {
y.retained[0]
.cmp(&x.retained[0])
.then_with(|| x.pretty_class.cmp(&y.pretty_class))
});
removed_classes.truncate(TOP_N);
let mut suspects: BTreeMap<&str, Vec<Option<u64>>> = BTreeMap::new();
for (i, r) in reports.iter().enumerate() {
for s in &r.leaks.suspects {
let e = suspects
.entry(s.pretty_class.as_str())
.or_insert_with(|| vec![None; n]);
e[i] = Some(e[i].map_or(s.retained, |cur| cur.max(s.retained)));
}
}
let mut grown_suspects: Vec<SeriesSuspectRow> = Vec::new();
let mut shrunk_suspects: Vec<SeriesSuspectRow> = Vec::new();
let mut gone_suspects: Vec<SeriesSuspectRow> = Vec::new();
for (name, cells) in &suspects {
let retained: Vec<u64> = cells.iter().map(|c| c.unwrap_or(0)).collect();
let first_present = n > 0 && cells[0].is_some();
let last_present = n > 0 && cells[last].is_some();
let delta_retained = if n == 0 {
0
} else {
retained[last] as i64 - retained[0] as i64
};
let is_new = last_present && !first_present;
let is_gone = first_present && !last_present;
let row = SeriesSuspectRow {
pretty_class: (*name).to_string(),
retained,
delta_retained,
is_new,
is_gone,
};
if is_gone {
gone_suspects.push(row);
} else if is_new || delta_retained > 0 {
if last_present {
grown_suspects.push(row);
}
} else if last_present && first_present && delta_retained < 0 {
shrunk_suspects.push(row);
}
}
grown_suspects.sort_by(|x, y| {
y.retained[last]
.cmp(&x.retained[last])
.then_with(|| x.pretty_class.cmp(&y.pretty_class))
});
shrunk_suspects.sort_by(|x, y| {
x.delta_retained
.cmp(&y.delta_retained)
.then_with(|| x.pretty_class.cmp(&y.pretty_class))
});
gone_suspects.sort_by(|x, y| {
y.retained[0]
.cmp(&x.retained[0])
.then_with(|| x.pretty_class.cmp(&y.pretty_class))
});
let tpfg_diff: Vec<TypeEdgeDiff> = if n >= 2
&& !reports[0].type_ref_graph.is_empty()
&& !reports[last].type_ref_graph.is_empty()
{
use std::collections::HashMap;
let mut first_map: HashMap<(&str, &str), (u64, u64)> = HashMap::new();
for e in &reports[0].type_ref_graph {
first_map.insert(
(e.src_class.as_str(), e.dst_class.as_str()),
(e.edge_count, e.retained_weight),
);
}
let mut last_map: HashMap<(&str, &str), (u64, u64)> = HashMap::new();
for e in &reports[last].type_ref_graph {
last_map.insert(
(e.src_class.as_str(), e.dst_class.as_str()),
(e.edge_count, e.retained_weight),
);
}
let mut all_keys: std::collections::BTreeSet<(&str, &str)> =
std::collections::BTreeSet::new();
for k in first_map.keys() {
all_keys.insert(*k);
}
for k in last_map.keys() {
all_keys.insert(*k);
}
let mut rows: Vec<TypeEdgeDiff> = all_keys
.into_iter()
.map(|(src, dst)| {
let (count_first, weight_first) =
first_map.get(&(src, dst)).copied().unwrap_or((0, 0));
let (count_last, weight_last) =
last_map.get(&(src, dst)).copied().unwrap_or((0, 0));
TypeEdgeDiff {
src_class: src.to_string(),
dst_class: dst.to_string(),
count_first,
count_last,
delta_count: count_last as i64 - count_first as i64,
weight_first,
weight_last,
delta_weight: weight_last as i64 - weight_first as i64,
}
})
.collect();
rows.sort_unstable_by(|a, b| {
b.delta_weight
.unsigned_abs()
.cmp(&a.delta_weight.unsigned_abs())
.then(
b.delta_count
.unsigned_abs()
.cmp(&a.delta_count.unsigned_abs()),
)
.then(a.src_class.cmp(&b.src_class))
});
rows.truncate(500);
rows
} else {
vec![]
};
SeriesDiffResult {
labels,
total_objects,
total_shallow,
delta_total_objects,
delta_total_shallow,
net_delta_retained,
gross_growth_retained,
gross_shrink_retained,
growth_leaders,
spike_leaders,
new_classes,
removed_classes,
grown_suspects,
shrunk_suspects,
gone_suspects,
tpfg_diff,
}
}
const MINUS: char = '\u{2212}';
fn fmt_delta_bytes(n: i64) -> String {
if n == 0 {
return "0 B".to_string();
}
let sign = if n > 0 { '+' } else { MINUS };
let mag = report::format_bytes(n.unsigned_abs());
format!("{sign}{mag}")
}
fn fmt_delta_count(n: i64) -> String {
if n == 0 {
return "0".to_string();
}
let sign = if n > 0 { '+' } else { MINUS };
let s = n.unsigned_abs().to_string();
let mut grouped = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
grouped.push(',');
}
grouped.push(c);
}
let grouped: String = grouped.chars().rev().collect();
format!("{sign}{grouped}")
}
fn verdict(d: &SeriesDiffResult) -> String {
let first_shallow = d.total_shallow.first().copied().unwrap_or(0);
let pct = if first_shallow > 0 {
d.delta_total_shallow as f64 / first_shallow as f64 * 100.0
} else {
0.0
};
let new_suspects = d.grown_suspects.iter().filter(|s| s.is_new).count();
let mut line = if d.delta_total_shallow > 0 {
let driver = d
.growth_leaders
.first()
.map(|c| {
format!(
"; largest driver `{}` ({} retained)",
c.pretty_class,
fmt_delta_bytes(c.delta_retained)
)
})
.unwrap_or_default();
format!(
"Heap grew {:.1}% ({} shallow){}.",
pct,
fmt_delta_bytes(d.delta_total_shallow),
driver,
)
} else if d.delta_total_shallow < 0 {
format!(
"Heap shrank {:.1}% ({} shallow); no net growth.",
pct.abs(),
fmt_delta_bytes(d.delta_total_shallow),
)
} else {
"Heap size is unchanged.".to_string()
};
if d.gross_growth_retained != 0 || d.gross_shrink_retained != 0 {
line.push_str(&format!(
" Gross retained churn: {} grown / {} reclaimed across steps.",
fmt_delta_bytes(d.gross_growth_retained),
fmt_delta_bytes(d.gross_shrink_retained),
));
}
if new_suspects > 0 {
let plural = if new_suspects == 1 { "" } else { "s" };
line.push_str(&format!(" {new_suspects} new suspect{plural}."));
}
line
}
fn report_col_headers(n: usize) -> Vec<String> {
(1..=n).map(|i| format!("r{i}")).collect()
}
fn series_table(label: &str, n: usize) -> Table {
let mut headers: Vec<String> = vec![label.to_string()];
headers.extend(report_col_headers(n));
headers.push("Δ(r1→rN)".to_string());
let mut aligns: Vec<Align> = vec![Align::Left];
aligns.extend(std::iter::repeat_n(Align::Right, n + 1));
let header_refs: Vec<&str> = headers.iter().map(String::as_str).collect();
Table::new(&header_refs, &aligns)
}
fn retained_row(pretty_class: &str, retained: &[u64], delta_retained: i64) -> Vec<String> {
let mut cells: Vec<String> = vec![format!("`{pretty_class}`")];
for &r in retained {
cells.push(report::format_bytes(r));
}
cells.push(fmt_delta_bytes(delta_retained));
cells
}
pub fn render_md(d: &SeriesDiffResult) -> String {
let n = d.labels.len();
let mut out = String::new();
out.push_str("## Cross-Dump Growth\n\n");
out.push_str(
"_How the reachable heap grew across a time series of dumps of the same application \
(first = baseline, last = current)._\n\n",
);
out.push_str("### Reports\n\n");
for (i, label) in d.labels.iter().enumerate() {
out.push_str(&format!("- `r{}` = {}\n", i + 1, label));
}
out.push('\n');
out.push_str(&format!("**Verdict:** {}\n\n", verdict(d)));
out.push_str("### Headline Totals\n\n");
out.push_str(&format!(
"- **Δ Objects (r1→rN):** {}\n",
fmt_delta_count(d.delta_total_objects)
));
out.push_str(&format!(
"- **Δ Shallow heap (r1→rN):** {}\n",
fmt_delta_bytes(d.delta_total_shallow)
));
out.push_str(&format!(
"- **Gross growth (classes that grew, r1→rN):** {}\n",
fmt_delta_bytes(d.gross_growth_retained)
));
out.push_str(&format!(
"- **Gross reclaimed (classes that shrank, r1→rN):** {}\n",
fmt_delta_bytes(d.gross_shrink_retained)
));
let churn = d
.gross_growth_retained
.unsigned_abs()
.saturating_add(d.gross_shrink_retained.unsigned_abs()) as i64;
out.push_str(&format!(
"- **Gross Retained churn (growth + reclaimed, r1→rN):** {}\n",
fmt_delta_bytes(churn)
));
out.push_str(&format!(
"- **Net Δ Retained (all classes, r1→rN):** {}\n\n",
fmt_delta_bytes(d.net_delta_retained)
));
out.push_str("### Growth Leaders (by peak−baseline retained)\n\n");
if d.growth_leaders.is_empty() {
out.push_str("No class grew in retained heap.\n\n");
} else {
let mut t = series_table("Class", n);
for c in &d.growth_leaders {
t.row(retained_row(&c.pretty_class, &c.retained, c.delta_retained));
}
t.render(&mut out);
out.push('\n');
}
if !d.spike_leaders.is_empty() {
out.push_str("### Spike Leaders (peaked then reclaimed)\n\n");
out.push_str(
"_Classes whose retained peaked at an intermediate report then reclaimed memory \
by the last report. Sorted by peak−baseline retained._\n\n",
);
let mut t = series_table("Class", n);
for c in &d.spike_leaders {
t.row(retained_row(&c.pretty_class, &c.retained, c.delta_retained));
}
t.render(&mut out);
out.push('\n');
}
out.push_str("### New Classes\n\n");
if d.new_classes.is_empty() {
out.push_str("No classes are new in the current dump.\n\n");
} else {
let mut t = series_table("Class", n);
for c in &d.new_classes {
t.row(retained_row(&c.pretty_class, &c.retained, c.delta_retained));
}
t.render(&mut out);
out.push('\n');
}
out.push_str("### Removed Classes\n\n");
if d.removed_classes.is_empty() {
out.push_str("No classes dropped out of the current dump.\n\n");
} else {
let mut t = series_table("Class", n);
for c in &d.removed_classes {
t.row(retained_row(&c.pretty_class, &c.retained, c.delta_retained));
}
t.render(&mut out);
out.push('\n');
}
out.push_str("### New / Grown Leak Suspects\n\n");
if d.grown_suspects.is_empty() {
out.push_str("No leak suspect is new or grew in the current dump.\n\n");
} else {
let mut headers: Vec<String> = vec!["Suspect".to_string()];
headers.extend(report_col_headers(n));
headers.push("Δ(r1→rN)".to_string());
headers.push("New?".to_string());
let mut aligns: Vec<Align> = vec![Align::Left];
aligns.extend(std::iter::repeat_n(Align::Right, n + 1));
aligns.push(Align::Left);
let header_refs: Vec<&str> = headers.iter().map(String::as_str).collect();
let mut t = Table::new(&header_refs, &aligns);
for s in &d.grown_suspects {
let mut cells = retained_row(&s.pretty_class, &s.retained, s.delta_retained);
cells.push(if s.is_new {
"yes".to_string()
} else {
String::new()
});
t.row(cells);
}
t.render(&mut out);
out.push('\n');
}
out.push_str("### Shrunk Leak Suspects\n\n");
if d.shrunk_suspects.is_empty() {
out.push_str("No leak suspect shrank in the current dump.\n\n");
} else {
let mut t = series_table("Suspect", n);
for s in &d.shrunk_suspects {
t.row(retained_row(&s.pretty_class, &s.retained, s.delta_retained));
}
t.render(&mut out);
out.push('\n');
}
out.push_str("### Disappeared Leak Suspects\n\n");
if d.gone_suspects.is_empty() {
out.push_str("No leak suspect disappeared in the current dump.\n\n");
} else {
let mut t = series_table("Suspect", n);
for s in &d.gone_suspects {
t.row(retained_row(&s.pretty_class, &s.retained, s.delta_retained));
}
t.render(&mut out);
out.push('\n');
}
out
}
pub fn run(paths: &[String], format: OutputFormat) -> io::Result<String> {
let mut reports: Vec<Report> = Vec::with_capacity(paths.len());
for p in paths {
reports.push(load_report(p)?);
}
let result = diff_series(&reports);
Ok(match format {
OutputFormat::Json => serde_json::to_string_pretty(&result).map_err(io::Error::other)?,
OutputFormat::Html => crate::html::render_diff_html(&result),
OutputFormat::Md | OutputFormat::MdGraphs => render_md(&result),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{
HistRow, LeakSuspects, PackageNode, SCHEMA_VERSION, Suspect, SystemOverview, TopConsumers,
};
fn hist(name: &str, inst: u64, sh: u64, ret: u64) -> HistRow {
HistRow {
pretty_class: name.to_string(),
instances: inst,
shallow: sh,
retained: ret,
max_instance_shallow: 0,
incoming_ref_count: 0,
loader_id: 0,
loader_label: None,
root_path: None,
}
}
fn suspect(name: &str, inst: u64, ret: u64) -> Suspect {
Suspect {
is_single: false,
pretty_class: name.to_string(),
instance_count: inst,
retained: ret,
shallow: 0,
path: vec![],
accumulation_obj_1based: None,
accumulation_class: None,
accumulation_retained: None,
dominated: vec![],
dominated_total_count: 0,
dominated_shown: 0,
dominated_by_class: vec![],
keywords: vec![],
root_type_label: String::new(),
root_path: None,
dominator_tree: None,
merged_paths: None,
}
}
fn base_report(
total_objects: u64,
total_shallow: u64,
histogram: Vec<HistRow>,
suspects: Vec<Suspect>,
) -> Report {
Report {
schema_version: SCHEMA_VERSION,
generated: "x".to_string(),
truncated_input: false,
overview: SystemOverview {
source_name: "s".to_string(),
file_path: "s".to_string(),
format: "hprof".to_string(),
file_size: 100,
identifier_size_bits: 64,
compressed_oops: None,
dump_creation: None,
total_objects,
total_shallow,
gc_roots: 5,
gc_roots_by_type: vec![],
heap_composition: Default::default(),
dominator_depth_histogram: vec![],
retention_concentration: Default::default(),
classes_loaded: 3,
classloaders_loaded: 1,
unreachable_count: 0,
unreachable_shallow: 0,
unreachable_retained: 0,
unreachable_composition: Default::default(),
unreachable_garbage_roots: vec![],
unreachable_histogram: vec![],
histogram,
histogram_truncated_to: None,
system_properties: vec![],
jvm_version: None,
loader_rollup: vec![],
duplicate_classes: vec![],
record_census: Default::default(),
duplicate_strings: None,
duplicate_prim_arrays: None,
heap_fragmentation_ratio: 0.0,
top_class_concentration_bp: 0,
gc_roots_retained_by_type: vec![],
boxed_numbers: vec![],
header_overhead: vec![],
boxed_number_holders: vec![],
},
leaks: LeakSuspects {
total_shallow,
suspects,
},
top: TopConsumers {
biggest_objects: vec![],
biggest_classes: vec![],
threshold_bp: 100,
biggest_packages: PackageNode {
name: String::new(),
top_dominator_count: 0,
shallow_heap: 0,
retained_heap: 0,
children: vec![],
},
size_distribution: Default::default(),
},
threads: crate::report::ThreadOverview { threads: vec![] },
top_components: crate::report::TopComponents::default(),
alloc_sites: None,
arrays_by_size: Default::default(),
dominator_analysis: Default::default(),
collections: Default::default(),
references: Default::default(),
collection_attribution: None,
fields_by_size: None,
biggest_collections: None,
collection_contents: None,
leak_indicators: Default::default(),
triage: Vec::new(),
waste_summary: None,
top_retainers: Vec::new(),
queries: Vec::new(),
analysis_flags: Default::default(),
obj_graph_flat: None,
type_ref_graph: vec![],
thread_local_analysis: Vec::new(),
framework_analysis: Vec::new(),
field_stats: None,
}
}
#[test]
fn schema_version_mismatch_is_err() {
let mut a = base_report(0, 0, vec![], vec![]);
a.schema_version = SCHEMA_VERSION + 1;
let json = serde_json::to_string(&a).unwrap();
let dir = std::env::temp_dir();
let path = dir.join(format!(
"diff_reports_bad_schema_{}.json",
std::process::id()
));
std::fs::write(&path, json).unwrap();
let res = load_report(path.to_str().unwrap());
let _ = std::fs::remove_file(&path);
assert!(res.is_err());
let e = res.err().unwrap();
assert_eq!(e.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn render_md_smoke() {
let a = base_report(0, 0, vec![hist("Foo", 1, 10, 100)], vec![]);
let b = base_report(
0,
0,
vec![hist("Foo", 5, 50, 900)],
vec![suspect("com.example.Leaky", 3, 9_999)],
);
let d = diff_series(&[a, b]);
let md = render_md(&d);
assert!(md.contains("## Cross-Dump Growth"));
assert!(md.contains("### Reports"));
assert!(md.contains("`r1` ="));
assert!(md.contains("`r2` ="));
assert!(md.contains("**Verdict:**"));
assert!(md.contains("### Headline Totals"));
assert!(md.contains("### Growth Leaders (by peak"));
assert!(md.contains("Gross growth"));
assert!(md.contains("Gross reclaimed"));
assert!(md.contains("### New Classes"));
assert!(md.contains("### Removed Classes"));
assert!(md.contains("### New / Grown Leak Suspects"));
assert!(md.contains("### Shrunk Leak Suspects"));
assert!(md.contains("### Disappeared Leak Suspects"));
assert!(md.contains("Δ(r1→rN)"));
assert!(md.contains("`Foo`"));
assert!(md.contains("`com.example.Leaky`"));
}
#[test]
fn render_md_n3_has_three_value_columns() {
let r1 = named_report("d1", 0, 0, vec![hist("Foo", 1, 10, 100)], vec![]);
let r2 = named_report("d2", 0, 0, vec![hist("Foo", 2, 20, 200)], vec![]);
let r3 = named_report("d3", 0, 0, vec![hist("Foo", 3, 30, 300)], vec![]);
let d = diff_series(&[r1, r2, r3]);
let md = render_md(&d);
assert!(md.contains("`r1` = d1"));
assert!(md.contains("`r2` = d2"));
assert!(md.contains("`r3` = d3"));
let header = md
.lines()
.find(|l| l.contains("Class") && l.contains("Δ(r1→rN)"))
.expect("a header row with the class + Δ columns");
let r1 = header.find(" r1 ").expect("r1 column");
let r2 = header.find(" r2 ").expect("r2 column");
let r3 = header.find(" r3 ").expect("r3 column");
let delta = header.find("Δ(r1→rN)").expect("Δ column");
assert!(r1 < r2 && r2 < r3 && r3 < delta, "header order: {header}");
}
#[test]
fn verdict_grew_names_driver() {
let a = base_report(0, 1_000, vec![hist("Big", 1, 10, 1_000)], vec![]);
let b = base_report(
0,
2_000,
vec![hist("Big", 5, 50, 2_000)],
vec![suspect("NewLeak", 3, 500)],
);
let d = diff_series(&[a, b]);
let v = verdict(&d);
assert!(v.starts_with("Heap grew 100.0%"), "got: {v}");
assert!(v.contains("largest driver `Big`"), "got: {v}");
assert!(v.contains("1 new suspect."), "got: {v}");
}
#[test]
fn verdict_shrank() {
let a = base_report(0, 2_000, vec![hist("Big", 5, 50, 2_000)], vec![]);
let b = base_report(0, 500, vec![hist("Big", 1, 10, 500)], vec![]);
let d = diff_series(&[a, b]);
let v = verdict(&d);
assert!(v.starts_with("Heap shrank 75.0%"), "got: {v}");
assert!(v.contains("no net growth"), "got: {v}");
}
fn named_report(
name: &str,
total_objects: u64,
total_shallow: u64,
histogram: Vec<HistRow>,
suspects: Vec<Suspect>,
) -> Report {
let mut r = base_report(total_objects, total_shallow, histogram, suspects);
r.overview.source_name = name.to_string();
r
}
#[test]
fn series_n2_matches_pairwise_diff() {
let a = base_report(
0,
0,
vec![
hist("Grew", 1, 10, 100),
hist("Shrank", 5, 50, 500),
hist("Removed", 2, 20, 200),
],
vec![],
);
let b = base_report(
0,
0,
vec![
hist("Grew", 3, 30, 300),
hist("Shrank", 2, 20, 200),
hist("NewClass", 4, 40, 400),
],
vec![],
);
let s = diff_series(&[a, b]);
assert_eq!(s.growth_leaders.len(), 2);
assert_eq!(s.growth_leaders[0].pretty_class, "NewClass");
assert_eq!(s.growth_leaders[0].delta_retained, 400);
assert_eq!(s.growth_leaders[0].retained, vec![0, 400]);
assert_eq!(s.growth_leaders[1].pretty_class, "Grew");
assert_eq!(s.growth_leaders[1].delta_retained, 200);
assert_eq!(s.growth_leaders[1].delta_instances, 2);
assert_eq!(s.growth_leaders[1].retained, vec![100, 300]);
assert!(
!s.growth_leaders
.iter()
.any(|c| c.pretty_class == "Shrank" || c.pretty_class == "Removed")
);
assert_eq!(s.new_classes.len(), 1);
assert_eq!(s.new_classes[0].pretty_class, "NewClass");
assert_eq!(s.new_classes[0].retained, vec![0, 400]);
assert_eq!(s.removed_classes.len(), 1);
assert_eq!(s.removed_classes[0].pretty_class, "Removed");
assert_eq!(s.removed_classes[0].retained, vec![200, 0]);
assert_eq!(s.labels.len(), 2);
}
#[test]
fn series_n2_suspects_match_pairwise() {
let a = base_report(
0,
0,
vec![],
vec![
suspect("GrownSuspect", 10, 1_000),
suspect("Stable", 5, 500),
],
);
let b = base_report(
0,
0,
vec![],
vec![
suspect("GrownSuspect", 20, 3_000),
suspect("Stable", 5, 500),
suspect("BrandNew", 8, 5_000),
],
);
let s = diff_series(&[a, b]);
assert_eq!(s.grown_suspects.len(), 2);
assert_eq!(s.grown_suspects[0].pretty_class, "BrandNew");
assert!(s.grown_suspects[0].is_new);
assert_eq!(s.grown_suspects[0].retained, vec![0, 5_000]);
assert_eq!(s.grown_suspects[1].pretty_class, "GrownSuspect");
assert!(!s.grown_suspects[1].is_new);
assert_eq!(s.grown_suspects[1].delta_retained, 2_000);
assert!(!s.grown_suspects.iter().any(|s| s.pretty_class == "Stable"));
}
#[test]
fn series_n3_time_series() {
let r1 = named_report(
"dump1.hprof",
10,
1_000,
vec![hist("Climber", 1, 10, 100), hist("OnlyR1", 2, 20, 250)],
vec![suspect("EarlyLeak", 3, 900)],
);
let r2 = named_report(
"dump2.hprof",
20,
2_000,
vec![hist("Climber", 2, 20, 200)],
vec![suspect("EarlyLeak", 3, 900)],
);
let r3 = named_report(
"dump3.hprof",
30,
3_000,
vec![hist("Climber", 3, 30, 300), hist("OnlyR3", 5, 50, 700)],
vec![suspect("LateLeak", 4, 1_500)],
);
let s = diff_series(&[r1, r2, r3]);
assert_eq!(s.labels, vec!["dump1.hprof", "dump2.hprof", "dump3.hprof"]);
assert_eq!(s.total_objects, vec![10, 20, 30]);
assert_eq!(s.total_shallow, vec![1_000, 2_000, 3_000]);
assert_eq!(s.delta_total_objects, 20);
assert_eq!(s.delta_total_shallow, 2_000);
let climber = s
.growth_leaders
.iter()
.find(|c| c.pretty_class == "Climber")
.expect("Climber is a growth leader");
assert_eq!(climber.retained, vec![100, 200, 300]);
assert_eq!(climber.instances, vec![1, 2, 3]);
assert_eq!(climber.delta_retained, 200);
assert_eq!(climber.delta_instances, 2);
assert_eq!(s.new_classes.len(), 1);
assert_eq!(s.new_classes[0].pretty_class, "OnlyR3");
assert_eq!(s.new_classes[0].retained, vec![0, 0, 700]);
assert_eq!(s.removed_classes.len(), 1);
assert_eq!(s.removed_classes[0].pretty_class, "OnlyR1");
assert_eq!(s.removed_classes[0].retained, vec![250, 0, 0]);
let late = s
.grown_suspects
.iter()
.find(|s| s.pretty_class == "LateLeak")
.expect("LateLeak is a grown/new suspect");
assert!(late.is_new);
assert!(!late.is_gone);
assert_eq!(late.retained, vec![0, 0, 1_500]);
assert_eq!(s.gone_suspects.len(), 1);
assert_eq!(s.gone_suspects[0].pretty_class, "EarlyLeak");
assert!(s.gone_suspects[0].is_gone);
assert_eq!(s.gone_suspects[0].retained, vec![900, 900, 0]);
}
}