mod common;
use std::collections::BTreeMap;
use assert_cmd::Command;
use common::ctprof::{cgroup_stats_entry, make_thread, snapshot};
use ktstr::metric_types::{MonotonicCount, MonotonicNs};
fn ktstr() -> Command {
Command::cargo_bin("ktstr").unwrap()
}
#[test]
fn show_default_renders_pcomm_grouping_columns() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
let mut t = make_thread("integration_proc", "worker");
t.run_time_ns = MonotonicNs(5_000_000);
t.nr_wakeups = MonotonicCount(200);
snapshot(vec![t], BTreeMap::new())
.write(&snap_path)
.unwrap();
let assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
])
.assert()
.success();
let out = String::from_utf8_lossy(&assert.get_output().stdout).to_string();
for col in ["pcomm", "threads", "metric", "value"] {
assert!(out.contains(col), "missing column {col}:\n{out}");
}
assert!(
out.contains("integration_proc"),
"missing group key in output:\n{out}",
);
assert!(
out.contains("5.000ms"),
"missing scaled run_time_ns cell:\n{out}",
);
}
#[test]
fn show_cgroup_grouping_renders_scaled_cgroup_stats() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
let mut t = make_thread("worker", "w");
t.cgroup = "/app".to_string();
snapshot(
vec![t],
BTreeMap::from([(
"/app".to_string(),
cgroup_stats_entry(1_500_000, 0, 0, 1024 * 1024 * 1024),
)]),
)
.write(&snap_path)
.unwrap();
let assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--group-by",
"cgroup",
])
.assert()
.success();
let out = String::from_utf8_lossy(&assert.get_output().stdout).to_string();
for col in [
"cgroup",
"cpu_usage_usec",
"nr_throttled",
"throttled_usec",
"memory_current",
] {
assert!(out.contains(col), "missing column {col}:\n{out}");
}
assert!(
out.contains("1.500s"),
"cpu_usage_usec 1_500_000µs must scale to '1.500s', got:\n{out}",
);
assert!(
out.contains("1.000GiB"),
"memory_current 1 GiB must scale to '1.000GiB', got:\n{out}",
);
assert!(
!out.contains("→"),
"show cgroup-stats must not emit a `→` arrow:\n{out}",
);
assert!(
!out.contains("(+0"),
"show cgroup-stats must not carry a `(+0…)` zero-delta tail:\n{out}",
);
}
#[test]
fn show_sort_by_orders_groups_by_metric_descending() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
let mut t_alpha = make_thread("alpha", "alpha-w");
t_alpha.run_time_ns = MonotonicNs(100);
let mut t_bravo = make_thread("bravo", "bravo-w");
t_bravo.run_time_ns = MonotonicNs(500);
let mut t_charlie = make_thread("charlie", "charlie-w");
t_charlie.run_time_ns = MonotonicNs(250);
snapshot(vec![t_alpha, t_bravo, t_charlie], BTreeMap::new())
.write(&snap_path)
.unwrap();
let assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--sort-by",
"run_time_ns",
])
.assert()
.success();
let out = String::from_utf8_lossy(&assert.get_output().stdout).to_string();
let bravo_at = out.find("bravo").expect("bravo must surface in output");
let charlie_at = out.find("charlie").expect("charlie must surface in output");
let alpha_at = out.find("alpha").expect("alpha must surface in output");
assert!(
bravo_at < charlie_at,
"sort_by run_time_ns must place bravo (500) before charlie (250); \
alpha={alpha_at} bravo={bravo_at} charlie={charlie_at}\n{out}",
);
assert!(
charlie_at < alpha_at,
"sort_by run_time_ns must place charlie (250) before alpha (100); \
alpha={alpha_at} bravo={bravo_at} charlie={charlie_at}\n{out}",
);
}
#[test]
fn show_invalid_sort_by_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
snapshot(vec![make_thread("p", "w")], BTreeMap::new())
.write(&snap_path)
.unwrap();
let assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--sort-by",
"not_a_real_metric",
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
stderr.contains("not_a_real_metric"),
"stderr must name the offending metric:\n{stderr}",
);
}
#[test]
fn show_sections_primary_suppresses_derived_subtable() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
let mut t = make_thread("integration_proc", "worker");
t.run_time_ns = MonotonicNs(5_000_000);
t.wait_count = MonotonicCount(4);
t.wait_sum = MonotonicNs(1_000_000);
snapshot(vec![t], BTreeMap::new())
.write(&snap_path)
.unwrap();
let default_assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
])
.assert()
.success();
let default_out = String::from_utf8_lossy(&default_assert.get_output().stdout).to_string();
assert!(
default_out.contains("## Derived metrics"),
"default rendering must include the derived-metrics \
heading; got:\n{default_out}",
);
let filtered_assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--sections",
"primary",
])
.assert()
.success();
let filtered_out = String::from_utf8_lossy(&filtered_assert.get_output().stdout).to_string();
assert!(
!filtered_out.contains("## Derived metrics"),
"--sections primary must suppress the derived-metrics \
heading; got:\n{filtered_out}",
);
assert!(
filtered_out.contains("pcomm"),
"--sections primary must still render the primary \
table; got:\n{filtered_out}",
);
}
#[test]
fn show_sections_derived_suppresses_primary_subtable() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
let mut t = make_thread("integration_proc", "worker");
t.run_time_ns = MonotonicNs(5_000_000);
t.wait_time_ns = MonotonicNs(1_000_000);
t.wait_count = MonotonicCount(4);
t.wait_sum = MonotonicNs(1_000_000);
snapshot(vec![t], BTreeMap::new())
.write(&snap_path)
.unwrap();
let assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--sections",
"derived",
])
.assert()
.success();
let out = String::from_utf8_lossy(&assert.get_output().stdout).to_string();
assert!(
out.contains("## Derived metrics"),
"--sections derived must keep the derived heading; \
got:\n{out}",
);
assert!(
!out.contains("run_time_ns"),
"--sections derived must suppress the primary table \
and its rows — `run_time_ns` is a primary-only \
registry name, so its presence would prove the \
primary table still rendered; got:\n{out}",
);
assert!(
out.contains("cpu_efficiency"),
"--sections derived must keep the derived rows; \
cpu_efficiency should surface (run_time_ns + \
wait_time_ns are both populated): got:\n{out}",
);
}
#[test]
fn show_invalid_sections_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
snapshot(vec![make_thread("p", "w")], BTreeMap::new())
.write(&snap_path)
.unwrap();
let assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--sections",
"not_a_real_section",
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
stderr.contains("not_a_real_section"),
"stderr must name the offending section:\n{stderr}",
);
}
#[test]
fn show_wrap_does_not_change_byte_output_when_not_tty() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
let mut t = make_thread("integration_proc", "worker");
t.run_time_ns = MonotonicNs(5_000_000);
snapshot(vec![t], BTreeMap::new())
.write(&snap_path)
.unwrap();
let no_wrap_out = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
])
.output()
.expect("show without --wrap must execute");
let with_wrap_out = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--wrap",
])
.output()
.expect("show with --wrap must execute");
assert!(
no_wrap_out.status.success(),
"no-wrap show must succeed; stderr:\n{}",
String::from_utf8_lossy(&no_wrap_out.stderr),
);
assert!(
with_wrap_out.status.success(),
"with-wrap show must succeed; stderr:\n{}",
String::from_utf8_lossy(&with_wrap_out.stderr),
);
assert_eq!(
no_wrap_out.stdout, with_wrap_out.stdout,
"--wrap must produce byte-identical output when stdout \
is captured into a pipe (not a tty) — pins the \
awk/grep-friendly contract documented on the flag",
);
}
#[test]
fn show_sections_cgroup_stats_under_pcomm_warns() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
snapshot(vec![make_thread("p", "w")], BTreeMap::new())
.write(&snap_path)
.unwrap();
let assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--sections",
"cgroup-stats",
])
.assert()
.success();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
stderr.contains("cgroup-stats"),
"stderr must name the cgroup-only section the \
operator requested:\n{stderr}",
);
assert!(
stderr.contains("--group-by"),
"stderr must reference --group-by so the operator \
understands the gate:\n{stderr}",
);
}
#[test]
fn show_metrics_filter_keeps_named_row() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
let mut t = make_thread("integration_proc", "worker");
t.run_time_ns = MonotonicNs(5_000_000);
t.wait_sum = MonotonicNs(2_000_000);
t.voluntary_csw = MonotonicCount(42);
t.nr_wakeups = MonotonicCount(100);
snapshot(vec![t], BTreeMap::new())
.write(&snap_path)
.unwrap();
let default_assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
])
.assert()
.success();
let default_out = String::from_utf8_lossy(&default_assert.get_output().stdout).to_string();
assert!(
default_out.contains("run_time_ns"),
"default rendering must surface run_time_ns:\n{default_out}",
);
assert!(
default_out.contains("wait_sum"),
"default rendering must surface wait_sum (proves the \
filter has work to do): \n{default_out}",
);
assert!(
default_out.contains("voluntary_csw"),
"default rendering must surface voluntary_csw:\n{default_out}",
);
let filtered_assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--metrics",
"run_time_ns",
])
.assert()
.success();
let filtered_out = String::from_utf8_lossy(&filtered_assert.get_output().stdout).to_string();
assert!(
filtered_out.contains("run_time_ns"),
"--metrics run_time_ns must keep the named row; \
got:\n{filtered_out}",
);
assert!(
!filtered_out.contains("wait_sum"),
"--metrics run_time_ns must suppress wait_sum; \
got:\n{filtered_out}",
);
assert!(
!filtered_out.contains("voluntary_csw"),
"--metrics run_time_ns must suppress voluntary_csw; \
got:\n{filtered_out}",
);
assert!(
!filtered_out.contains("nr_wakeups"),
"--metrics run_time_ns must suppress nr_wakeups; \
got:\n{filtered_out}",
);
}
#[test]
fn show_invalid_metrics_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let snap_path = tmp.path().join("snap.ctprof.zst");
snapshot(vec![make_thread("p", "w")], BTreeMap::new())
.write(&snap_path)
.unwrap();
let assert = ktstr()
.args([
"ctprof",
"show",
snap_path.to_str().expect("ascii temp path"),
"--metrics",
"not_a_real_metric",
])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
assert!(
stderr.contains("not_a_real_metric"),
"stderr must name the offending metric token:\n{stderr}",
);
}