use std::collections::HashMap;
use super::{DIM, FnEntry, HEADER, Run};
const DELTA_W: usize = 11;
fn truncate_label(label: &str, max_len: usize) -> String {
if label.chars().count() <= max_len {
label.to_string()
} else {
let truncated: String = label.chars().take(max_len).collect();
format!("{truncated}\u{2026}")
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct JsonDiffEntry {
pub name: String,
pub self_ms_a: f64,
pub self_ms_b: f64,
pub delta_ms: f64,
#[serde(default)]
pub delta_pct: Option<f64>,
pub calls_a: u64,
pub calls_b: u64,
pub alloc_count_a: u64,
pub alloc_count_b: u64,
pub alloc_bytes_a: u64,
pub alloc_bytes_b: u64,
#[serde(default)]
pub cpu_self_ms_a: Option<f64>,
#[serde(default)]
pub cpu_self_ms_b: Option<f64>,
}
struct DiffSetup<'a> {
a_map: HashMap<&'a str, &'a FnEntry>,
b_map: HashMap<&'a str, &'a FnEntry>,
names: Vec<&'a str>,
total_count: usize,
after_filter_count: usize,
}
fn prepare_diff<'a>(a: &'a Run, b: &'a Run, show_all: bool, limit: Option<usize>) -> DiffSetup<'a> {
let a_map: HashMap<&str, &FnEntry> = a.functions.iter().map(|f| (f.name.as_str(), f)).collect();
let b_map: HashMap<&str, &FnEntry> = b.functions.iter().map(|f| (f.name.as_str(), f)).collect();
let mut names: Vec<&str> = a_map.keys().chain(b_map.keys()).copied().collect();
names.sort_unstable();
names.dedup();
names.sort_by(|na, nb| {
let delta_a = (b_map.get(na).map_or(0.0, |e| e.self_ms)
- a_map.get(na).map_or(0.0, |e| e.self_ms))
.abs();
let delta_b = (b_map.get(nb).map_or(0.0, |e| e.self_ms)
- a_map.get(nb).map_or(0.0, |e| e.self_ms))
.abs();
delta_b
.partial_cmp(&delta_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
let total_count = names.len();
if !show_all {
names.retain(|name| {
let calls_a = a_map.get(name).map_or(0, |e| e.calls);
let calls_b = b_map.get(name).map_or(0, |e| e.calls);
calls_a > 0 || calls_b > 0
});
}
let after_filter_count = names.len();
if let Some(n) = limit {
names.truncate(n);
}
DiffSetup {
a_map,
b_map,
names,
total_count,
after_filter_count,
}
}
pub fn diff_runs_json(a: &Run, b: &Run, show_all: bool, limit: Option<usize>) -> String {
let DiffSetup {
a_map,
b_map,
names,
..
} = prepare_diff(a, b, show_all, limit);
let json_entries: Vec<JsonDiffEntry> = names
.iter()
.map(|name| {
let self_a = a_map.get(name).map_or(0.0, |e| e.self_ms);
let self_b = b_map.get(name).map_or(0.0, |e| e.self_ms);
let delta = self_b - self_a;
let delta_pct = if self_a > 0.0 {
Some(delta / self_a * 100.0)
} else if delta == 0.0 {
Some(0.0)
} else {
None
};
JsonDiffEntry {
name: name.to_string(),
self_ms_a: self_a,
self_ms_b: self_b,
delta_ms: delta,
delta_pct,
calls_a: a_map.get(name).map_or(0, |e| e.calls),
calls_b: b_map.get(name).map_or(0, |e| e.calls),
alloc_count_a: a_map.get(name).map_or(0, |e| e.alloc_count),
alloc_count_b: b_map.get(name).map_or(0, |e| e.alloc_count),
alloc_bytes_a: a_map.get(name).map_or(0, |e| e.alloc_bytes),
alloc_bytes_b: b_map.get(name).map_or(0, |e| e.alloc_bytes),
cpu_self_ms_a: a_map.get(name).and_then(|e| e.cpu_self_ms),
cpu_self_ms_b: b_map.get(name).and_then(|e| e.cpu_self_ms),
}
})
.collect();
serde_json::to_string_pretty(&json_entries).expect("JSON serialization should not fail")
}
pub fn diff_runs(
a: &Run,
b: &Run,
label_a: &str,
label_b: &str,
show_all: bool,
limit: Option<usize>,
) -> String {
if a.source_format != b.source_format {
eprintln!("warning: comparing runs with different source formats (JSON vs NDJSON)");
}
let DiffSetup {
a_map,
b_map,
names,
total_count,
after_filter_count,
} = prepare_diff(a, b, show_all, limit);
let has_allocs = a.functions.iter().any(|f| f.alloc_count > 0)
|| b.functions.iter().any(|f| f.alloc_count > 0);
let has_cpu = a.functions.iter().any(|f| f.cpu_self_ms.is_some())
|| b.functions.iter().any(|f| f.cpu_self_ms.is_some());
let label_a = truncate_label(label_a, 20);
let label_b = truncate_label(label_b, 20);
let col_a = label_a.chars().count().max(10);
let col_b = label_b.chars().count().max(10);
let cpu_label_a = format!("CPU.{label_a}");
let cpu_label_b = format!("CPU.{label_b}");
let cpu_col_a = cpu_label_a.chars().count().max(10);
let cpu_col_b = cpu_label_b.chars().count().max(10);
let mut out = String::new();
{
let mut header = format!(
"{:<40} {:>col_a$} {:>col_b$} {:>DELTA_W$}",
"Function", label_a, label_b, "Delta"
);
if has_cpu {
header.push_str(&format!(
" {cpu_label_a:>cpu_col_a$} {cpu_label_b:>cpu_col_b$}"
));
}
if has_allocs {
header.push_str(&format!(" {:>10} {:>10}", "Allocs", "A.Delta"));
}
let width = header.len();
out.push_str(&format!("{HEADER}{header}{HEADER:#}\n"));
out.push_str(&format!("{DIM}{}{DIM:#}\n", "-".repeat(width)));
}
for name in &names {
let before = a_map.get(name).map_or(0.0, |e| e.self_ms);
let after = b_map.get(name).map_or(0.0, |e| e.self_ms);
let delta = after - before;
let delta_val = format!("{delta:+.2}ms");
out.push_str(&format!(
"{name:<40} {before:>w_a$.2}ms {after:>w_b$.2}ms {delta_val:>DELTA_W$}",
w_a = col_a - 2,
w_b = col_b - 2,
));
if has_cpu {
let cpu_before = a_map.get(name).and_then(|e| e.cpu_self_ms);
let cpu_after = b_map.get(name).and_then(|e| e.cpu_self_ms);
let fmt_cpu = |v: Option<f64>, col_w: usize| match v {
Some(ms) => format!("{ms:>w$.2}ms", w = col_w - 2),
None => format!("{:>col_w$}", "-"),
};
out.push_str(&format!(
" {} {}",
fmt_cpu(cpu_before, cpu_col_a),
fmt_cpu(cpu_after, cpu_col_b)
));
}
if has_allocs {
let allocs_after = b_map.get(name).map_or(0u64, |e| e.alloc_count);
let allocs_before = a_map.get(name).map_or(0u64, |e| e.alloc_count);
let allocs_delta = allocs_after as i128 - allocs_before as i128;
out.push_str(&format!(" {allocs_after:>10} {allocs_delta:>+10}"));
}
out.push('\n');
}
let zero_call_hidden = total_count - after_filter_count;
let truncated = after_filter_count - names.len();
let total_hidden = zero_call_hidden + truncated;
if total_hidden > 0 {
let label = if total_hidden == 1 {
"function"
} else {
"functions"
};
let hint = if truncated > 0 {
"use --top N or --all to show"
} else {
"use --all to show"
};
out.push_str(&format!(
"{DIM}\n{total_hidden} {label} hidden; {hint}\n{DIM:#}"
));
}
out
}
#[cfg(test)]
mod tests {
use super::super::load::load_ndjson;
use super::super::tag::load_tagged_run;
use super::super::{FnEntry, Run, RunFormat};
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn diff_shows_delta() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "walk".into(),
calls: 3,
total_ms: Some(12.0),
self_ms: 10.0,
..Default::default()
}],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "walk".into(),
calls: 3,
total_ms: Some(9.0),
self_ms: 8.0,
..Default::default()
}],
};
let diff = diff_runs(&a, &b, "Before", "After", true, None);
assert!(diff.contains("walk"), "should mention walk");
assert!(diff.contains("-2.00"), "should show negative delta: {diff}");
}
#[test]
fn diff_shows_alloc_deltas() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "walk".into(),
calls: 3,
total_ms: Some(12.0),
self_ms: 10.0,
cpu_self_ms: None,
alloc_count: 100,
alloc_bytes: 8192,
..Default::default()
}],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "walk".into(),
calls: 3,
total_ms: Some(9.0),
self_ms: 8.0,
cpu_self_ms: None,
alloc_count: 50,
alloc_bytes: 4096,
..Default::default()
}],
};
let diff = diff_runs(&a, &b, "Before", "After", true, None);
assert!(diff.contains("Allocs"), "should have Allocs column header");
assert!(
diff.contains("-50"),
"should show alloc count delta: {diff}"
);
}
#[test]
fn diff_alloc_count_does_not_wrap_above_i64_max() {
let large_count: u64 = i64::MAX as u64 + 1_000;
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "alloc_heavy".into(),
calls: 1,
total_ms: Some(1.0),
self_ms: 1.0,
cpu_self_ms: None,
alloc_count: large_count,
alloc_bytes: 0,
..Default::default()
}],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "alloc_heavy".into(),
calls: 1,
total_ms: Some(1.0),
self_ms: 1.0,
cpu_self_ms: None,
alloc_count: 0,
alloc_bytes: 0,
..Default::default()
}],
};
let diff = diff_runs(&a, &b, "Before", "After", true, None);
let line = diff.lines().find(|l| l.contains("alloc_heavy")).unwrap();
let fields: Vec<&str> = line.split_whitespace().collect();
let delta_str = fields.last().unwrap();
assert!(
delta_str.starts_with('-'),
"alloc delta should be negative (decrease from {large_count} to 0), got: {delta_str}"
);
}
#[test]
fn diff_shows_cpu_columns_when_present() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
cpu_self_ms: Some(8.0),
..Default::default()
}],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(12.0),
self_ms: 12.0,
cpu_self_ms: Some(10.0),
..Default::default()
}],
};
let diff = diff_runs(&a, &b, "Before", "After", true, None);
assert!(
diff.contains("CPU.Before"),
"should have CPU.Before column. Got:\n{diff}"
);
assert!(
diff.contains("CPU.After"),
"should have CPU.After column. Got:\n{diff}"
);
assert!(
diff.contains("8.00"),
"should show before CPU. Got:\n{diff}"
);
assert!(
diff.contains("10.00"),
"should show after CPU. Got:\n{diff}"
);
}
#[test]
fn diff_mixed_cpu_one_with_one_without() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
cpu_self_ms: Some(8.0),
..Default::default()
}],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(12.0),
self_ms: 12.0,
..Default::default()
}],
};
let diff = diff_runs(&a, &b, "Before", "After", true, None);
assert!(
diff.contains("CPU.Before"),
"should show CPU columns when either run has CPU data. Got:\n{diff}"
);
assert!(
diff.contains("8.00"),
"should show A's CPU value. Got:\n{diff}"
);
let data_line = diff.lines().find(|l| l.contains("work")).unwrap();
assert!(
data_line.ends_with('-'),
"missing CPU should render as dash, not 0.00ms. Got:\n{data_line}"
);
}
#[test]
fn diff_neither_has_cpu_hides_cpu_columns() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
..Default::default()
}],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(12.0),
self_ms: 12.0,
..Default::default()
}],
};
let diff = diff_runs(&a, &b, "Before", "After", true, None);
assert!(
!diff.contains("CPU"),
"should not show CPU columns when neither run has CPU data. Got:\n{diff}"
);
}
#[test]
fn diff_uses_custom_labels_in_headers() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
..Default::default()
}],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(12.0),
self_ms: 12.0,
..Default::default()
}],
};
let diff = diff_runs(&a, &b, "baseline", "optimized", true, None);
assert!(
diff.contains("baseline"),
"should use label_a as column header. Got:\n{diff}"
);
assert!(
diff.contains("optimized"),
"should use label_b as column header. Got:\n{diff}"
);
assert!(
!diff.contains("Before"),
"should not contain hardcoded 'Before'. Got:\n{diff}"
);
assert!(
!diff.contains("After"),
"should not contain hardcoded 'After'. Got:\n{diff}"
);
}
#[test]
fn diff_custom_labels_in_cpu_headers() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
cpu_self_ms: Some(8.0),
..Default::default()
}],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(12.0),
self_ms: 12.0,
cpu_self_ms: Some(10.0),
..Default::default()
}],
};
let diff = diff_runs(&a, &b, "v1", "v2", true, None);
assert!(
diff.contains("CPU.v1"),
"should use CPU.label_a as CPU column header. Got:\n{diff}"
);
assert!(
diff.contains("CPU.v2"),
"should use CPU.label_b as CPU column header. Got:\n{diff}"
);
}
#[test]
fn diff_truncates_long_labels() {
let entry = || FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
..Default::default()
};
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![entry()],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![entry()],
};
let long_label = "my-really-long-tag-name-that-goes-on";
let diff = diff_runs(&a, &b, long_label, "short", true, None);
assert!(
diff.contains("my-really-long-tag-n\u{2026}"),
"should truncate label > 20 chars with ellipsis. Got:\n{diff}"
);
assert!(
!diff.contains(long_label),
"should not contain the full long label. Got:\n{diff}"
);
}
#[test]
fn diff_label_column_width_expands_for_label() {
let entry = || FnEntry {
name: "work".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
..Default::default()
};
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![entry()],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![entry()],
};
let diff = diff_runs(&a, &b, "before", "after-refactor", true, None);
assert!(
diff.contains("after-refactor"),
"should expand column to fit label. Got:\n{diff}"
);
}
#[test]
fn ndjson_diff_does_not_produce_misleading_total_ms() {
let dir = TempDir::new().unwrap();
let ndjson_a = concat!(
r#"{"type":"header","run_id":"diff_a","timestamp_ms":1000,"bias_ns":0,"names":{"0":"work"}}"#,
"\n",
r#"{"span_id":1,"parent_span_id":0,"name_id":0,"start_ns":0,"end_ns":5000000,"thread_id":1,"cpu_start_ns":0,"cpu_end_ns":0,"alloc_count":0,"alloc_bytes":0,"free_count":0,"free_bytes":0}"#,
"\n",
r#"{"type":"trailer","bias_ns":0,"names":{"0":"work"}}"#,
"\n",
);
let ndjson_b = concat!(
r#"{"type":"header","run_id":"diff_b","timestamp_ms":2000,"bias_ns":0,"names":{"0":"work"}}"#,
"\n",
r#"{"span_id":1,"parent_span_id":0,"name_id":0,"start_ns":0,"end_ns":8000000,"thread_id":1,"cpu_start_ns":0,"cpu_end_ns":0,"alloc_count":0,"alloc_bytes":0,"free_count":0,"free_bytes":0}"#,
"\n",
r#"{"type":"trailer","bias_ns":0,"names":{"0":"work"}}"#,
"\n",
);
fs::write(dir.path().join("1000.ndjson"), ndjson_a).unwrap();
fs::write(dir.path().join("2000.ndjson"), ndjson_b).unwrap();
let (run_a, _) = load_ndjson(&dir.path().join("1000.ndjson"), false).unwrap();
let (run_b, _) = load_ndjson(&dir.path().join("2000.ndjson"), false).unwrap();
assert!(run_a.functions[0].total_ms.is_none());
assert!(run_b.functions[0].total_ms.is_none());
let diff = diff_runs(&run_a, &run_b, "before", "after", true, None);
assert!(diff.contains("work"), "diff should contain function name");
assert!(
diff.contains("+3.00ms"),
"diff should show +3.00ms self_ms delta"
);
}
#[test]
fn diff_runs_json_computes_delta() {
let run_a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 10,
self_ms: 20.0,
..Default::default()
}],
};
let run_b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 12,
self_ms: 25.0,
..Default::default()
}],
};
let json = diff_runs_json(&run_a, &run_b, true, None);
let entries: Vec<JsonDiffEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "work");
assert!((entries[0].self_ms_a - 20.0).abs() < f64::EPSILON);
assert!((entries[0].self_ms_b - 25.0).abs() < f64::EPSILON);
assert!((entries[0].delta_ms - 5.0).abs() < f64::EPSILON);
assert!((entries[0].delta_pct.unwrap() - 25.0).abs() < f64::EPSILON);
assert_eq!(entries[0].calls_a, 10);
assert_eq!(entries[0].calls_b, 12);
}
#[test]
fn diff_runs_json_new_function_has_null_pct() {
let run_a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![],
};
let run_b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "new_fn".into(),
calls: 1,
self_ms: 5.0,
..Default::default()
}],
};
let json = diff_runs_json(&run_a, &run_b, true, None);
let raw: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
assert!(
raw[0].get("delta_pct").unwrap().is_null(),
"delta_pct should serialize as null, not be omitted. Got:\n{json}"
);
let entries: Vec<JsonDiffEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "new_fn");
assert!((entries[0].self_ms_a).abs() < f64::EPSILON);
assert!(entries[0].delta_pct.is_none());
}
#[test]
fn diff_runs_json_zero_zero_has_zero_pct() {
let run_a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "idle".into(),
calls: 5,
self_ms: 0.0,
..Default::default()
}],
};
let run_b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "idle".into(),
calls: 5,
self_ms: 0.0,
..Default::default()
}],
};
let json = diff_runs_json(&run_a, &run_b, true, None);
let entries: Vec<JsonDiffEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "idle");
assert_eq!(
entries[0].delta_pct,
Some(0.0),
"0/0 case should produce Some(0.0), not None"
);
}
#[test]
fn diff_tagged_ndjson_runs() {
let dir = TempDir::new().unwrap();
let runs_dir = dir.path().join("runs");
let tags_dir = dir.path().join("tags");
fs::create_dir_all(&runs_dir).unwrap();
fs::create_dir_all(&tags_dir).unwrap();
let ndjson_a = concat!(
r#"{"type":"header","run_id":"aaa_1000","timestamp_ms":1000,"bias_ns":0,"names":{"0":"compute"}}"#,
"\n",
r#"{"span_id":1,"parent_span_id":0,"name_id":0,"start_ns":0,"end_ns":2000000,"thread_id":1,"cpu_start_ns":0,"cpu_end_ns":0,"alloc_count":0,"alloc_bytes":0,"free_count":0,"free_bytes":0}"#,
"\n",
r#"{"span_id":2,"parent_span_id":0,"name_id":0,"start_ns":3000000,"end_ns":6000000,"thread_id":1,"cpu_start_ns":0,"cpu_end_ns":0,"alloc_count":0,"alloc_bytes":0,"free_count":0,"free_bytes":0}"#,
"\n",
r#"{"type":"trailer","bias_ns":0,"names":{"0":"compute"}}"#,
"\n",
);
let ndjson_b = concat!(
r#"{"type":"header","run_id":"bbb_2000","timestamp_ms":2000,"bias_ns":0,"names":{"0":"compute"}}"#,
"\n",
r#"{"span_id":1,"parent_span_id":0,"name_id":0,"start_ns":0,"end_ns":4000000,"thread_id":1,"cpu_start_ns":0,"cpu_end_ns":0,"alloc_count":0,"alloc_bytes":0,"free_count":0,"free_bytes":0}"#,
"\n",
r#"{"span_id":2,"parent_span_id":0,"name_id":0,"start_ns":5000000,"end_ns":9000000,"thread_id":1,"cpu_start_ns":0,"cpu_end_ns":0,"alloc_count":0,"alloc_bytes":0,"free_count":0,"free_bytes":0}"#,
"\n",
r#"{"type":"trailer","bias_ns":0,"names":{"0":"compute"}}"#,
"\n",
);
fs::write(runs_dir.join("1000.ndjson"), ndjson_a).unwrap();
fs::write(runs_dir.join("2000.ndjson"), ndjson_b).unwrap();
fs::write(tags_dir.join("before"), "aaa_1000").unwrap();
fs::write(tags_dir.join("after"), "bbb_2000").unwrap();
let run_a = load_tagged_run(&tags_dir, &runs_dir, "before").unwrap();
let run_b = load_tagged_run(&tags_dir, &runs_dir, "after").unwrap();
let compute_a = run_a
.functions
.iter()
.find(|f| f.name == "compute")
.unwrap();
assert_eq!(compute_a.calls, 2, "run A should have 2 calls from 2 spans");
assert!(
(compute_a.self_ms - 5.0).abs() < 0.01,
"run A self_ms should be ~5.0ms (from NDJSON), got {}",
compute_a.self_ms
);
let compute_b = run_b
.functions
.iter()
.find(|f| f.name == "compute")
.unwrap();
assert_eq!(compute_b.calls, 2, "run B should have 2 calls from 2 spans");
assert!(
(compute_b.self_ms - 8.0).abs() < 0.01,
"run B self_ms should be ~8.0ms (from NDJSON), got {}",
compute_b.self_ms
);
let diff = diff_runs(&run_a, &run_b, "before", "after", true, None);
assert!(
diff.contains("compute"),
"diff should contain function name: {diff}"
);
assert!(
diff.contains("+3.00"),
"diff should show +3.00ms delta (8.0 - 5.0): {diff}"
);
}
#[test]
fn diff_runs_json_includes_alloc_and_cpu_fields() {
let run_a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 5,
total_ms: Some(20.0),
self_ms: 15.0,
cpu_self_ms: Some(12.0),
alloc_count: 100,
alloc_bytes: 8192,
..Default::default()
}],
};
let run_b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![FnEntry {
name: "work".into(),
calls: 7,
total_ms: Some(25.0),
self_ms: 18.0,
cpu_self_ms: Some(14.0),
alloc_count: 200,
alloc_bytes: 16384,
..Default::default()
}],
};
let json = diff_runs_json(&run_a, &run_b, true, None);
let entries: Vec<JsonDiffEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.alloc_count_a, 100);
assert_eq!(e.alloc_count_b, 200);
assert_eq!(e.alloc_bytes_a, 8192);
assert_eq!(e.alloc_bytes_b, 16384);
assert_eq!(e.cpu_self_ms_a, Some(12.0));
assert_eq!(e.cpu_self_ms_b, Some(14.0));
let raw: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
let obj = &raw[0];
assert!(obj.get("alloc_count_a").is_some(), "missing alloc_count_a");
assert!(obj.get("alloc_count_b").is_some(), "missing alloc_count_b");
assert!(obj.get("alloc_bytes_a").is_some(), "missing alloc_bytes_a");
assert!(obj.get("alloc_bytes_b").is_some(), "missing alloc_bytes_b");
assert!(obj.get("cpu_self_ms_a").is_some(), "missing cpu_self_ms_a");
assert!(obj.get("cpu_self_ms_b").is_some(), "missing cpu_self_ms_b");
}
#[test]
fn diff_hides_zero_call_entries_by_default() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![
FnEntry {
name: "active".into(),
calls: 5,
total_ms: Some(10.0),
self_ms: 8.0,
..Default::default()
},
FnEntry {
name: "unused".into(),
..Default::default()
},
],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![
FnEntry {
name: "active".into(),
calls: 7,
total_ms: Some(12.0),
self_ms: 9.0,
..Default::default()
},
FnEntry {
name: "unused".into(),
..Default::default()
},
],
};
let diff = diff_runs(&a, &b, "Before", "After", false, None);
assert!(diff.contains("active"), "should show active function");
assert!(
!diff.contains("unused"),
"should hide zero-call entries when show_all=false. Got:\n{diff}"
);
}
#[test]
fn diff_limit_truncates_output() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![
FnEntry {
name: "fn_a".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
..Default::default()
},
FnEntry {
name: "fn_b".into(),
calls: 1,
total_ms: Some(5.0),
self_ms: 5.0,
..Default::default()
},
FnEntry {
name: "fn_c".into(),
calls: 1,
total_ms: Some(2.0),
self_ms: 2.0,
..Default::default()
},
],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![
FnEntry {
name: "fn_a".into(),
calls: 2,
total_ms: Some(20.0),
self_ms: 20.0,
..Default::default()
},
FnEntry {
name: "fn_b".into(),
calls: 2,
total_ms: Some(8.0),
self_ms: 8.0,
..Default::default()
},
FnEntry {
name: "fn_c".into(),
calls: 2,
total_ms: Some(3.0),
self_ms: 3.0,
..Default::default()
},
],
};
let diff = diff_runs(&a, &b, "Before", "After", true, Some(2));
assert!(
diff.contains("1 function hidden"),
"should show truncation footer. Got:\n{diff}"
);
}
#[test]
fn diff_json_limit_truncates() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![
FnEntry {
name: "fn_a".into(),
calls: 1,
total_ms: Some(10.0),
self_ms: 10.0,
..Default::default()
},
FnEntry {
name: "fn_b".into(),
calls: 1,
total_ms: Some(5.0),
self_ms: 5.0,
..Default::default()
},
FnEntry {
name: "fn_c".into(),
calls: 1,
total_ms: Some(2.0),
self_ms: 2.0,
..Default::default()
},
],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![
FnEntry {
name: "fn_a".into(),
calls: 2,
total_ms: Some(20.0),
self_ms: 20.0,
..Default::default()
},
FnEntry {
name: "fn_b".into(),
calls: 2,
total_ms: Some(8.0),
self_ms: 8.0,
..Default::default()
},
FnEntry {
name: "fn_c".into(),
calls: 2,
total_ms: Some(3.0),
self_ms: 3.0,
..Default::default()
},
],
};
let json = diff_runs_json(&a, &b, true, Some(2));
let entries: Vec<JsonDiffEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(entries.len(), 2, "limit=2 should produce 2 diff entries");
}
#[test]
fn diff_json_hides_zero_call() {
let a = Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![
FnEntry {
name: "active".into(),
calls: 3,
total_ms: Some(5.0),
self_ms: 4.0,
..Default::default()
},
FnEntry {
name: "unused".into(),
..Default::default()
},
],
};
let b = Run {
run_id: None,
timestamp_ms: 2000,
source_format: RunFormat::default(),
functions: vec![
FnEntry {
name: "active".into(),
calls: 5,
total_ms: Some(7.0),
self_ms: 6.0,
..Default::default()
},
FnEntry {
name: "unused".into(),
..Default::default()
},
],
};
let json = diff_runs_json(&a, &b, false, None);
let entries: Vec<JsonDiffEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(entries.len(), 1, "should hide zero-call entries");
assert_eq!(entries[0].name, "active");
}
#[test]
fn diff_columns_aligned() {
use crate::report::test_util::assert_aligned;
fn run_with(entry: FnEntry) -> Run {
Run {
run_id: None,
timestamp_ms: 1000,
source_format: RunFormat::default(),
functions: vec![entry],
}
}
let a = run_with(FnEntry {
name: "work".into(),
calls: 1,
self_ms: 10.0,
..Default::default()
});
let b = run_with(FnEntry {
name: "work".into(),
calls: 1,
self_ms: 8.0,
..Default::default()
});
assert_aligned(&diff_runs(&a, &b, "before", "after", false, None), "base");
let a = run_with(FnEntry {
name: "work".into(),
calls: 1,
self_ms: 10.0,
cpu_self_ms: Some(9.0),
..Default::default()
});
let b = run_with(FnEntry {
name: "work".into(),
calls: 1,
self_ms: 8.0,
cpu_self_ms: Some(7.0),
..Default::default()
});
assert_aligned(&diff_runs(&a, &b, "before", "after", false, None), "cpu");
let a = run_with(FnEntry {
name: "work".into(),
calls: 1,
self_ms: 10.0,
alloc_count: 100,
alloc_bytes: 8192,
..Default::default()
});
let b = run_with(FnEntry {
name: "work".into(),
calls: 1,
self_ms: 8.0,
alloc_count: 50,
alloc_bytes: 4096,
..Default::default()
});
assert_aligned(&diff_runs(&a, &b, "before", "after", false, None), "alloc");
let a = run_with(FnEntry {
name: "work".into(),
calls: 1,
self_ms: 10.0,
cpu_self_ms: Some(9.0),
alloc_count: 100,
alloc_bytes: 8192,
..Default::default()
});
let b = run_with(FnEntry {
name: "work".into(),
calls: 1,
self_ms: 8.0,
cpu_self_ms: Some(7.0),
alloc_count: 50,
alloc_bytes: 4096,
..Default::default()
});
assert_aligned(
&diff_runs(&a, &b, "before", "after", false, None),
"cpu+alloc",
);
}
}