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}