ktstr/ctprof_compare/
columns.rs

1//! User-facing display configuration: columns, sections, and
2//! display-format shorthands.
3//!
4//! Three layers, all consumed by the renderer in
5//! [`super::write_diff`] / `write_show` paths and by the CLI
6//! parsers that map operator-supplied strings to typed values:
7//!
8//! 1. [`DisplayFormat`] — closed enum of layout shorthands
9//!    (Full, DeltaOnly, NoPct, Arrow, PctOnly) that resolve to
10//!    a fixed [`Column`] vec via [`compare_columns_for`].
11//!    [`show_columns_default`] mirrors the same shape for the
12//!    `ctprof show` single-snapshot path.
13//!
14//! 2. [`Column`] / [`parse_columns`] — closed enumeration of the
15//!    rendered cell slots (Group, Threads, Metric, Baseline,
16//!    Candidate, Delta, Pct, Arrow, Uptime, SortBy). The
17//!    [`parse_columns`] CLI parser takes a comma-separated spec
18//!    and rejects unknown names + show-incompatible columns at
19//!    parse time so the renderer never sees a mismatched column
20//!    set. Order in the resolved vec is the rendered order; the
21//!    renderer never re-sorts.
22//!
23//! 3. [`Section`] / [`parse_sections`] / [`parse_metrics`] —
24//!    user-facing filter dimensions. Section is the per-row
25//!    section-tag dimension (Primary, TaskstatsDelay, Derived,
26//!    CgroupStats, Limits, MemoryStat, MemoryEvents, Pressure,
27//!    HostPressure, Smaps, SchedExt) used to scope `--sections
28//!    <list>` and `--metrics <list>`. The
29//!    [`warn_cgroup_only_sections_under_non_cgroup`] helper
30//!    emits a stderr warning at run time when an explicit
31//!    `--sections` filter names a cgroup-only section while
32//!    `--group-by` is not [`super::GroupBy::Cgroup`] — without
33//!    the warning, the section would silently render zero rows.
34//!
35//! All three layers are CLI-input parsers; none of them dispatch
36//! on rendered data shape. The renderer consumes the resolved
37//! `Vec<Column>` / `Vec<Section>` / `Vec<&'static str>` directly
38//! without re-validating.
39
40use super::{CTPROF_DERIVED_METRICS, CTPROF_METRICS, GroupBy};
41
42/// Per-row display layout for `write_diff`.
43///
44/// `Full` (default) emits the seven-column form
45/// `(group | threads | metric | baseline | candidate | delta | %)`.
46/// The remaining variants are compact shortcuts for common
47/// operator workflows; each resolves to a fixed [`Column`] set
48/// before the renderer runs. A `--columns` override on the same
49/// invocation wins over the format's default column set.
50///
51/// [`Arrow`] collapses baseline / candidate into a single cell
52/// shaped `<baseline> -> <candidate>` so a narrow display still
53/// surfaces directionality. The arrow column is paired with the
54/// dedicated Delta + Pct + Uptime columns (not fused into the
55/// arrow cell itself), so the renderer keeps the deltas
56/// readable on either side of the arrow form. The arrow cell
57/// shape mirrors `cgroup_cell`'s so primary and cgroup tables
58/// stay visually consistent.
59///
60/// [`Arrow`]: DisplayFormat::Arrow
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
62#[non_exhaustive]
63pub enum DisplayFormat {
64    /// Default — emit baseline, candidate, delta, and pct
65    /// columns alongside group / threads / metric.
66    #[default]
67    Full,
68    /// Drop baseline + candidate columns; keep delta + pct.
69    DeltaOnly,
70    /// Drop pct column; keep baseline + candidate + delta.
71    NoPct,
72    /// Arrow form: `<baseline> -> <candidate>` cell paired with
73    /// the dedicated Delta + Pct + Uptime columns. Compact view
74    /// for "show me direction at a glance" while still carrying
75    /// the deltas on the same row.
76    Arrow,
77    /// Drop baseline / candidate / delta; keep pct only.
78    PctOnly,
79}
80
81/// One column slot in the rendered diff/show table. The renderer
82/// iterates the resolved [`Column`] vec to build both the
83/// header row and each data row, dispatching cell construction
84/// per variant. Order in the slice is the rendered order — the
85/// renderer never re-sorts.
86///
87/// Column variants are uniform across compare and show even
88/// though show's [`Column::Baseline`], [`Column::Candidate`],
89/// [`Column::Delta`], [`Column::Pct`], [`Column::Arrow`] are
90/// meaningless for a single snapshot. The show entry point
91/// rejects those names at CLI parse time so an operator never
92/// reaches the renderer with a mismatched column set.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum Column {
96    /// The group-by axis label (rendered header is "pcomm",
97    /// "cgroup", "comm-pattern", or "comm" per `GroupBy`).
98    Group,
99    /// Thread-count cell (`N` when the count matches across
100    /// snapshots, `A->B` arrow form otherwise).
101    Threads,
102    /// Metric name with bracketed tag suffix.
103    Metric,
104    /// Baseline value (compare only).
105    Baseline,
106    /// Candidate value (compare only).
107    Candidate,
108    /// Signed delta (compare only).
109    Delta,
110    /// Percentage delta (compare only).
111    Pct,
112    /// Single-cell `<baseline> -> <candidate>` (compare only).
113    /// Mutually exclusive with `Baseline`/`Candidate` (the arrow
114    /// cell already shows both); coexists with `Delta`/`Pct`,
115    /// which remain separate numeric columns.
116    Arrow,
117    /// Aggregated value cell (show only).
118    Value,
119    /// Bracketed tag suffix (sched_class + config_gates + dead).
120    /// Off by default — opt in with `--columns ...,tags`.
121    Tags,
122    /// Relative uptime: group age as a percentage of the oldest
123    /// thread in the snapshot. 100% = as old as the oldest
124    /// thread, 0% = just spawned. Color gradient: green ≥75%,
125    /// yellow 50–75%, red <50% (>2x younger than the oldest).
126    Uptime,
127    /// Sort-by metric summary column. Shows the --sort-by metric's
128    /// baseline→candidate (delta%) per group. Only present when
129    /// --sort-by is set.
130    SortBy,
131}
132
133impl Column {
134    /// Canonical CLI name. Round-trips through
135    /// [`parse_columns`].
136    pub fn cli_name(self) -> &'static str {
137        match self {
138            Column::Group => "group",
139            Column::Threads => "threads",
140            Column::Metric => "metric",
141            Column::Baseline => "baseline",
142            Column::Candidate => "candidate",
143            Column::Delta => "delta",
144            Column::Pct => "%",
145            Column::Arrow => "arrow",
146            Column::Value => "value",
147            Column::Tags => "tags",
148            Column::Uptime => "uptime",
149            Column::SortBy => "sort-by", // overridden dynamically in colored_header_with_sort
150        }
151    }
152
153    /// Header cell text. The group axis carries a per-`GroupBy`
154    /// label (`pcomm`, `cgroup`, etc.); other columns echo
155    /// [`Self::cli_name`].
156    pub fn header(self, group_header: &'static str) -> &'static str {
157        match self {
158            Column::Group => group_header,
159            Column::Threads => "threads",
160            Column::Metric => "metric",
161            Column::Baseline => "baseline",
162            Column::Candidate => "candidate",
163            Column::Delta => "delta",
164            Column::Pct => "%",
165            Column::Arrow => "value",
166            Column::Value => "value",
167            Column::Tags => "tags",
168            Column::Uptime => "%uptime",
169            Column::SortBy => "sort-by", // overridden dynamically in colored_header_with_sort
170        }
171    }
172}
173
174/// Resolve a [`DisplayFormat`] to its default column set
175/// (compare-side). Returns the full ordered column slice
176/// including the group / threads / metric prefix.
177pub(super) fn compare_columns_for(format: DisplayFormat) -> Vec<Column> {
178    let mut cols = vec![Column::Group, Column::Threads, Column::Metric];
179    let trailing: &[Column] = match format {
180        DisplayFormat::Full => &[
181            Column::Baseline,
182            Column::Candidate,
183            Column::Delta,
184            Column::Pct,
185        ],
186        DisplayFormat::DeltaOnly => &[Column::Delta, Column::Pct],
187        DisplayFormat::NoPct => &[Column::Baseline, Column::Candidate, Column::Delta],
188        DisplayFormat::Arrow => &[Column::Arrow, Column::Delta, Column::Pct, Column::Uptime],
189        DisplayFormat::PctOnly => &[Column::Pct],
190    };
191    cols.extend_from_slice(trailing);
192    cols
193}
194
195/// Resolve the show-side default column set (no
196/// baseline/candidate/delta/pct — show is single-snapshot).
197pub(super) fn show_columns_default() -> Vec<Column> {
198    vec![
199        Column::Group,
200        Column::Threads,
201        Column::Metric,
202        Column::Value,
203    ]
204}
205
206/// Parse a CLI `--columns` spec into a typed [`Column`] vec.
207/// Format: comma-separated names matching [`Column::cli_name`].
208/// Whitespace around each name is trimmed. Empty input parses
209/// to an empty Vec — caller falls back to the format default.
210///
211/// `compare_side` controls which subset is allowed:
212/// - `true` accepts every variant except [`Column::Value`]
213///   (show-only).
214/// - `false` accepts every variant except
215///   [`Column::Baseline`], [`Column::Candidate`],
216///   [`Column::Delta`], [`Column::Pct`], [`Column::Arrow`]
217///   (compare-only).
218///
219/// Errors:
220/// - Unknown name (cite the offending token; list valid names).
221/// - Wrong-side name (e.g. `value` on compare or `baseline`
222///   on show).
223/// - Duplicate name across two entries.
224/// - Empty token between commas.
225/// - `arrow` paired with `baseline` or `candidate` (the arrow
226///   cell already shows `baseline -> candidate`; pairing those
227///   would render the same data twice). `arrow + delta + %` is
228///   allowed and matches the format-default for
229///   [`DisplayFormat::Arrow`].
230pub fn parse_columns(spec: &str, compare_side: bool) -> anyhow::Result<Vec<Column>> {
231    if spec.trim().is_empty() {
232        return Ok(Vec::new());
233    }
234    let allowed: &[Column] = if compare_side {
235        &[
236            Column::Group,
237            Column::Threads,
238            Column::Metric,
239            Column::Baseline,
240            Column::Candidate,
241            Column::Delta,
242            Column::Pct,
243            Column::Arrow,
244            Column::Tags,
245            Column::Uptime,
246        ]
247    } else {
248        &[
249            Column::Group,
250            Column::Threads,
251            Column::Metric,
252            Column::Value,
253            Column::Tags,
254            Column::Uptime,
255        ]
256    };
257    let valid_names = allowed
258        .iter()
259        .map(|c| c.cli_name())
260        .collect::<Vec<_>>()
261        .join(", ");
262    let mut out: Vec<Column> = Vec::new();
263    let mut seen: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
264    for entry in spec.split(',') {
265        let entry = entry.trim();
266        if entry.is_empty() {
267            anyhow::bail!(
268                "empty entry in --columns spec {spec:?}; \
269                 entries are comma-separated and must be non-empty"
270            );
271        }
272        let normalized = entry.to_ascii_lowercase();
273        let Some(col) = allowed.iter().copied().find(|c| c.cli_name() == normalized) else {
274            anyhow::bail!(
275                "unknown column {entry:?} in --columns spec {spec:?}; \
276                 must be one of: {valid_names}",
277            );
278        };
279        if !seen.insert(col.cli_name()) {
280            anyhow::bail!(
281                "duplicate column {entry:?} in --columns spec {spec:?}; \
282                 each column may appear at most once"
283            );
284        }
285        out.push(col);
286    }
287    // The arrow cell renders `<baseline> -> <candidate>`, which
288    // visually replaces the separate Baseline / Candidate
289    // columns; pairing arrow with either of those names asks
290    // the renderer to emit the same data twice. Reject at parse
291    // time. Delta and Pct, by contrast, are NOT visually
292    // duplicated by the arrow cell — they remain as numeric
293    // columns alongside Arrow under the format-default
294    // (`compare_columns_for(DisplayFormat::Arrow)` produces
295    // `[Group, Threads, Metric, Arrow, Delta, Pct, Uptime]`),
296    // so user-supplied `--columns` specs can also include them.
297    let has_arrow = out.iter().any(|c| matches!(c, Column::Arrow));
298    let has_redundant_with_arrow = out
299        .iter()
300        .any(|c| matches!(c, Column::Baseline | Column::Candidate));
301    if has_arrow && has_redundant_with_arrow {
302        anyhow::bail!(
303            "column 'arrow' is mutually exclusive with baseline/candidate \
304             — the arrow cell already shows baseline -> candidate. \
305             Pair arrow with delta/% (or use it alone) instead."
306        );
307    }
308    Ok(out)
309}
310
311/// One sub-table emitted by `write_diff` / `write_show`.
312/// `--sections` filters which sub-tables render — every section
313/// not in the filter is suppressed before its emission gate
314/// (zero-suppression, group-by-cgroup gating, etc.) runs, so a
315/// section that would otherwise emit when its data is present
316/// stays silent when omitted from the filter.
317///
318/// Variant order tracks the rendering order in `write_diff`
319/// and `write_show` so iteration over [`Section::ALL`] walks
320/// the table in the order the operator sees it. The
321/// [`Self::cli_name`] tokens are the spelling accepted by
322/// [`parse_sections`] — round-trip through that parser pins the
323/// vocabulary against drift.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
325#[non_exhaustive]
326pub enum Section {
327    /// Per-thread metric table — the primary rows produced by
328    /// `build_row` / `aggregate`, EXCLUDING the taskstats
329    /// genetlink-sourced rows which carry their own
330    /// [`Section::TaskstatsDelay`] tag for separate filtering.
331    /// Always rendered first.
332    Primary,
333    /// `## Derived metrics` section emitted from
334    /// [`CTPROF_DERIVED_METRICS`].
335    Derived,
336    /// Cgroup-enrichment table (`cpu_usage_usec`,
337    /// `nr_throttled`, `throttled_usec`, `memory_current`).
338    /// Compare- and show-side both gate on `GroupBy::Cgroup`
339    /// plus a non-empty `cgroup_stats` map; the `--sections`
340    /// filter runs ahead of that gate.
341    CgroupStats,
342    /// `## Cgroup limits / knobs` table — operator-set
343    /// configuration (`cpu.max`, `cpu.weight`, `memory.max`,
344    /// `memory.high`, `pids.current`, `pids.max`).
345    Limits,
346    /// `## memory.stat` long-table — kernel-emitted memory
347    /// counters per cgroup.
348    MemoryStat,
349    /// `## memory.events` long-table — pressure-event counters
350    /// per cgroup.
351    MemoryEvents,
352    /// `## Pressure / <resource>` per-cgroup PSI sub-tables
353    /// (cpu / memory / io / irq).
354    Pressure,
355    /// `## Host pressure / <resource>` host-level PSI
356    /// sub-tables.
357    HostPressure,
358    /// `## smaps_rollup` memory-mapping summary. Compare-side
359    /// rows are keyed per pcomm pattern under default
360    /// normalization (matching the [`GroupBy::Pcomm`] join key)
361    /// or per literal `pcomm[tgid]` PID under
362    /// `CompareOptions::no_thread_normalize`; show-side rows
363    /// are emitted per-PID directly off each captured leader
364    /// thread.
365    Smaps,
366    /// `## sched_ext` global sysfs section (`state`,
367    /// `switch_all`, `nr_rejected`, `hotplug_seq`,
368    /// `enable_seq`).
369    SchedExt,
370    /// Taskstats genetlink-sourced rows in the primary table —
371    /// the 34 fields covering the eight delay-accounting
372    /// categories (`cpu_delay_*`, `blkio_delay_*`,
373    /// `swapin_delay_*`, `freepages_delay_*`,
374    /// `thrashing_delay_*`, `compact_delay_*`, `wpcopy_delay_*`,
375    /// `irq_delay_*`) plus the two memory watermarks
376    /// (`hiwater_rss_bytes`, `hiwater_vm_bytes`). Renders inside
377    /// the primary table alongside [`Section::Primary`] rows;
378    /// each `CtprofMetricDef` carries a [`Self`] tag in its
379    /// `CtprofMetricDef::section` field, and the primary
380    /// table emitter checks
381    /// `DisplayOptions::is_section_enabled` per row so
382    /// `--sections taskstats-delay` shows only the taskstats
383    /// rows, `--sections primary` excludes them, and either
384    /// alone keeps the primary table open. Captured via the
385    /// kernel's TASKSTATS family in `crate::taskstats`.
386    TaskstatsDelay,
387}
388
389impl Section {
390    /// Every variant in rendering order. Single source of
391    /// truth — `parse_sections` walks this slice to validate
392    /// names and the `DisplayOptions::is_section_enabled`
393    /// default-empty case treats it as "all on."
394    pub const ALL: &'static [Section] = &[
395        Section::Primary,
396        Section::TaskstatsDelay,
397        Section::Derived,
398        Section::CgroupStats,
399        Section::Limits,
400        Section::MemoryStat,
401        Section::MemoryEvents,
402        Section::Pressure,
403        Section::HostPressure,
404        Section::Smaps,
405        Section::SchedExt,
406    ];
407
408    /// Canonical CLI name. Round-trips through
409    /// [`parse_sections`].
410    pub fn cli_name(self) -> &'static str {
411        match self {
412            Section::Primary => "primary",
413            Section::TaskstatsDelay => "taskstats-delay",
414            Section::Derived => "derived",
415            Section::CgroupStats => "cgroup-stats",
416            Section::Limits => "cgroup-limits",
417            Section::MemoryStat => "memory-stat",
418            Section::MemoryEvents => "memory-events",
419            Section::Pressure => "pressure",
420            Section::HostPressure => "host-pressure",
421            Section::Smaps => "smaps-rollup",
422            Section::SchedExt => "sched-ext",
423        }
424    }
425
426    /// Returns `true` when this section's data only exists
427    /// under [`GroupBy::Cgroup`] grouping. Five sections live
428    /// behind the cgroup outer-gate in `write_diff` /
429    /// `write_show`: [`CgroupStats`](Section::CgroupStats),
430    /// [`Limits`](Section::Limits),
431    /// [`MemoryStat`](Section::MemoryStat),
432    /// [`MemoryEvents`](Section::MemoryEvents), and
433    /// [`Pressure`](Section::Pressure). Naming any of them
434    /// under `--sections` while using a non-cgroup
435    /// `--group-by` would silently produce zero rows for that
436    /// section — the framework warns the operator instead via
437    /// [`warn_cgroup_only_sections_under_non_cgroup`].
438    pub fn requires_cgroup_grouping(self) -> bool {
439        matches!(
440            self,
441            Section::CgroupStats
442                | Section::Limits
443                | Section::MemoryStat
444                | Section::MemoryEvents
445                | Section::Pressure
446        )
447    }
448}
449
450/// Emit a stderr warning when an explicit `--sections` filter
451/// names a cgroup-only section while `--group-by` is not
452/// [`GroupBy::Cgroup`]. Without the warning, the section would
453/// silently render zero rows (its outer-gate suppresses it),
454/// leaving the operator wondering whether their snapshot lacked
455/// the data or their flag was misconfigured.
456///
457/// Only fires when the filter is explicitly populated — the
458/// default-empty case ("render every section that has data")
459/// is already self-correcting and emits no warning. Non-cgroup
460/// sections in the same explicit filter are not flagged; only
461/// the cgroup-only entries are called out.
462///
463/// Diagnostic shape per cgroup-only entry: one line of the
464/// form `section 'X' requires --group-by cgroup; omitted under
465/// --group-by Y`. The text is pinned by
466/// `format_cgroup_only_section_warning` so a wording drift
467/// surfaces in unit tests rather than at the operator's
468/// terminal.
469pub fn warn_cgroup_only_sections_under_non_cgroup(sections: &[Section], group_by: GroupBy) {
470    if sections.is_empty() || group_by == GroupBy::Cgroup {
471        return;
472    }
473    for section in sections {
474        if section.requires_cgroup_grouping() {
475            eprintln!("{}", format_cgroup_only_section_warning(*section, group_by));
476        }
477    }
478}
479
480/// Render the per-section "requires --group-by cgroup" warning
481/// text. Split from [`warn_cgroup_only_sections_under_non_cgroup`]
482/// so the wording can be unit-tested without capturing stderr.
483/// The `--group-by` axis is rendered via [`group_by_cli_name`]
484/// so the operator-facing label matches the clap value-enum
485/// spelling they typed (`pcomm` / `cgroup` / `comm` /
486/// `comm-exact` / `all`).
487pub(crate) fn format_cgroup_only_section_warning(section: Section, group_by: GroupBy) -> String {
488    format!(
489        "section '{}' requires --group-by cgroup; omitted under --group-by {}",
490        section.cli_name(),
491        group_by_cli_name(group_by),
492    )
493}
494
495/// Operator-facing spelling of a [`GroupBy`] variant — matches
496/// the clap value-enum tokens accepted on the CLI. Centralized
497/// here so the warning surface and any future diagnostic site
498/// share one source of truth.
499fn group_by_cli_name(group_by: GroupBy) -> &'static str {
500    match group_by {
501        GroupBy::Pcomm => "pcomm",
502        GroupBy::Cgroup => "cgroup",
503        GroupBy::Comm => "comm",
504        GroupBy::CommExact => "comm-exact",
505        GroupBy::All => "all",
506    }
507}
508
509/// Parse a CLI `--sections` spec into a typed [`Section`] vec.
510/// Format: comma-separated names matching [`Section::cli_name`].
511/// Whitespace around each name is trimmed. Empty input parses
512/// to an empty `Vec` — caller treats that as "every section
513/// renders" via `DisplayOptions::is_section_enabled`.
514///
515/// Errors (mirrored from [`parse_columns`] so the two CLI
516/// surfaces report drift identically):
517/// - Unknown name (cite the offending token; list valid names).
518/// - Duplicate name across two entries.
519/// - Empty token between commas.
520pub fn parse_sections(spec: &str) -> anyhow::Result<Vec<Section>> {
521    if spec.trim().is_empty() {
522        return Ok(Vec::new());
523    }
524    let valid_names = Section::ALL
525        .iter()
526        .map(|s| s.cli_name())
527        .collect::<Vec<_>>()
528        .join(", ");
529    let mut out: Vec<Section> = Vec::new();
530    let mut seen: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
531    for entry in spec.split(',') {
532        let entry = entry.trim();
533        if entry.is_empty() {
534            anyhow::bail!(
535                "empty entry in --sections spec {spec:?}; \
536                 entries are comma-separated and must be non-empty"
537            );
538        }
539        let normalized = entry.to_ascii_lowercase();
540        let Some(section) = Section::ALL
541            .iter()
542            .copied()
543            .find(|s| s.cli_name() == normalized)
544        else {
545            anyhow::bail!(
546                "unknown section {entry:?} in --sections spec {spec:?}; \
547                 must be one of: {valid_names}",
548            );
549        };
550        if !seen.insert(section.cli_name()) {
551            anyhow::bail!(
552                "duplicate section {entry:?} in --sections spec {spec:?}; \
553                 each section may appear at most once"
554            );
555        }
556        out.push(section);
557    }
558    Ok(out)
559}
560
561/// Parse a CLI `--metrics` spec into a typed
562/// `Vec<&'static str>` of registry names. Format:
563/// comma-separated names that must each match a `name` field
564/// from either [`CTPROF_METRICS`] or
565/// [`CTPROF_DERIVED_METRICS`]. Whitespace around each name
566/// is trimmed. Empty input parses to an empty `Vec` — caller
567/// treats that as "every metric renders" via
568/// `DisplayOptions::is_metric_enabled`, mirroring
569/// [`parse_sections`]'s empty-input semantic.
570///
571/// The returned `&'static str`s point into the registry's own
572/// `name` fields (not into the input `spec`), so the parsed
573/// vector survives the input string going out of scope and
574/// equality checks against registry names are pointer-stable.
575///
576/// Errors (mirrored from [`parse_sections`] / [`parse_columns`]
577/// so the three CLI surfaces report drift identically):
578/// - Unknown name (cite the offending token).
579/// - Duplicate name across two entries.
580/// - Empty token between commas.
581pub fn parse_metrics(spec: &str) -> anyhow::Result<Vec<&'static str>> {
582    if spec.trim().is_empty() {
583        return Ok(Vec::new());
584    }
585    let mut out: Vec<&'static str> = Vec::new();
586    let mut seen: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
587    for entry in spec.split(',') {
588        let entry = entry.trim();
589        if entry.is_empty() {
590            anyhow::bail!(
591                "empty entry in --metrics spec {spec:?}; \
592                 entries are comma-separated and must be non-empty"
593            );
594        }
595        // Linear scan over both registries — name lookup is
596        // not on a hot path. Returns the registry's own
597        // `&'static str` so the parsed vec is pointer-stable
598        // and survives the input string's lifetime.
599        let primary = CTPROF_METRICS
600            .iter()
601            .find(|m| m.name == entry)
602            .map(|m| m.name);
603        let derived = CTPROF_DERIVED_METRICS
604            .iter()
605            .find(|d| d.name == entry)
606            .map(|d| d.name);
607        let Some(name) = primary.or(derived) else {
608            anyhow::bail!(
609                "unknown metric {entry:?} in --metrics spec {spec:?}; \
610                 must be one of the names from `ctprof metric-list` \
611                 (CTPROF_METRICS or CTPROF_DERIVED_METRICS)",
612            );
613        };
614        if !seen.insert(name) {
615            anyhow::bail!(
616                "duplicate metric {entry:?} in --metrics spec {spec:?}; \
617                 each metric may appear at most once"
618            );
619        }
620        out.push(name);
621    }
622    Ok(out)
623}