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}