ktstr/ctprof_compare/
runner.rs

1//! CLI entry-point orchestration: parsers, top-level command
2//! dispatchers, args struct, and the `DisplayOptions` shape that
3//! `write_diff` consumes.
4//!
5//! Three layers:
6//!
7//! 1. CLI parsers — [`parse_sort_by`] (the sole runner-side
8//!    parser; [`super::parse_columns`] / [`super::parse_sections`] /
9//!    [`super::parse_metrics`] live in `columns.rs` because they
10//!    parse user-visible filter dimensions).
11//!
12//! 2. CLI args + display options — [`CtprofCompareArgs`]
13//!    (`ctprof compare` flags surface) and [`DisplayOptions`]
14//!    (the renderer's plumbed-as-one-struct config bundle).
15//!
16//! 3. Top-level dispatch — [`run_compare`] / [`run_metric_list`]
17//!    drive the `ctprof compare` and `ctprof list-metrics` paths
18//!    end-to-end. [`write_metric_list`] / [`print_metric_list`]
19//!    emit the metric registry. [`print_diff`] is the post-compare
20//!    print convenience that wraps [`super::write_diff`] +
21//!    [`limit_sections`] / [`flush_section`].
22
23use std::fmt;
24use std::path::Path;
25
26use anyhow::Context;
27
28use crate::ctprof::CtprofSnapshot;
29
30use super::{
31    AggRule, CTPROF_DERIVED_METRICS, CTPROF_METRICS, Column, CompareOptions, CtprofDiff,
32    CtprofMetricDef, DisplayFormat, GroupBy, Section, SortKey,
33    columns::{compare_columns_for, show_columns_default},
34    compare, metric_tags, parse_columns, parse_metrics, parse_sections,
35    warn_cgroup_only_sections_under_non_cgroup, write_diff,
36};
37
38/// Parse a `--sort-by` CLI value into a list of [`SortKey`]s.
39/// Spec format: `metric1[:dir1],metric2[:dir2],...` where each
40/// `metric` is a name from [`CTPROF_METRICS`] or
41/// [`CTPROF_DERIVED_METRICS`] and `dir` is `asc` or `desc`
42/// (case-insensitive — `:DESC`, `:Asc`, `:asc` all work).
43/// Direction defaults to `desc` (largest delta first — operator
44/// "show me the largest changes" default).
45///
46/// Whitespace around the metric name and around the direction
47/// is trimmed independently, so `wait_sum : desc` and
48/// `wait_sum:desc` produce identical [`SortKey`] values.
49///
50/// Each parsed [`SortKey`] stores the matched registry name as
51/// `&'static str` (not a copy of the user's input), so downstream
52/// equality with [`CtprofMetricDef::name`] or
53/// `DerivedMetricDef::name` is a content-equality check
54/// (`str::eq`) over the same registry-owned bytes — no per-key
55/// allocation outlives this call. The two registries are
56/// disjoint, so a name resolves unambiguously to one or the
57/// other.
58///
59/// Sorts groups by their aggregated metric values under whatever
60/// `--group-by` axis is in effect. The same spec works under
61/// every grouping (pcomm / cgroup / comm / comm-exact) — group
62/// rank reflects the per-group aggregate (sum, max, etc. per
63/// the metric's [`AggRule`]) of the named metric, OR the
64/// per-group derived value for entries from
65/// [`CTPROF_DERIVED_METRICS`].
66///
67/// Examples:
68/// - `"wait_sum"` → one key, descending.
69/// - `"wait_sum:asc"` → one key, ascending.
70/// - `"wait_sum:desc,run_time_ns:desc"` → two keys, both
71///   descending; lexicographic.
72/// - `"avg_wait_ns:desc"` → one key referencing a derived
73///   metric, descending.
74/// - `""` → empty Vec (caller falls back to default sort).
75///
76/// Errors:
77/// - Unknown metric name (not in [`CTPROF_METRICS`] AND not
78///   in [`CTPROF_DERIVED_METRICS`]).
79/// - Categorical metric name (one whose [`AggRule`] is
80///   [`AggRule::Mode`] / [`AggRule::ModeChar`] /
81///   [`AggRule::ModeBool`] — string- / char- / bool-valued, no
82///   scalar to sort by). The default sort already places mode
83///   rows last under the `delta_pct` ladder; sorting BY a mode
84///   metric would silently degrade to alphabetical group order.
85/// - Duplicate metric name across two entries (e.g.
86///   `--sort-by wait_sum,wait_sum`). The second key would never
87///   contribute to the lex ordering, so it's an operator typo
88///   rather than a meaningful spec.
89/// - Direction string other than `asc` / `desc`.
90/// - Empty token between commas (e.g. `"a,,b"`).
91pub fn parse_sort_by(spec: &str) -> anyhow::Result<Vec<SortKey>> {
92    if spec.is_empty() {
93        return Ok(Vec::new());
94    }
95    // Build a `name → &'static CtprofMetricDef` index so the
96    // lookup returns the canonical registry pointer (for storing
97    // in SortKey) AND the AggRule (for the categorical-reject
98    // check).
99    let registry: std::collections::BTreeMap<&'static str, &'static CtprofMetricDef> =
100        CTPROF_METRICS.iter().map(|m| (m.name, m)).collect();
101    let mut out: Vec<SortKey> = Vec::new();
102    let mut seen: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
103    for entry in spec.split(',') {
104        let entry = entry.trim();
105        if entry.is_empty() {
106            anyhow::bail!(
107                "empty entry in --sort-by spec {spec:?}; \
108                 entries are comma-separated and must be non-empty"
109            );
110        }
111        let (metric, descending) = match entry.split_once(':') {
112            Some((m, dir)) => {
113                // Trim both sides (`"wait_sum : DESC"` → metric
114                // `"wait_sum"` and direction `"DESC"`) and lowercase
115                // the direction so `:DESC` / `:Asc` / `:asc` are
116                // accepted equivalently. Operator-typed CLI input
117                // is forgiving about case; the canonical form
118                // stored in [`SortKey`] is still derived from the
119                // matched ascii literal.
120                let dir_norm = dir.trim().to_ascii_lowercase();
121                match dir_norm.as_str() {
122                    "desc" => (m, true),
123                    "asc" => (m, false),
124                    _ => anyhow::bail!(
125                        "invalid direction {dir:?} in --sort-by entry \
126                         {entry:?}; expected `asc` or `desc`"
127                    ),
128                }
129            }
130            None => (entry, true),
131        };
132        let metric = metric.trim();
133        // Resolve the input name against either the primary
134        // registry or the derived registry. The two namespaces
135        // are disjoint (the registry_and_derived_names_disjoint
136        // test pins this), so a name resolves unambiguously.
137        // The categorical-reject check applies only to primary
138        // metrics (derived metrics never go through AggRule).
139        let resolved_name: Option<&'static str> = if let Some(def) = registry.get(metric).copied() {
140            if matches!(
141                def.rule,
142                AggRule::Mode(_) | AggRule::ModeChar(_) | AggRule::ModeBool(_),
143            ) {
144                anyhow::bail!(
145                    "metric {metric:?} is categorical (no numeric value to sort by); \
146                     --sort-by accepts only metrics whose AggRule yields a scalar \
147                     (Sum*, Max*, Range*, or Affinity)"
148                );
149            }
150            Some(def.name)
151        } else {
152            CTPROF_DERIVED_METRICS
153                .iter()
154                .find(|d| d.name == metric)
155                .map(|d| d.name)
156        };
157        let Some(canonical) = resolved_name else {
158            // Sorted comma-separated list keeps the diagnostic
159            // copy-pasteable — operator can grep the names
160            // without parsing BTreeSet debug syntax. The
161            // rendered table cells append `[tag]` suffixes (e.g.
162            // `wait_sum [non-ext] [SCHEDSTATS]`), but
163            // `--sort-by` accepts only the bare metric name; if
164            // the operator pasted the rendered cell verbatim
165            // the trailing bracket would land here, hence the
166            // explicit hint.
167            let mut valid: Vec<&'static str> = registry.keys().copied().collect();
168            for d in CTPROF_DERIVED_METRICS {
169                valid.push(d.name);
170            }
171            valid.sort();
172            let valid = valid.join(", ");
173            anyhow::bail!(
174                "unknown metric {metric:?} in --sort-by spec {spec:?}; \
175                 use the bare metric name, not the rendered cell with \
176                 [tag] suffixes; must be one of: {valid}",
177            );
178        };
179        if !seen.insert(canonical) {
180            anyhow::bail!(
181                "duplicate metric {metric:?} in --sort-by spec {spec:?}; \
182                 each metric may appear at most once across all sort keys"
183            );
184        }
185        out.push(SortKey {
186            metric: canonical,
187            descending,
188        });
189    }
190    Ok(out)
191}
192
193/// Aggregate display options for the renderer. Plumbed as a
194/// single struct through [`write_diff`] so a future addition
195/// lands in one place without growing every signature. The
196/// show-side entry (`write_show` in `src/bin/ktstr.rs`) keeps
197/// a flatter signature for historical reasons but mirrors the
198/// same field semantics — `--wrap`, `--sections`, `--metrics`
199/// reach show via `wrap` / `sections` / `metrics` parameters
200/// that share the same
201/// helpers (`new_wrapped_table`, [`Section::cli_name`]).
202#[derive(Debug, Clone, Default)]
203#[non_exhaustive]
204pub struct DisplayOptions {
205    /// Format shorthand. Default [`DisplayFormat::Full`].
206    pub format: DisplayFormat,
207    /// User-supplied column override; empty Vec means "use the
208    /// format's default column set." Set via [`parse_columns`].
209    pub columns: Vec<Column>,
210    /// When `true`, tables render with comfy-table's
211    /// [`comfy_table::ContentArrangement::Dynamic`] layout
212    /// (terminal-width-aware cell wrapping). When `false`,
213    /// tables render with `ContentArrangement::Disabled` — the
214    /// prior shape, where columns can spill past the terminal
215    /// edge. Default `false` keeps existing operator workflows
216    /// untouched until the flag is opted into.
217    pub wrap: bool,
218    /// User-supplied section filter; empty Vec means "render
219    /// every section that has data" — the unfiltered default.
220    /// Non-empty restricts the rendered output to the listed
221    /// sections only. Set via [`parse_sections`].
222    pub sections: Vec<Section>,
223    /// User-supplied per-metric row filter; empty Vec means
224    /// "render every metric in the primary + derived sections"
225    /// — the unfiltered default. Non-empty restricts the
226    /// rendered rows to the listed metric names (which must
227    /// be in [`CTPROF_METRICS`] or
228    /// [`CTPROF_DERIVED_METRICS`]). Set via
229    /// [`parse_metrics`].
230    ///
231    /// Distinct from [`Self::sections`]: sections gate whole
232    /// sub-tables (primary, derived, cgroup-stats, …);
233    /// metrics gate individual ROWS within the primary and
234    /// derived sub-tables. The two compose — naming
235    /// `--sections primary` and `--metrics run_time_ns` shows
236    /// a single primary row.
237    pub metrics: Vec<&'static str>,
238    /// Maximum rendered lines per section. Sections whose table
239    /// output exceeds this limit are truncated with a notice.
240    /// `0` means unlimited (no truncation). Populated from the
241    /// CLI `--limit` arg (default `500`); the struct's `Default`
242    /// is `0` (unlimited).
243    pub section_line_limit: usize,
244}
245
246/// Arguments for the `ktstr ctprof compare` subcommand.
247#[derive(Debug, clap::Args)]
248pub struct CtprofCompareArgs {
249    /// Baseline snapshot (`.ctprof.zst`) from `ktstr ctprof capture -o`.
250    pub baseline: std::path::PathBuf,
251    /// Candidate snapshot (`.ctprof.zst`) from `ktstr ctprof capture -o`.
252    pub candidate: std::path::PathBuf,
253    /// Grouping key. `pcomm` aggregates per process
254    /// name with token-based pattern normalization (so
255    /// `worker-{0..N}` parent processes cluster into one
256    /// `worker-{N}` bucket); `cgroup` per cgroup path; `comm`
257    /// aggregates threads by NAME PATTERN under the same
258    /// token-based normalizer (digits, hex,
259    /// alpha-prefix-digits collapse into placeholders so
260    /// `tokio-worker-{0..N}` and `kworker/u8:7` cluster); use
261    /// `--no-thread-normalize` to disable that collapse and group
262    /// by literal `comm` / `pcomm` instead. `comm-exact`
263    /// disables thread-axis normalization only — its smaps keys
264    /// stay pcomm-pattern normalized — unlike `comm
265    /// --no-thread-normalize`, which also keys smaps literally
266    /// per-PID; see `GroupBy::CommExact`.
267    ///
268    /// Under `all` (default): also activates fudging — pairs
269    /// of cgroups with renamed-but-identical thread populations
270    /// (Jaccard similarity ≥ 0.90 over (pcomm, comm) thread
271    /// types, both sides ≥ 10 distinct types) are joined for
272    /// diffing instead of surfacing as orphans. Fudged rows
273    /// render with a `[fudged: <leaf>]` marker; the
274    /// `## Fudged cgroup matches` section at the bottom of the
275    /// output details the matched pairs and their
276    /// jaccard / overlap / cascade roots / residuals.
277    #[arg(long, value_enum, default_value_t = GroupBy::All, help_heading = "Grouping")]
278    pub group_by: GroupBy,
279    /// Glob patterns that collapse dynamic cgroup path segments
280    /// so structurally-equivalent cgroups across runs group
281    /// together. Example:
282    /// `--cgroup-flatten '/kubepods/*/workload'` treats different
283    /// pod IDs as the same group. Repeatable. Independent of
284    /// `--no-cg-normalize`: explicit globs apply first, then
285    /// auto-normalize runs unless disabled.
286    #[arg(long, help_heading = "Grouping")]
287    pub cgroup_flatten: Vec<String>,
288    /// Disable token-based pattern normalization across every
289    /// name-family axis: `--group-by comm`, `--group-by pcomm`,
290    /// AND the `## smaps_rollup` per-process keying (which
291    /// normalizes by the pcomm pattern by default — see
292    /// `collect_smaps_rollup`). With this flag set:
293    /// threads / processes group by their literal name; smaps
294    /// rows preserve their per-PID identity (`pcomm[tgid]`
295    /// instead of the normalized pcomm pattern). The
296    /// digit/hex/alpha-prefix placeholders are bypassed on every
297    /// axis. Has no effect under `--group-by comm-exact`
298    /// (already literal) or `--group-by cgroup`. Mirror of
299    /// `--no-cg-normalize` for the cgroup axis.
300    #[arg(long, help_heading = "Grouping")]
301    pub no_thread_normalize: bool,
302    /// Disable token-based pattern normalization for the cgroup
303    /// axis (`--group-by cgroup`). Cgroup paths group by literal
304    /// post-`--cgroup-flatten` path — Layer 1 (systemd template
305    /// `@<id>.service` → `@{I}.service`), Layer 2 (token
306    /// normalization), and Layer 3 (tighten) are all bypassed.
307    /// Has no effect under any other grouping.
308    #[arg(long, help_heading = "Grouping")]
309    pub no_cg_normalize: bool,
310    /// Multi-key sort spec for the diff rows. Format:
311    /// `metric1[:dir1],metric2[:dir2],...` where each `metric` is
312    /// one of the primary or derived metric names (run
313    /// `ctprof metric-list` for the full vocabulary) and
314    /// `dir` is `asc` or `desc` (default `desc`). Groups rank by
315    /// the tuple (`metric1_delta`, `metric2_delta`, ...) under
316    /// lexicographic order with per-key direction; rows within a
317    /// group keep registry order. Empty (the default) keeps the
318    /// "biggest |delta_pct|" sort. Examples:
319    /// - `--sort-by wait_sum:desc,run_time_ns:desc` — rank by
320    ///   the largest scheduler-wait deltas first, breaking ties
321    ///   by run-time delta.
322    /// - `--sort-by hiwater_rss_bytes:desc` — rank by the
323    ///   largest peak-RSS growth across the snapshot. Useful
324    ///   for memory-leak investigations.
325    /// - `--sort-by avg_wait_ns:asc` — rank by smallest average
326    ///   wait time first; surfaces the most-improved processes.
327    ///
328    /// Affects only the per-thread metric table and the
329    /// derived-metrics section. The `## smaps_rollup`
330    /// sub-table sorts process rows independently by absolute
331    /// Rss delta descending (tiebreak: max-Rss, then name; see
332    /// `sorted_smaps_process_keys`); a future flag could expose that knob,
333    /// but `--sort-by` does not propagate to it today.
334    ///
335    /// Parsed by [`parse_sort_by`] into [`CompareOptions::sort_by`].
336    #[arg(long, default_value = "", help_heading = "Display")]
337    pub sort_by: String,
338    /// Per-row column layout. `full` emits the
339    /// seven-column form; `delta-only` drops baseline +
340    /// candidate; `no-pct` drops the percentage column;
341    /// `arrow` collapses baseline / candidate into one
342    /// `baseline -> candidate` cell paired with separate Delta
343    /// and Pct columns; `pct-only` keeps just the percentage.
344    /// `arrow` (default) collapses baseline / candidate into
345    /// one cell paired with Delta and Pct columns.
346    /// `--columns` (below) overrides the format's default
347    /// column set when both are present.
348    #[arg(long, value_enum, default_value_t = DisplayFormat::Arrow, help_heading = "Display")]
349    pub display_format: DisplayFormat,
350    /// Comma-separated column names to render. Empty (the
351    /// default) means "use the column set selected by
352    /// --display-format." Valid names: `group`, `threads`,
353    /// `metric`, `baseline`, `candidate`, `delta`, `%`,
354    /// `arrow`, `tags`, `uptime`. Order in the spec is the
355    /// rendered order.
356    /// Example: `--columns metric,delta,%`. Applies to the
357    /// `primary` section's per-metric table only; secondary
358    /// tables (cgroup-stats, smaps-rollup, etc.) have fixed
359    /// column shapes and ignore this flag.
360    #[arg(long, default_value = "", help_heading = "Display")]
361    pub columns: String,
362    /// Comma-separated section names to render. Empty (the
363    /// default) renders every section that has data. When
364    /// non-empty, restricts output to the listed sub-tables —
365    /// every section not named is suppressed before its
366    /// data-availability gate runs. Valid names: `primary`,
367    /// `taskstats-delay`, `derived`, `cgroup-stats`,
368    /// `cgroup-limits`, `memory-stat`, `memory-events`,
369    /// `pressure`, `host-pressure`, `smaps-rollup`,
370    /// `sched-ext`. Useful for narrowing a wide compare to one
371    /// area of interest. Example:
372    /// `--sections primary,host-pressure`.
373    #[arg(long, default_value = "", help_heading = "Filter")]
374    pub sections: String,
375    /// Comma-separated metric names to render. Empty (the
376    /// default) renders every metric in the primary and
377    /// derived sub-tables. When non-empty, restricts the
378    /// rendered ROWS to the listed names — names must come
379    /// from the `ctprof metric-list` vocabulary
380    /// (CTPROF_METRICS or CTPROF_DERIVED_METRICS).
381    /// Useful for zooming on a specific counter family
382    /// without computing every metric: `--metrics
383    /// run_time_ns,wait_sum,affine_success_ratio`. Composes
384    /// with `--sections` — naming `--sections primary
385    /// --metrics run_time_ns` shows a single primary row.
386    #[arg(long, default_value = "", help_heading = "Filter")]
387    pub metrics: String,
388    /// Wrap table cells to fit the terminal width. Off by
389    /// default — wide tables can spill past the terminal edge,
390    /// matching the prior shell-pipeline-friendly layout. When
391    /// set, cells too wide for the available width wrap inside
392    /// the cell rather than overflowing, at the cost of taller
393    /// rows. The wrap kicks in only when stdout is a tty (the
394    /// terminal width is unknown otherwise); when piped to a
395    /// file or another command, the flag is silently dropped
396    /// and output stays unwrapped so awk/grep pipelines see
397    /// the same byte sequence as without the flag.
398    #[arg(long, help_heading = "Display")]
399    pub wrap: bool,
400    /// Maximum rendered lines per section. Sections whose table
401    /// output exceeds this limit are truncated with a notice
402    /// showing the number of hidden lines. Applies independently
403    /// to each sub-table (primary, derived, smaps-rollup, etc.).
404    /// `0` disables truncation entirely. Default `500`.
405    #[arg(long, default_value_t = 500, help_heading = "Display")]
406    pub limit: usize,
407}
408
409/// Entry point for the compare CLI. Parses `--sort-by` first,
410/// then loads both snapshots, computes the diff, prints the
411/// table, and returns `0` on success. Exits non-zero only on
412/// I/O or parse errors; a non-empty diff is data, not a
413/// failure.
414///
415/// Order is deliberate: `parse_sort_by` runs before the
416/// snapshot loads so an operator typo in the spec (`--sort-by
417/// not_a_real_metric`) fails fast without waiting on disk I/O.
418/// Without this ordering the operator pays for two snapshot
419/// loads only to hit the parser error after — and an
420/// integration test driving a malformed spec against
421/// non-existent snapshot paths would surface the load failure
422/// instead of the parser failure (the path the test actually
423/// pins).
424pub fn run_compare(args: &CtprofCompareArgs) -> anyhow::Result<i32> {
425    let sort_by = parse_sort_by(&args.sort_by)
426        .with_context(|| format!("parse --sort-by {:?}", args.sort_by))?;
427    // Parse --columns alongside --sort-by so a malformed spec
428    // surfaces before the snapshot loads. compare_side: true
429    // for the diff renderer. --sections / --metrics share the
430    // same fail-fast contract — an unknown name should not pay
431    // for two snapshot loads before failing.
432    let columns = parse_columns(&args.columns, true)
433        .with_context(|| format!("parse --columns {:?}", args.columns))?;
434    let sections = parse_sections(&args.sections)
435        .with_context(|| format!("parse --sections {:?}", args.sections))?;
436    let metrics = parse_metrics(&args.metrics)
437        .with_context(|| format!("parse --metrics {:?}", args.metrics))?;
438
439    // Warn the operator if any explicitly-named section is
440    // cgroup-only but the requested grouping isn't cgroup —
441    // those sections would silently render zero rows under the
442    // outer GroupBy::Cgroup gate in `write_diff` otherwise.
443    // The warning fires before snapshot load so the operator
444    // sees it immediately, not after a long disk-I/O wait.
445    warn_cgroup_only_sections_under_non_cgroup(&sections, args.group_by);
446
447    let baseline = CtprofSnapshot::load(&args.baseline)
448        .with_context(|| format!("load baseline {}", args.baseline.display()))?;
449    let candidate = CtprofSnapshot::load(&args.candidate)
450        .with_context(|| format!("load candidate {}", args.candidate.display()))?;
451
452    let display = DisplayOptions {
453        format: args.display_format,
454        columns,
455        wrap: args.wrap,
456        sections,
457        metrics,
458        section_line_limit: args.limit,
459    };
460
461    let opts = CompareOptions {
462        group_by: args.group_by.into(),
463        cgroup_flatten: args.cgroup_flatten.clone(),
464        no_thread_normalize: args.no_thread_normalize,
465        no_cg_normalize: args.no_cg_normalize,
466        sort_by,
467    };
468    let diff = compare(&baseline, &candidate, &opts);
469    print_diff(
470        &diff,
471        &args.baseline,
472        &args.candidate,
473        args.group_by,
474        &display,
475    );
476    Ok(0)
477}
478
479/// Render the metric-list discovery output: a tag legend
480/// (sched_class / config_gates / `[dead]`) followed by a per-metric
481/// table whose rows show `name | tags | description`. Tag legend
482/// is keyed off the closed-set vocabulary the registry pin test
483/// guards (`registry_tag_vocabulary_is_closed`), so adding a new
484/// allowed class or gate fails the test until both the legend
485/// and the closed-set table are updated together.
486///
487/// Splits rendering from I/O so tests can drive the formatter
488/// into a `String` buffer; the public [`run_metric_list`] entry
489/// point is the print wrapper.
490pub fn write_metric_list<W: fmt::Write>(w: &mut W) -> fmt::Result {
491    write_tag_legend(w)?;
492    write_sections_table(w)?;
493    write_metrics_table(w)?;
494    write_derived_metrics_table(w)?;
495    Ok(())
496}
497
498/// Emit the `## Tag legend` section (sched_class / config_gates /
499/// `status:` `[dead]`) and its trailing blank-line separator.
500fn write_tag_legend<W: fmt::Write>(w: &mut W) -> fmt::Result {
501    writeln!(w, "## Tag legend")?;
502    writeln!(w)?;
503    writeln!(w, "sched_class:")?;
504    writeln!(
505        w,
506        "  [cfs-only]    metric increments only inside CFS-class call paths (kernel/sched/fair.c);"
507    )?;
508    writeln!(w, "                zero under sched_ext / RT / DL / IDLE.")?;
509    writeln!(
510        w,
511        "  [non-ext]     metric is written by the schedstat sleep/wait family wrappers"
512    )?;
513    writeln!(
514        w,
515        "                (kernel/sched/stats.c); CFS / RT / DL accumulate, sched_ext bypasses."
516    )?;
517    writeln!(
518        w,
519        "  [fair-policy] metric emits only when fair_policy(p->policy) is true:"
520    )?;
521    writeln!(
522        w,
523        "                SCHED_NORMAL, SCHED_BATCH, AND SCHED_EXT under CONFIG_SCHED_CLASS_EXT."
524    )?;
525    writeln!(w)?;
526    writeln!(
527        w,
528        "config_gates (compact form; full kconfig symbol prefixed with CONFIG_):"
529    )?;
530    writeln!(
531        w,
532        "  [SCHED_INFO]            requires CONFIG_SCHED_INFO; gates the sched_info_* counters"
533    )?;
534    writeln!(
535        w,
536        "                          surfaced via /proc/<tid>/schedstat (run_time_ns, wait_time_ns,"
537    )?;
538    writeln!(w, "                          timeslices).")?;
539    writeln!(
540        w,
541        "  [SCHEDSTATS]            requires CONFIG_SCHEDSTATS; gates every __schedstat_* /"
542    )?;
543    writeln!(
544        w,
545        "                          schedstat_* macro call (kernel/sched/stats.h:75-82)."
546    )?;
547    writeln!(
548        w,
549        "  [SCHED_CORE]            requires CONFIG_SCHED_CORE; gates the core-scheduling"
550    )?;
551    writeln!(
552        w,
553        "                          subsystem (core_forceidle_sum)."
554    )?;
555    writeln!(
556        w,
557        "  [SCHED_CLASS_EXT]       requires CONFIG_SCHED_CLASS_EXT; without it no task can"
558    )?;
559    writeln!(w, "                          land on the sched_ext class.")?;
560    writeln!(
561        w,
562        "  [TASK_DELAY_ACCT]       requires CONFIG_TASK_DELAY_ACCT AND runtime delayacct=on"
563    )?;
564    writeln!(
565        w,
566        "                          (boot param or kernel.task_delayacct sysctl)."
567    )?;
568    writeln!(
569        w,
570        "  [TASK_IO_ACCOUNTING]    requires CONFIG_TASK_IO_ACCOUNTING; gates /proc/<tid>/io."
571    )?;
572    writeln!(
573        w,
574        "  [TASKSTATS]             requires CONFIG_TASKSTATS; gates the netlink TASKSTATS family"
575    )?;
576    writeln!(
577        w,
578        "                          (kernel/taskstats.c) used by the taskstats delay-accounting"
579    )?;
580    writeln!(
581        w,
582        "                          and hiwater_rss/hiwater_vm capture path. Calls also need"
583    )?;
584    writeln!(w, "                          CAP_NET_ADMIN.")?;
585    writeln!(
586        w,
587        "  [TASK_XACCT]            requires CONFIG_TASK_XACCT; gates extended accounting fields"
588    )?;
589    writeln!(
590        w,
591        "                          (hiwater_rss, hiwater_vm) populated by xacct_add_tsk."
592    )?;
593    writeln!(w)?;
594    writeln!(w, "status:")?;
595    writeln!(
596        w,
597        "  [dead]        kernel exposes the counter via /proc but never increments it; always"
598    )?;
599    writeln!(
600        w,
601        "                reads zero. Surfaced for forward-compat parity with the kernel's"
602    )?;
603    writeln!(w, "                exposure surface.")?;
604    writeln!(w)?;
605    Ok(())
606}
607
608/// Emit the `## Sections` vocabulary table and its trailing
609/// blank-line separator.
610fn write_sections_table<W: fmt::Write>(w: &mut W) -> fmt::Result {
611    // Sections vocabulary table — discovery companion to the
612    // `--sections` CLI flag. Lists every Section variant in
613    // rendering order with its CLI name and a short description
614    // of what it renders. Operators reading the rendered table
615    // see `--sections primary,host-pressure` (or whatever) in
616    // their compare/show invocation and need a way to learn
617    // which sub-tables those tokens correspond to without
618    // jumping to source. This section closes that loop.
619    writeln!(w, "## Sections")?;
620    writeln!(w)?;
621    let mut sections_table = crate::cli::new_table();
622    sections_table.set_header(vec!["section", "rendered heading", "description"]);
623    for section in Section::ALL {
624        let (heading, desc) = match section {
625            Section::Primary => (
626                "(no heading; first table)",
627                "Per-thread metric table — the primary aggregated rows EXCLUDING the taskstats genetlink rows (those carry the `taskstats-delay` tag).",
628            ),
629            Section::TaskstatsDelay => (
630                "(rendered inside the primary table)",
631                "Taskstats genetlink-sourced rows — eight delay-accounting categories (cpu/blkio/swapin/freepages/thrashing/compact/wpcopy/irq × count/total/max/min) plus hiwater_rss_bytes / hiwater_vm_bytes. Per-row filter inside the primary table.",
632            ),
633            Section::Derived => (
634                "## Derived metrics",
635                "Computed metrics derived from the primary registry (ratios, averages, signed differences).",
636            ),
637            Section::CgroupStats => (
638                "(no heading; cgroup-stats table)",
639                "Per-cgroup CPU + memory enrichment from cpu.stat / memory.current. Requires --group-by cgroup.",
640            ),
641            Section::Limits => (
642                "## Cgroup limits / knobs",
643                "Operator-set cgroup configuration — cpu.max, cpu.weight, memory.max, memory.high, pids.*. Requires --group-by cgroup.",
644            ),
645            Section::MemoryStat => (
646                "## memory.stat",
647                "Kernel-emitted memory.stat counters per cgroup. Requires --group-by cgroup.",
648            ),
649            Section::MemoryEvents => (
650                "## memory.events",
651                "Pressure-event counters from memory.events per cgroup. Requires --group-by cgroup.",
652            ),
653            Section::Pressure => (
654                "## Pressure / <resource>",
655                "Per-cgroup PSI sub-tables — one per resource (cpu / memory / io / irq). Requires --group-by cgroup.",
656            ),
657            Section::HostPressure => (
658                "## Host pressure / <resource>",
659                "System-level PSI sub-tables from /proc/pressure/<resource>.",
660            ),
661            Section::Smaps => (
662                "## smaps_rollup",
663                "Per-process memory-mapping summary from /proc/<pid>/smaps_rollup (Rss / Pss / private / shared / swap). Compare-side keys default to per-pcomm-pattern aggregates (`worker-{N}`); pass `--no-thread-normalize` to switch back to literal `pcomm[tgid]` per-PID rows. Under default normalization, byte counts per (pcomm-pattern, key) pair are field-summed across all PIDs sharing the same pcomm skeleton.",
664            ),
665            Section::SchedExt => (
666                "## sched_ext",
667                "Global sched_ext sysfs state — state, switch_all, nr_rejected, hotplug_seq, enable_seq.",
668            ),
669        };
670        sections_table.add_row(vec![
671            section.cli_name().to_string(),
672            heading.to_string(),
673            desc.to_string(),
674        ]);
675    }
676    writeln!(w, "{sections_table}")?;
677    writeln!(w)?;
678    Ok(())
679}
680
681/// Emit the `## Metrics` registry table and its trailing
682/// blank-line separator.
683fn write_metrics_table<W: fmt::Write>(w: &mut W) -> fmt::Result {
684    writeln!(w, "## Metrics")?;
685    writeln!(w)?;
686    let mut table = crate::cli::new_table();
687    table.set_header(vec!["metric", "tags", "description"]);
688    for m in CTPROF_METRICS {
689        // metric_tags renders only the bracketed
690        // sched_class / [dead] / config_gate suffixes into the
691        // `tags` column — keeps the table scannable. When the
692        // metric has no tags, the cell is empty.
693        let tags = metric_tags(m);
694        table.add_row(vec![m.name.to_string(), tags, m.description.to_string()]);
695    }
696    writeln!(w, "{table}")?;
697    writeln!(w)?;
698    Ok(())
699}
700
701/// Emit the `## Derived metrics` table.
702fn write_derived_metrics_table<W: fmt::Write>(w: &mut W) -> fmt::Result {
703    writeln!(w, "## Derived metrics")?;
704    writeln!(w)?;
705    let mut dt = crate::cli::new_table();
706    dt.set_header(vec!["metric", "unit", "inputs", "description"]);
707    for d in CTPROF_DERIVED_METRICS {
708        // Ladder is the source of truth — `ratio` and unit
709        // suffixes both fall out of `ScaleLadder::base_unit`
710        // (with an explicit override for ratio rows where
711        // is_ratio is true and the ladder is None).
712        let unit_cell = if d.is_ratio {
713            "ratio".to_string()
714        } else {
715            d.ladder.base_unit().to_string()
716        };
717        dt.add_row(vec![
718            d.name.to_string(),
719            unit_cell,
720            d.inputs.join(", "),
721            d.description.to_string(),
722        ]);
723    }
724    writeln!(w, "{dt}")?;
725    Ok(())
726}
727
728/// Print the metric-list discovery output to stdout. Thin
729/// wrapper over [`write_metric_list`] so the CLI keeps the
730/// one-line call ergonomics; tests drive the writer into a
731/// `String` buffer.
732pub fn print_metric_list() {
733    let mut out = String::new();
734    // Infallible: writing into a String cannot fail.
735    let _ = write_metric_list(&mut out);
736    print!("{out}");
737}
738
739/// Entry point for the `ctprof metric-list` subcommand.
740/// Always returns `Ok(0)` — discovery output is informational
741/// and never fails.
742pub fn run_metric_list() -> anyhow::Result<i32> {
743    print_metric_list();
744    Ok(0)
745}
746
747/// Render [`CtprofDiff`] as a table on stdout. Thin wrapper
748/// over [`write_diff`] so the non-test caller keeps the
749/// ergonomics of a one-line call; tests drive [`write_diff`]
750/// into a `String` buffer.
751pub fn print_diff(
752    diff: &CtprofDiff,
753    baseline_path: &Path,
754    candidate_path: &Path,
755    group_by: GroupBy,
756    display: &DisplayOptions,
757) {
758    let mut out = String::new();
759    // Infallible: writing into a String cannot fail.
760    let _ = write_diff(
761        &mut out,
762        diff,
763        baseline_path,
764        candidate_path,
765        group_by,
766        display,
767    );
768    if display.section_line_limit > 0 {
769        print!("{}", limit_sections(&out, display.section_line_limit));
770    } else {
771        print!("{out}");
772    }
773}
774
775/// Truncate each `## <heading>` section to at most `limit` lines.
776/// Sections are delimited by lines starting with `## `. Content
777/// before the first section header passes through untruncated
778/// (typically the file-path header row).
779pub fn limit_sections(output: &str, limit: usize) -> String {
780    let mut result = String::with_capacity(output.len());
781    let mut section_lines: Vec<&str> = Vec::new();
782    let mut section_header: Option<&str> = None;
783
784    for line in output.lines() {
785        if line.starts_with("## ") {
786            flush_section(&mut result, section_header, &section_lines, limit);
787            section_lines.clear();
788            section_header = Some(line);
789        } else if section_header.is_some() {
790            section_lines.push(line);
791        } else {
792            result.push_str(line);
793            result.push('\n');
794        }
795    }
796    flush_section(&mut result, section_header, &section_lines, limit);
797    result
798}
799
800fn flush_section(result: &mut String, header: Option<&str>, lines: &[&str], limit: usize) {
801    let Some(header) = header else { return };
802    result.push_str(header);
803    result.push('\n');
804    if lines.len() <= limit {
805        for line in lines {
806            result.push_str(line);
807            result.push('\n');
808        }
809    } else {
810        for line in &lines[..limit] {
811            result.push_str(line);
812            result.push('\n');
813        }
814        result.push_str(&format!(
815            "... {} more lines truncated (use --limit 0 for unlimited)\n",
816            lines.len() - limit,
817        ));
818    }
819}
820
821impl DisplayOptions {
822    /// Resolved compare-side column set: `columns` if
823    /// non-empty, otherwise `compare_columns_for` over
824    /// `format`. `--columns` always wins over the format
825    /// shorthand (explicit > shorthand) per the design call.
826    pub fn resolved_compare_columns(&self) -> Vec<Column> {
827        if self.columns.is_empty() {
828            compare_columns_for(self.format)
829        } else {
830            self.columns.clone()
831        }
832    }
833
834    /// Resolved show-side column set: `columns` if non-empty,
835    /// otherwise `show_columns_default`.
836    pub fn resolved_show_columns(&self) -> Vec<Column> {
837        if self.columns.is_empty() {
838            show_columns_default()
839        } else {
840            self.columns.clone()
841        }
842    }
843
844    /// Returns `true` when `section` should render under the
845    /// current filter. Empty `sections` means "every section
846    /// renders" (the default — no filter applied), matching
847    /// [`parse_sections`]'s empty-input semantic. Non-empty
848    /// `sections` restricts rendering to the named entries.
849    pub fn is_section_enabled(&self, section: Section) -> bool {
850        self.sections.is_empty() || self.sections.contains(&section)
851    }
852
853    /// Returns `true` when the metric named `name` should
854    /// render under the current row-level filter. Empty
855    /// `metrics` means "every metric renders" — the
856    /// unfiltered default mirroring
857    /// [`Self::is_section_enabled`]. Non-empty restricts
858    /// rendering to the listed names. The comparison is on
859    /// the metric's `&'static str` name (so a registry-name
860    /// pointer or any byte-equal string both match).
861    pub fn is_metric_enabled(&self, name: &str) -> bool {
862        self.metrics.is_empty() || self.metrics.contains(&name)
863    }
864
865    /// Construct a comfy-table builder honouring the
866    /// [`wrap`](Self::wrap) flag: terminal-width-aware
867    /// `Dynamic` arrangement when `wrap` is true, otherwise the
868    /// existing borderless, disabled-arrangement layout via
869    /// [`crate::cli::new_table`]. Single source of truth so
870    /// every section in [`write_diff`] honours `--wrap` without
871    /// per-call-site `if` branching. The show-side renderer
872    /// (`write_show` in `src/bin/ktstr.rs`) calls the underlying
873    /// helpers directly through the same branch.
874    pub fn new_table(&self) -> comfy_table::Table {
875        if self.wrap {
876            crate::cli::new_wrapped_table()
877        } else {
878            crate::cli::new_table()
879        }
880    }
881
882    /// Create a table constrained to the given max content widths.
883    /// Heading rows wider than data get auto-truncated by comfy_table
884    /// with its built-in "..." indicator.
885    pub fn new_constrained_table(&self, max_widths: &[u16]) -> comfy_table::Table {
886        let mut t = self.new_table();
887        // Create dummy columns so constraints can be set.
888        // Columns are auto-created when the header is added later,
889        // but we need them NOW for set_constraint. Adding a dummy
890        // header row with the right column count, then replacing
891        // it when the real header is set.
892        let dummy: Vec<&str> = (0..max_widths.len()).map(|_| "").collect();
893        t.set_header(dummy);
894        for (i, &w) in max_widths.iter().enumerate() {
895            if let Some(col) = t.column_mut(i) {
896                col.set_constraint(comfy_table::ColumnConstraint::UpperBoundary(
897                    comfy_table::Width::Fixed(w),
898                ));
899            }
900        }
901        t
902    }
903}