ktstr/ctprof_compare/report/mod.rs
1//! `write_diff` — primary diff renderer for [`CtprofDiff`].
2//!
3//! Multi-section emitter for the `ctprof compare` output.
4//! Each section emits through a dedicated submodule so the
5//! orchestrator below stays a slim sequencer:
6//!
7//! - [`primary`] — `## Primary metrics` table (53 non-taskstats
8//! rows + 34 taskstats rows). Three layouts based on
9//! [`GroupBy`]: hierarchical cgroup + pcomm + comm tree
10//! under [`GroupBy::All`], one-table-per-parent under
11//! [`GroupBy::Cgroup`], flat under [`GroupBy::Pcomm`] /
12//! [`GroupBy::Comm`] / [`GroupBy::CommExact`].
13//! - [`derived`] — `## Derived metrics` table; reuses the
14//! primary tree builder for [`GroupBy::All`] so primary and
15//! derived rows share heading order.
16//! - [`smaps`] — `## smaps_rollup` per-process memory
17//! compare; suppressed when neither side carries data.
18//! - [`cgroup`] — five sub-tables that exist only under
19//! [`GroupBy::Cgroup`]: cgroup-stats, limits/knobs,
20//! memory.stat, memory.events, per-cgroup PSI.
21//! - [`host_psi`] — `## Host pressure / <resource>` tables;
22//! independent of group_by because host pressure is the
23//! primary scheduler-health signal.
24//! - [`sched_ext`] — `## sched_ext` global sysfs compare.
25//! - [`orphans`] — fudged-cgroup pairs (rendered first so
26//! they don't get buried), only-baseline list,
27//! only-candidate list.
28//!
29//! Each emitter is gated on
30//! [`DisplayOptions::is_section_enabled`] so `--sections`
31//! always wins over the data-availability suppression
32//! heuristic, and the column widths used by [`primary`],
33//! [`derived`], and [`smaps`] are measured ONCE across all
34//! data rows (primary + derived) so every table in every
35//! section shares column widths under [`GroupBy::All`].
36//!
37//! Cell rendering is delegated to [`super::render`]; metric
38//! lookup to [`super::metrics`]; column resolution to
39//! [`super::columns`]; the table builder lives on
40//! [`super::DisplayOptions`].
41
42mod cgroup;
43mod derived;
44mod host_psi;
45mod orphans;
46mod primary;
47mod sched_ext;
48mod smaps;
49
50use std::fmt;
51use std::path::Path;
52
53use super::columns::Column;
54use super::diff_types::CtprofDiff;
55use super::options::GroupBy;
56use super::render::{colored_header_with_sort, render_derived_row_cells, render_diff_row_cells};
57use super::runner::DisplayOptions;
58
59/// Render [`CtprofDiff`] into `w`. The formatter layer lives
60/// here so tests can inspect exactly what `print_diff` would
61/// emit without shelling through stdout capture. Write errors
62/// propagate as [`std::fmt::Error`] — callers that write into an
63/// infallible sink (`String`) can unwrap or ignore.
64///
65/// `display` controls per-row column layout, terminal-width
66/// wrapping, and per-section filtering: see `DisplayFormat` /
67/// [`Column`] / `Section` / [`DisplayOptions`] for the
68/// resolution rules. Each sub-emitter is gated on
69/// [`DisplayOptions::is_section_enabled`] before its
70/// data-availability check, so `--sections` always wins over
71/// the per-section zero-suppression heuristic.
72pub fn write_diff<W: fmt::Write>(
73 w: &mut W,
74 diff: &CtprofDiff,
75 baseline_path: &Path,
76 candidate_path: &Path,
77 group_by: GroupBy,
78 display: &DisplayOptions,
79) -> fmt::Result {
80 let group_header = match group_by {
81 GroupBy::Pcomm => "pcomm",
82 GroupBy::Cgroup => "cgroup",
83 GroupBy::Comm => "comm-pattern",
84 GroupBy::CommExact => "comm",
85 GroupBy::All => "comm",
86 };
87
88 let mut columns = display.resolved_compare_columns();
89 let has_sort_col = diff.rows.first().is_some_and(|r| r.sort_by_cell.is_some());
90 if has_sort_col {
91 columns.push(Column::SortBy);
92 }
93
94 // Compute column widths from ALL data rows (primary + derived)
95 // so every table in every section shares the same widths.
96 // Heading rows are constrained to these widths via
97 // new_constrained_table so they can't inflate columns.
98 let global_max_widths: Vec<u16> = if group_by == GroupBy::All {
99 compute_global_max_widths(diff, &columns, display)
100 } else {
101 Vec::new()
102 };
103
104 primary::write_primary_section(
105 w,
106 diff,
107 group_by,
108 group_header,
109 &columns,
110 display,
111 &global_max_widths,
112 )?;
113 derived::write_derived_section(
114 w,
115 diff,
116 group_by,
117 group_header,
118 &columns,
119 display,
120 &global_max_widths,
121 )?;
122 smaps::write_smaps_section(w, diff, group_by, &columns, display, &global_max_widths)?;
123 cgroup::write_cgroup_sections(w, diff, group_by, display)?;
124 host_psi::write_host_psi_section(w, diff, display)?;
125 sched_ext::write_sched_ext_section(w, diff, display)?;
126 orphans::write_orphans_section(w, diff, baseline_path, candidate_path, group_by)?;
127
128 Ok(())
129}
130
131/// Two-pass column-width measurement: build a throwaway table,
132/// add every primary + derived data row through the same cell
133/// path the real renderers use, and read back per-column max
134/// widths. Heading rows are constrained to these widths in the
135/// real tables so they can't inflate columns.
136fn compute_global_max_widths(
137 diff: &CtprofDiff,
138 columns: &[Column],
139 display: &DisplayOptions,
140) -> Vec<u16> {
141 let mut measure = display.new_table();
142 measure.set_header(colored_header_with_sort(
143 columns,
144 "comm",
145 diff.sort_metric_name,
146 ));
147 for row in &diff.rows {
148 let mut cells = render_diff_row_cells(row, columns);
149 if let Some(pos) = columns.iter().position(|c| *c == Column::Group) {
150 let comm = row.group_key.splitn(3, '\x00').nth(2).unwrap_or("");
151 cells[pos] = comm.to_string();
152 }
153 measure.add_row(cells);
154 }
155 for row in &diff.derived_rows {
156 let mut cells = render_derived_row_cells(row, columns);
157 if let Some(pos) = columns.iter().position(|c| *c == Column::Group) {
158 let comm = row.group_key.splitn(3, '\x00').nth(2).unwrap_or("");
159 cells[pos] = comm.to_string();
160 }
161 measure.add_row(cells);
162 }
163 measure.column_max_content_widths()
164}