ktstr/ctprof_compare/render.rs
1//! Per-cell and per-row rendering helpers for the compare output.
2//!
3//! This module is the formatting layer between
4//! [`crate::ctprof_compare::CtprofDiff`] and
5//! [`comfy_table::Table`]: every cell that the diff emitter
6//! writes flows through one of the helpers here. Splits cleanly
7//! into three responsibilities:
8//!
9//! - **Row builders** — [`render_diff_row_cells`] /
10//! [`render_derived_row_cells`] take a single [`DiffRow`] /
11//! [`DerivedRow`] plus the resolved [`Column`] set and emit
12//! the column-by-column `Vec<String>` that the table adapter
13//! consumes.
14//! - **Cell formatters** — [`format_arrow_cell`] renders a
15//! `baseline → candidate` arrow pair (the delta renders in a
16//! separate column), while [`cgroup_cell`] and
17//! [`format_psi_avg_cell`] fuse a `(delta)` parenthetical into
18//! `baseline → candidate (delta)` for the various units (raw
19//! `Aggregated`, raw `u64` cgroup counters, centi-percent PSI
20//! averages).
21//! - **Color / chrome** — [`color_diff_cell`],
22//! [`color_derived_cells`], [`colored_header`] /
23//! [`colored_header_with_sort`] wrap rendered strings in
24//! [`comfy_table::Cell`]s with the foreground color encoding
25//! delta direction (yellow for a positive delta, magenta for
26//! a negative delta), uptime
27//! gradient (green / yellow / red), or the cyan header / blue
28//! derived-row palette.
29//!
30//! All cell formatters delegate scalar formatting to
31//! [`super::scale`] (auto-scale ladders) and metric-name lookup
32//! to [`super::metrics`]; the comfy-table builder lives in
33//! [`super::runner::DisplayOptions`].
34
35use super::aggregate::Aggregated;
36use super::columns::Column;
37use super::diff_types::{DerivedRow, DiffRow};
38use super::scale::{
39 ScaleLadder, format_delta_cell, format_derived_delta_cell, format_derived_value_cell,
40 format_scaled_u64, format_value_cell,
41};
42use super::{CTPROF_METRICS, metric_display_name, metric_tags};
43use crate::ctprof::{Psi, PsiHalf, PsiResource};
44
45/// Format the arrow cell `<baseline> -> <candidate>` for
46/// primary diff rows. The `delta` argument is currently unused
47/// (discarded via `let _ = delta;`) — the delta renders in the
48/// separate Delta column paired with Arrow under
49/// `DisplayFormat::Arrow`. Unlike [`cgroup_cell`], no `(delta)`
50/// parenthetical is fused into the cell.
51pub(super) fn format_arrow_cell(
52 baseline: &Aggregated,
53 candidate: &Aggregated,
54 delta: Option<f64>,
55 ladder: ScaleLadder,
56) -> String {
57 let baseline_cell = format_value_cell(baseline, ladder);
58 let candidate_cell = format_value_cell(candidate, ladder);
59 let _ = delta;
60 format!("{baseline_cell} \u{2192} {candidate_cell}")
61}
62
63/// Format the arrow cell for derived rows. Same shape as
64/// [`format_arrow_cell`] but pulls from typed
65/// [`super::DerivedValue`]s and routes through the derived
66/// formatters so ratios / ns / B units pick up their
67/// auto-scale ladders.
68pub(super) fn format_arrow_cell_derived(row: &DerivedRow) -> String {
69 let baseline_cell = match row.baseline {
70 Some(v) => format_derived_value_cell(v, row.metric_ladder, row.is_ratio),
71 None => "-".to_string(),
72 };
73 let candidate_cell = match row.candidate {
74 Some(v) => format_derived_value_cell(v, row.metric_ladder, row.is_ratio),
75 None => "-".to_string(),
76 };
77 format!("{baseline_cell} \u{2192} {candidate_cell}")
78}
79
80/// Helper: shared threads-cell rendering — the `N` form when
81/// counts match across snapshots, `A->B` arrow form otherwise.
82pub(super) fn render_threads_cell(a: usize, b: usize) -> String {
83 if a == b {
84 a.to_string()
85 } else {
86 format!("{}\u{2192}{}", a, b)
87 }
88}
89
90/// Render a [`DiffRow`] into the column-by-column cell vector
91/// per the resolved [`Column`] set. Caller emits the resulting
92/// `Vec<String>` straight into a comfy_table row.
93pub(super) fn render_diff_row_cells(row: &DiffRow, columns: &[Column]) -> Vec<String> {
94 let metric_def = CTPROF_METRICS
95 .iter()
96 .find(|m| m.name == row.metric_name)
97 .expect("metric_name comes from CTPROF_METRICS via build_row");
98 let metric_cell = metric_display_name(metric_def).to_string();
99 let mut cells = Vec::with_capacity(columns.len());
100 for col in columns {
101 let cell = match col {
102 Column::Group => row.display_key.clone(),
103 Column::Threads => render_threads_cell(row.thread_count_a, row.thread_count_b),
104 Column::Metric => metric_cell.clone(),
105 Column::Baseline => format_value_cell(&row.baseline, row.metric_ladder),
106 Column::Candidate => format_value_cell(&row.candidate, row.metric_ladder),
107 Column::Delta => match row.delta {
108 Some(d) => format_delta_cell(d, row.metric_ladder),
109 None => match (&row.baseline, &row.candidate) {
110 (Aggregated::Mode { .. }, Aggregated::Mode { .. }) => {
111 if row.baseline.mode_value() == row.candidate.mode_value() {
112 "same".to_string()
113 } else {
114 "differs".to_string()
115 }
116 }
117 _ => "-".to_string(),
118 },
119 },
120 Column::Pct => match row.delta_pct {
121 Some(p) => format!("{:+.1}%", p * 100.0),
122 None => "-".to_string(),
123 },
124 Column::Arrow => {
125 format_arrow_cell(&row.baseline, &row.candidate, row.delta, row.metric_ladder)
126 }
127 // Show-only columns. The compare-side parse_columns
128 // gate rejects Value at CLI parse time, so reaching
129 // this arm requires constructing a column set
130 // directly through the Rust API. Surface a `-`
131 // rather than panic.
132 Column::Value => "-".to_string(),
133 Column::Tags => metric_tags(metric_def),
134 Column::Uptime => match row.uptime_pct {
135 Some(pct) => format!("{pct:.0}%"),
136 None => "-".to_string(),
137 },
138 Column::SortBy => row.sort_by_cell.clone().unwrap_or_else(|| "-".to_string()),
139 };
140 cells.push(cell);
141 }
142 cells
143}
144
145/// Color a diff-table cell based on its column type, the row's
146/// raw delta (sign of color), and the row's delta_pct (fraction
147/// for the bold threshold). Delta/% cells: yellow for positive
148/// (increase), magenta for negative (decrease). Uptime:
149/// green/yellow/red gradient. Other columns: default.
150///
151/// `delta` carries the raw metric delta — used only for color
152/// sign (positive vs negative). `delta_pct` carries the
153/// fractional delta (Δ / baseline) — used as the bold threshold
154/// on the Pct column. Without the split, the bold check
155/// |raw_delta| > 0.5 fired on every non-zero ns/byte change
156/// (ns and bytes are large; 0.5 trivially exceeded).
157pub fn color_diff_cell(
158 text: String,
159 col: Column,
160 delta: Option<f64>,
161 delta_pct: Option<f64>,
162 uptime_pct: Option<f64>,
163 sort_by_delta: Option<f64>,
164) -> comfy_table::Cell {
165 use comfy_table::{Attribute, Color};
166 match col {
167 Column::Pct => {
168 let color = match delta {
169 Some(d) if d > 0.0 => Color::Yellow,
170 Some(d) if d < 0.0 => Color::Magenta,
171 _ => Color::White,
172 };
173 let mut cell = comfy_table::Cell::new(text).fg(color);
174 if matches!(delta_pct, Some(p) if p.abs() > 0.5) {
175 cell = cell.add_attribute(Attribute::Bold);
176 }
177 cell
178 }
179 Column::Delta => {
180 let color = match delta {
181 Some(d) if d > 0.0 => Color::Yellow,
182 Some(d) if d < 0.0 => Color::Magenta,
183 _ => Color::White,
184 };
185 comfy_table::Cell::new(text).fg(color)
186 }
187 Column::Uptime => {
188 let color = match uptime_pct {
189 Some(p) if p >= 75.0 => Color::Green,
190 Some(p) if p >= 50.0 => Color::Yellow,
191 Some(_) => Color::Red,
192 None => Color::White,
193 };
194 let mut cell = comfy_table::Cell::new(text).fg(color);
195 if matches!(uptime_pct, Some(p) if p < 50.0) {
196 cell = cell.add_attribute(Attribute::Bold);
197 }
198 cell
199 }
200 Column::SortBy => {
201 let color = match sort_by_delta {
202 Some(d) if d > 0.0 => Color::Yellow,
203 Some(d) if d < 0.0 => Color::Magenta,
204 _ => Color::Cyan,
205 };
206 comfy_table::Cell::new(text).fg(color)
207 }
208 _ => comfy_table::Cell::new(text),
209 }
210}
211
212/// Extract the parent directory and leaf segment of a cgroup path.
213/// `/system.slice/foo.service` → (`/system.slice`, `foo.service`).
214/// `/` → (`/`, ``) (empty leaf). Empty → (``, ``).
215pub(super) fn cgroup_parent_leaf(path: &str) -> (&str, &str) {
216 match path.rfind('/') {
217 Some(0) => ("/", &path[1..]),
218 Some(i) => (&path[..i], &path[i + 1..]),
219 None => ("", path),
220 }
221}
222
223/// Build a colored header row — cyan foreground so headers are
224/// visually distinct from data rows.
225pub fn colored_header(columns: &[Column], group_header: &'static str) -> Vec<comfy_table::Cell> {
226 colored_header_with_sort(columns, group_header, None)
227}
228
229pub fn colored_header_with_sort(
230 columns: &[Column],
231 group_header: &'static str,
232 sort_metric: Option<&str>,
233) -> Vec<comfy_table::Cell> {
234 columns
235 .iter()
236 .map(|c| {
237 let label = if *c == Column::SortBy {
238 sort_metric.unwrap_or("sort-by")
239 } else {
240 c.header(group_header)
241 };
242 comfy_table::Cell::new(label).fg(comfy_table::Color::Cyan)
243 })
244 .collect()
245}
246
247/// Wrap a string-cell row in [`comfy_table::Cell`]s with blue
248/// foreground so derived-metric rows render visually distinct
249/// from the per-thread primary table when stdout is a TTY.
250/// Operators scanning a long compare or show output can locate
251/// the `## Derived metrics` rows at a glance instead of relying
252/// on the section header alone.
253///
254/// On a non-TTY stdout the comfy-table builder calls
255/// [`comfy_table::Table::force_no_tty`] (see
256/// [`crate::cli::new_table`]) which strips the ANSI escape
257/// sequences; the rendered output is byte-identical to the
258/// pre-color baseline for shell-pipeline consumers.
259///
260/// Color choice: blue contrasts with both the unstyled primary
261/// table and the perf-delta verdict palette
262/// (`Color::Red` / `Color::Green` for REGRESSION /
263/// improvement) — derived rows do not carry a regression
264/// verdict of their own, so reusing the verdict colors here
265/// would conflict with the established convention.
266pub fn color_derived_cells(cells: Vec<String>) -> Vec<comfy_table::Cell> {
267 cells
268 .into_iter()
269 .map(|c| comfy_table::Cell::new(c).fg(comfy_table::Color::Blue))
270 .collect()
271}
272
273/// Render a [`DerivedRow`] into the column-by-column cell
274/// vector. Mirrors [`render_diff_row_cells`] but routes
275/// numeric cells through the typed-derived formatters.
276pub(super) fn render_derived_row_cells(row: &DerivedRow, columns: &[Column]) -> Vec<String> {
277 let mut cells = Vec::with_capacity(columns.len());
278 for col in columns {
279 let cell = match col {
280 Column::Group => row.display_key.clone(),
281 Column::Threads => render_threads_cell(row.thread_count_a, row.thread_count_b),
282 Column::Metric => row.metric_name.to_string(),
283 Column::Baseline => match row.baseline {
284 Some(v) => format_derived_value_cell(v, row.metric_ladder, row.is_ratio),
285 None => "-".to_string(),
286 },
287 Column::Candidate => match row.candidate {
288 Some(v) => format_derived_value_cell(v, row.metric_ladder, row.is_ratio),
289 None => "-".to_string(),
290 },
291 Column::Delta => match row.delta {
292 Some(d) => format_derived_delta_cell(d, row.metric_ladder, row.is_ratio),
293 None => "-".to_string(),
294 },
295 Column::Pct => match row.delta_pct {
296 Some(p) => format!("{:+.1}%", p * 100.0),
297 None => "-".to_string(),
298 },
299 Column::Arrow => format_arrow_cell_derived(row),
300 Column::Value => "-".to_string(),
301 Column::Tags => String::new(),
302 Column::Uptime => "-".to_string(),
303 Column::SortBy => row.sort_by_cell.clone().unwrap_or_else(|| "-".to_string()),
304 };
305 cells.push(cell);
306 }
307 cells
308}
309
310/// Render a `(baseline, candidate, delta)` cell for the
311/// cgroup-enrichment secondary table emitted under
312/// [`super::GroupBy::Cgroup`]. The `ladder` parameter routes
313/// each scalar through `auto_scale` (private to this module) so
314/// a 7.5 GiB `memory_current` row reads
315/// `7.500GiB → 8.250GiB (+768.000MiB)` instead of
316/// `8053063680 → 8858370048 (+805306368)`. Each cell scales
317/// independently — baseline, candidate, and delta may pick
318/// different prefixes when their magnitudes cross thresholds.
319///
320/// See [`ScaleLadder`] for the closed enumeration of supported
321/// ladder families and per-variant step-up rules. The variants
322/// most relevant to cgroup-render call sites:
323/// - [`ScaleLadder::Us`]: cgroup `cpu_usage_usec` /
324/// `throttled_usec` / PSI `total_usec`.
325/// - [`ScaleLadder::Bytes`]: `memory_current` / `memory.max` /
326/// `memory.high` (IEC binary, B → KiB → MiB → GiB → TiB).
327/// - [`ScaleLadder::Unitless`]: `nr_throttled` / `cpu.weight` /
328/// `pids.current` / sched_ext attribute counters (decimal
329/// SI, "" → K → M → G).
330pub fn cgroup_cell(baseline: Option<u64>, candidate: Option<u64>, ladder: ScaleLadder) -> String {
331 match (baseline, candidate) {
332 (Some(baseline), Some(candidate)) => {
333 let baseline_cell = format_scaled_u64(baseline, ladder);
334 let candidate_cell = format_scaled_u64(candidate, ladder);
335 let d = candidate as i128 - baseline as i128;
336 // Delta is signed; route via format_delta_cell so the
337 // sign is rendered explicitly and the auto-scale step
338 // applies. i128 → f64 cast is lossy at extreme
339 // magnitudes (>2^53) but cgroup counters on typical
340 // hosts stay well under that ceiling.
341 let delta_cell = format_delta_cell(d as f64, ladder);
342 format!("{baseline_cell} → {candidate_cell} ({delta_cell})")
343 }
344 (Some(baseline), None) => format!("{} → -", format_scaled_u64(baseline, ladder)),
345 (None, Some(candidate)) => format!("- → {}", format_scaled_u64(candidate, ladder)),
346 (None, None) => "-".to_string(),
347 }
348}
349
350/// Render a baseline→candidate→delta cell for a PSI average
351/// field. `baseline` and `candidate` are centi-percent (0..=10000
352/// covering 0.00..=100.00 %); the cell renders each as `N.NN%`
353/// and computes a signed delta `(+|-D.DD%)`. Mirrors
354/// [`cgroup_cell`]'s structure but does NOT route through the
355/// auto-scale ladder — a pressure percentage is dimensionless
356/// and topping out at 100 means there's nothing to scale.
357pub fn format_psi_avg_cell(baseline: Option<u16>, candidate: Option<u16>) -> String {
358 match (baseline, candidate) {
359 (Some(b), Some(c)) => {
360 let baseline_cell = format_psi_avg_centi_percent(b);
361 let candidate_cell = format_psi_avg_centi_percent(c);
362 let d = c as i32 - b as i32;
363 let sign = if d >= 0 { "+" } else { "-" };
364 let abs = d.unsigned_abs();
365 let delta_int = abs / 100;
366 let delta_frac = abs % 100;
367 format!("{baseline_cell} → {candidate_cell} ({sign}{delta_int}.{delta_frac:02}%)")
368 }
369 (Some(b), None) => format!("{} → -", format_psi_avg_centi_percent(b)),
370 (None, Some(c)) => format!("- → {}", format_psi_avg_centi_percent(c)),
371 (None, None) => "-".to_string(),
372 }
373}
374
375/// Convert a centi-percent value (0..=10000) to its display
376/// form `N.NN%`. The centi-percent representation is 1:1 with
377/// the kernel's `LOAD_INT.LOAD_FRAC` 2-decimal-digit emission at
378/// `kernel/sched/psi.c:1284` — preserve that precision on
379/// display.
380pub fn format_psi_avg_centi_percent(v: u16) -> String {
381 let int = v / 100;
382 let frac = v % 100;
383 format!("{int}.{frac:02}%")
384}
385
386/// One entry in the [`psi_resource_accessors`] table — a
387/// display name paired with the accessor that pulls one
388/// [`PsiResource`] out of a [`Psi`] bundle.
389type PsiAccessor = (&'static str, fn(&Psi) -> PsiResource);
390
391/// Returns the four PSI resource accessors paired with their
392/// display names. Single source of truth for compare-side
393/// rendering — adding a fifth resource means one edit here.
394pub(super) fn psi_resource_accessors() -> [PsiAccessor; 4] {
395 [
396 ("cpu", |p| p.cpu),
397 ("memory", |p| p.memory),
398 ("io", |p| p.io),
399 ("irq", |p| p.irq),
400 ]
401}
402
403/// Returns true when either side of a [`Psi`] pair has any
404/// non-zero data. Used to suppress a host-pressure or
405/// per-cgroup-pressure section when both sides are flat zero.
406pub(super) fn psi_pair_has_data(a: &Psi, b: &Psi) -> bool {
407 psi_has_data(a) || psi_has_data(b)
408}
409
410pub(super) fn psi_has_data(p: &Psi) -> bool {
411 [p.cpu, p.memory, p.io, p.irq]
412 .iter()
413 .any(psi_resource_has_data)
414}
415
416pub(super) fn psi_resource_has_data(r: &PsiResource) -> bool {
417 let h = |h: &PsiHalf| h.avg10 != 0 || h.avg60 != 0 || h.avg300 != 0 || h.total_usec != 0;
418 h(&r.some) || h(&r.full)
419}