Skip to main content

nmbrs_workload/report/
vocab.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Single source of truth for the report grammar's lexical
5//! vocabulary — directives, their CLI flag mapping, and the
6//! valid value space for each.
7//!
8//! See [SRD-64 §4.3](../../../../docs/SRD/64_report_cli.md)
9//! for the design intent. This module is consumed by:
10//!
11//! - the YAML parser in [`super`] (validates input
12//!   directives against [`KIND_DIRECTIVES`]),
13//! - `nmbrs::report_cmd` (builds [`ReportItem`] from CLI
14//!   flags by walking [`directives_for`]),
15//! - `nmbrs::completion::report_node` (offers per-flag
16//!   value providers from [`ValueProvider`]),
17//! - `super::ReportItem::to_yaml_directive_string` (the
18//!   round-trip emitter — uses canonical directive ordering
19//!   from [`directives_for`]).
20//!
21//! Adding a new directive means adding an entry to one of
22//! the per-kind tables in this module. The parser, CLI,
23//! completion, and emitter all pick it up automatically.
24//!
25//! ## Closed sets
26//!
27//! Directives whose value space is a closed set ship the
28//! enumeration here. Tab completion offers exactly these
29//! values; the parser rejects anything else.
30//!
31//! ## Open sets
32//!
33//! Directives whose value space depends on the active
34//! session db (metric names, observed label keys / values)
35//! ship a [`ValueProvider::Db`] tag. Completion plumbing in
36//! `nmbrs::completion` resolves these against the active
37//! session at tab time. The parser does not validate
38//! open-set values — they're free-form strings until the
39//! renderer queries the db.
40
41use super::Kind;
42
43/// Enumeration of value-space kinds for one directive's
44/// argument. Drives both completion suggestions and
45/// (for closed sets) parser-side validation.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ValueProvider {
48    /// Free text — no completion, no validation.
49    /// Examples: `--label`, `--xlabel`, `--ylabel`.
50    Text,
51    /// Closed set of strings. Completion offers exactly
52    /// these; the parser rejects anything else.
53    Closed(&'static [&'static str]),
54    /// Numeric value (`f32` / `u32`). Completion offers
55    /// no suggestions; parser asserts numeric.
56    Number,
57    /// Hex color (`#RRGGBB` / `#RRGGBBAA`). Completion
58    /// offers no suggestions; parser asserts the shape.
59    HexColor,
60    /// File-system path (with `*.yaml` / `*.yml` filter
61    /// for workload paths). Completion plumbing decides.
62    Path { glob: &'static str },
63    /// Metric names from the active session db.
64    /// Completion queries the db at tab time.
65    DbMetricNames,
66    /// Distinct label keys observed in the active session
67    /// db. Completion queries the db at tab time.
68    DbLabelKeys,
69    /// `key=value` pairs where `key` completes from
70    /// [`Self::DbLabelKeys`] and `value` completes from the
71    /// distinct values observed for that key. Multi-valued
72    /// (comma-separated). Used for `--where`.
73    DbLabelKeyValuePairs,
74    /// Strict-JSON object literal. No completion.
75    /// Used for `--series` sub-blocks.
76    Json,
77}
78
79/// One directive: its CLI flag, its YAML directive name, the
80/// kinds it applies to, and the value-space it consumes.
81///
82/// `cli_flag` is always `--<name>`. `yaml_directive` is the
83/// keyword the YAML parser recognises; for some directives
84/// the YAML form is `<name> <value>` (no `=`) and for others
85/// it's `<name>=<value>`. [`yaml_form`] captures that.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Directive {
88    /// CLI long flag (e.g. `--over`, `--palette`).
89    pub cli_flag: &'static str,
90    /// YAML directive keyword (e.g. `over`, `palette`).
91    pub yaml_directive: &'static str,
92    /// Whether the YAML form uses `=` or whitespace
93    /// between keyword and value.
94    pub yaml_form: YamlForm,
95    /// Kinds the directive applies to. Per-item
96    /// declarations on a non-applicable kind warn at parse
97    /// time (SRD-46 §"Style and metadata directives").
98    pub applies_to: KindMask,
99    /// Where the directive's value lands in the AST.
100    pub target: DirectiveTarget,
101    /// Value-space for completion + parser validation.
102    pub value: ValueProvider,
103    /// Whether the flag may appear more than once in one
104    /// command-line invocation (e.g. `--series` is
105    /// repeatable; `--label` is not).
106    pub repeatable: bool,
107}
108
109/// `<keyword> <value>` (whitespace-separated) vs
110/// `<keyword>=<value>` (assignment). The parser accepts
111/// both for some directives by historical accident; this
112/// type captures the **canonical** form the round-trip
113/// emitter uses.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum YamlForm {
116    /// `over cycle`, `by profile`, `where dataset=example`,
117    /// `label "Demo"`, `as plot_demo`.
118    Whitespace,
119    /// `palette=wong`, `agg=mean`, `width=4`, `xscale=log`.
120    Equals,
121}
122
123/// Where a directive's value lives in the parsed AST. The
124/// CLI builder uses this to decide which field to populate;
125/// the round-trip emitter walks targets in canonical order.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum DirectiveTarget {
128    /// `ReportItem::label`.
129    ItemLabel,
130    /// `ReportItem::as_stem`.
131    ItemAsStem,
132    /// One of the [`super::Style`] scalar fields, named by
133    /// `yaml_directive`.
134    StyleField,
135    /// `ReportItem::style.series` (one entry per `--series`).
136    StyleSeries,
137    /// [`super::Style::destinations`] — the output routing set
138    /// (`to stdout, sessiondir`). Rides the style cascade so a
139    /// `defaults:` mapping can route a whole report or group at
140    /// once, but it is routing, not cosmetics.
141    Destinations,
142    /// `ReportItem::body` line — the renderer-consumed
143    /// directives (`over`, `by`, `where`, `agg`, `xlabel`,
144    /// `ylabel`, `xscale`, `yscale`, `metric`).
145    Body,
146}
147
148/// Bitmask of [`Kind`] applicability. A directive may apply
149/// to one kind, several, or all.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct KindMask(pub u8);
152
153impl KindMask {
154    pub const PLOT: KindMask = KindMask(1 << 0);
155    pub const TABLE: KindMask = KindMask(1 << 1);
156    pub const TEXT: KindMask = KindMask(1 << 2);
157    pub const FILE: KindMask = KindMask(1 << 3);
158    pub const DETAILS: KindMask = KindMask(1 << 4);
159
160    /// `Plot ∪ Table` — the figure kinds.
161    pub const FIGURES: KindMask = KindMask(0b00011);
162    /// Every kind.
163    pub const ALL: KindMask = KindMask(0b11111);
164
165    pub const fn contains(self, kind: Kind) -> bool {
166        (self.0 & kind_bit(kind)) != 0
167    }
168
169    pub const fn or(self, other: KindMask) -> KindMask {
170        KindMask(self.0 | other.0)
171    }
172}
173
174const fn kind_bit(kind: Kind) -> u8 {
175    match kind {
176        Kind::Plot => 1 << 0,
177        Kind::Table => 1 << 1,
178        Kind::Text => 1 << 2,
179        Kind::File => 1 << 3,
180        Kind::Details => 1 << 4,
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Closed-set vocabularies
186// ---------------------------------------------------------------------------
187
188/// Aggregation function names accepted by `--agg` /
189/// `agg=<fn>`.
190pub const AGG_FNS: &[&str] = &["mean", "min", "max", "p50", "p99", "sum", "count"];
191
192/// Palette names. Numeric indices (`0`..`7`) are also
193/// accepted by the parser; completion only offers the
194/// named forms — numeric indices are a discoverability
195/// dead-end.
196pub const PALETTE_NAMES: &[&str] = &[
197    "wong",
198    "cividis_5",
199    "ibm",
200    "tol_bright",
201    "tol_high_contrast",
202    "tol_light",
203    "tol_muted",
204    "viridis_5",
205];
206
207/// Line-dash styles.
208pub const LINE_STYLES: &[&str] = &["solid", "dashed", "dotted", "dashdot", "none"];
209
210/// Marker shapes. `auto` cycles a distinct shape per series
211/// (literature-style); the rest are explicit shapes.
212pub const MARKER_SHAPES: &[&str] = &[
213    "auto", "none", "circle", "square", "triangle", "diamond", "plus", "cross",
214];
215
216/// Axis scale modes.
217/// Axis-scale mode keywords accepted by `xscale=` / `yscale=`.
218///
219/// - `linear` — default linear axis, range derived from data.
220/// - `log` — logarithmic axis (base 10). Data ≤ 0 becomes a
221///   plot error. Tick marks land on decade boundaries
222///   automatically.
223/// - `dec` — linear axis but the range is *snapped* to the
224///   nearest decade boundary on each side (powers of 10),
225///   producing tick marks at "round" decimal values.
226/// - `bin` — linear axis snapped to power-of-2 boundaries
227///   (1, 2, 4, 8, 16, …). Useful for plots whose x-axis is a
228///   `limit=` or `concurrency=` swept by doubling.
229pub const AXIS_SCALES: &[&str] = &["linear", "log", "dec", "bin"];
230
231/// SRD-46 output destinations — where a rendered item is
232/// delivered (`to stdout, sessiondir`).
233///
234/// - `sessiondir` — the session directory: the standalone
235///   artifact plus the markdown upsert into `summary.md` (or
236///   the item's `file` target). The default when nothing is
237///   declared, so a run's report is always durable.
238/// - `stdout` / `stderr` — the console form on that stream.
239///   NEVER implied: end-of-run rendering writes to a terminal
240///   only when the workload asks, because stdout is a
241///   workload's op output and a report carries wall-clock
242///   values that make it non-reproducible.
243/// - `none` — render nothing. Suppresses the item without
244///   deleting its declaration.
245pub const DESTINATION_NAMES: &[&str] = &["sessiondir", "stdout", "stderr", "none"];
246
247// ---------------------------------------------------------------------------
248// The directive table — one entry per CLI flag / YAML
249// directive pair. Adding a new directive starts here.
250// ---------------------------------------------------------------------------
251
252/// All directives, in canonical declaration order. The
253/// round-trip emitter walks this in order, picking out
254/// entries applicable to the item's kind.
255///
256/// Ordering matters for the emitter — directives appear in
257/// this order in the emitted YAML so `parse → emit → parse`
258/// is identity. Roughly: identity (label / as) → style →
259/// data shape (over / by / where / agg) → axis (x* / y*) →
260/// per-series sub-blocks.
261pub const ALL_DIRECTIVES: &[Directive] = &[
262    // ── Item identity ────────────────────────────────────
263    Directive {
264        cli_flag: "--label",
265        yaml_directive: "label",
266        yaml_form: YamlForm::Whitespace,
267        applies_to: KindMask::ALL,
268        target: DirectiveTarget::ItemLabel,
269        value: ValueProvider::Text,
270        repeatable: false,
271    },
272    Directive {
273        cli_flag: "--as",
274        yaml_directive: "as",
275        yaml_form: YamlForm::Whitespace,
276        applies_to: KindMask::FIGURES,
277        target: DirectiveTarget::ItemAsStem,
278        value: ValueProvider::Text,
279        repeatable: false,
280    },
281    // ── Output routing ───────────────────────────────────
282    // `to <dest>[, <dest>…]` — where a rendered item is
283    // delivered. One directive carrying the whole comma list
284    // (not repeatable) so the declaration reads as a set and
285    // an inner scope REPLACES an outer one rather than
286    // accumulating destinations the operator can't take back.
287    Directive {
288        cli_flag: "--to",
289        yaml_directive: "to",
290        yaml_form: YamlForm::Whitespace,
291        applies_to: KindMask::ALL,
292        target: DirectiveTarget::Destinations,
293        value: ValueProvider::Closed(DESTINATION_NAMES),
294        repeatable: false,
295    },
296    // ── Style / cosmetics ────────────────────────────────
297    Directive {
298        cli_flag: "--palette",
299        yaml_directive: "palette",
300        yaml_form: YamlForm::Equals,
301        applies_to: KindMask::FIGURES,
302        target: DirectiveTarget::StyleField,
303        value: ValueProvider::Closed(PALETTE_NAMES),
304        repeatable: false,
305    },
306    Directive {
307        cli_flag: "--line",
308        yaml_directive: "line",
309        yaml_form: YamlForm::Equals,
310        applies_to: KindMask::PLOT,
311        target: DirectiveTarget::StyleField,
312        value: ValueProvider::Closed(LINE_STYLES),
313        repeatable: false,
314    },
315    Directive {
316        cli_flag: "--width",
317        yaml_directive: "width",
318        yaml_form: YamlForm::Equals,
319        applies_to: KindMask::PLOT,
320        target: DirectiveTarget::StyleField,
321        value: ValueProvider::Number,
322        repeatable: false,
323    },
324    Directive {
325        cli_flag: "--scale",
326        yaml_directive: "scale",
327        yaml_form: YamlForm::Equals,
328        applies_to: KindMask::PLOT,
329        target: DirectiveTarget::StyleField,
330        value: ValueProvider::Number,
331        repeatable: false,
332    },
333    Directive {
334        cli_flag: "--marker",
335        yaml_directive: "marker",
336        yaml_form: YamlForm::Equals,
337        applies_to: KindMask::PLOT,
338        target: DirectiveTarget::StyleField,
339        value: ValueProvider::Closed(MARKER_SHAPES),
340        repeatable: false,
341    },
342    Directive {
343        cli_flag: "--size",
344        yaml_directive: "size",
345        yaml_form: YamlForm::Equals,
346        applies_to: KindMask::PLOT,
347        target: DirectiveTarget::StyleField,
348        value: ValueProvider::Number,
349        repeatable: false,
350    },
351    Directive {
352        cli_flag: "--color",
353        yaml_directive: "color",
354        yaml_form: YamlForm::Equals,
355        applies_to: KindMask::FIGURES,
356        target: DirectiveTarget::StyleField,
357        value: ValueProvider::HexColor,
358        repeatable: false,
359    },
360    Directive {
361        cli_flag: "--figure-width",
362        yaml_directive: "figure_width",
363        yaml_form: YamlForm::Equals,
364        applies_to: KindMask::FIGURES,
365        target: DirectiveTarget::StyleField,
366        value: ValueProvider::Number,
367        repeatable: false,
368    },
369    Directive {
370        cli_flag: "--figure-height",
371        yaml_directive: "figure_height",
372        yaml_form: YamlForm::Equals,
373        applies_to: KindMask::FIGURES,
374        target: DirectiveTarget::StyleField,
375        value: ValueProvider::Number,
376        repeatable: false,
377    },
378    // ── Data shape (body directives) ─────────────────────
379    Directive {
380        cli_flag: "--metric",
381        yaml_directive: "metric",
382        yaml_form: YamlForm::Whitespace,
383        applies_to: KindMask::FIGURES,
384        target: DirectiveTarget::Body,
385        value: ValueProvider::DbMetricNames,
386        repeatable: false,
387    },
388    Directive {
389        cli_flag: "--over",
390        yaml_directive: "over",
391        yaml_form: YamlForm::Whitespace,
392        applies_to: KindMask::FIGURES,
393        target: DirectiveTarget::Body,
394        value: ValueProvider::DbLabelKeys,
395        repeatable: false,
396    },
397    Directive {
398        cli_flag: "--by",
399        yaml_directive: "by",
400        yaml_form: YamlForm::Whitespace,
401        applies_to: KindMask::FIGURES,
402        target: DirectiveTarget::Body,
403        value: ValueProvider::DbLabelKeys,
404        repeatable: false,
405    },
406    Directive {
407        cli_flag: "--where",
408        yaml_directive: "where",
409        yaml_form: YamlForm::Whitespace,
410        applies_to: KindMask::FIGURES,
411        target: DirectiveTarget::Body,
412        value: ValueProvider::DbLabelKeyValuePairs,
413        repeatable: false,
414    },
415    Directive {
416        cli_flag: "--agg",
417        yaml_directive: "agg",
418        yaml_form: YamlForm::Equals,
419        applies_to: KindMask::FIGURES,
420        target: DirectiveTarget::Body,
421        value: ValueProvider::Closed(AGG_FNS),
422        repeatable: false,
423    },
424    // ── Axis labels / scales (plot only) ─────────────────
425    Directive {
426        cli_flag: "--xlabel",
427        yaml_directive: "xlabel",
428        yaml_form: YamlForm::Equals,
429        applies_to: KindMask::PLOT,
430        target: DirectiveTarget::Body,
431        value: ValueProvider::Text,
432        repeatable: false,
433    },
434    Directive {
435        cli_flag: "--ylabel",
436        yaml_directive: "ylabel",
437        yaml_form: YamlForm::Equals,
438        applies_to: KindMask::PLOT,
439        target: DirectiveTarget::Body,
440        value: ValueProvider::Text,
441        repeatable: false,
442    },
443    Directive {
444        cli_flag: "--x-scale",
445        yaml_directive: "x-scale",
446        yaml_form: YamlForm::Equals,
447        applies_to: KindMask::PLOT,
448        target: DirectiveTarget::Body,
449        value: ValueProvider::Closed(AXIS_SCALES),
450        repeatable: false,
451    },
452    Directive {
453        cli_flag: "--y-scale",
454        yaml_directive: "y-scale",
455        yaml_form: YamlForm::Equals,
456        applies_to: KindMask::PLOT,
457        target: DirectiveTarget::Body,
458        value: ValueProvider::Closed(AXIS_SCALES),
459        repeatable: false,
460    },
461    // ── Per-series style override (repeatable) ───────────
462    //
463    // `style <key>=<value>:<directives>` overrides scalar
464    // style fields (line / width / marker / size / color /
465    // palette) for the series whose discriminator matches
466    // `<key>=<value>`. Series-partition selection is no
467    // longer a plot directive — `query: avg(…) by (k, profile)`
468    // with `x: <one-of-those>` is the canonical declaration,
469    // and the renderer auto-derives partition labels from the
470    // result-set labels minus the X axis. That makes "what
471    // dimensions split the series" a property of the
472    // MetricsQL aggregation surface rather than something the
473    // plot grammar duplicates.
474    Directive {
475        cli_flag: "--style",
476        yaml_directive: "style",
477        yaml_form: YamlForm::Whitespace,
478        applies_to: KindMask::FIGURES,
479        target: DirectiveTarget::StyleSeries,
480        value: ValueProvider::Json,
481        repeatable: true,
482    },
483];
484
485/// Directives applicable to one [`Kind`], in canonical
486/// emit order. Filters [`ALL_DIRECTIVES`] by
487/// [`Directive::applies_to`].
488pub fn directives_for(kind: Kind) -> impl Iterator<Item = &'static Directive> {
489    ALL_DIRECTIVES
490        .iter()
491        .filter(move |d| d.applies_to.contains(kind))
492}
493
494/// Look up the directive for a given CLI flag (without
495/// the leading `--`). Returns `None` for unknown flags.
496pub fn directive_by_cli_flag(flag: &str) -> Option<&'static Directive> {
497    ALL_DIRECTIVES.iter().find(|d| {
498        // Accept both `--foo` and `foo` for ergonomics.
499        d.cli_flag == flag
500            || d.cli_flag
501                .strip_prefix("--")
502                .is_some_and(|stripped| stripped == flag)
503    })
504}
505
506/// Look up the directive for a given YAML directive
507/// keyword. Returns `None` for unknown keywords.
508pub fn directive_by_yaml_keyword(keyword: &str) -> Option<&'static Directive> {
509    ALL_DIRECTIVES.iter().find(|d| d.yaml_directive == keyword)
510}
511
512/// All CLI flags applicable to one kind, suitable for
513/// passing to `StrictNode::leaf_with_flags`. Stable
514/// order, matches [`ALL_DIRECTIVES`].
515pub fn cli_flags_for(kind: Kind) -> Vec<&'static str> {
516    directives_for(kind).map(|d| d.cli_flag).collect()
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn every_directive_has_a_unique_cli_flag() {
525        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
526        for d in ALL_DIRECTIVES {
527            assert!(
528                seen.insert(d.cli_flag),
529                "duplicate cli_flag '{}'",
530                d.cli_flag
531            );
532        }
533    }
534
535    #[test]
536    fn every_directive_has_a_unique_yaml_keyword() {
537        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
538        for d in ALL_DIRECTIVES {
539            assert!(
540                seen.insert(d.yaml_directive),
541                "duplicate yaml_directive '{}'",
542                d.yaml_directive
543            );
544        }
545    }
546
547    #[test]
548    fn every_cli_flag_starts_with_double_dash() {
549        for d in ALL_DIRECTIVES {
550            assert!(
551                d.cli_flag.starts_with("--"),
552                "cli_flag '{}' missing -- prefix",
553                d.cli_flag
554            );
555        }
556    }
557
558    #[test]
559    fn directives_for_plot_includes_axis_directives() {
560        let plot: Vec<&str> = directives_for(Kind::Plot).map(|d| d.cli_flag).collect();
561        assert!(plot.contains(&"--xlabel"));
562        assert!(plot.contains(&"--ylabel"));
563        assert!(plot.contains(&"--x-scale"));
564        assert!(plot.contains(&"--y-scale"));
565        assert!(plot.contains(&"--marker"));
566        assert!(plot.contains(&"--line"));
567    }
568
569    #[test]
570    fn directives_for_table_excludes_axis_and_marker_directives() {
571        let table: Vec<&str> = directives_for(Kind::Table).map(|d| d.cli_flag).collect();
572        assert!(!table.contains(&"--xlabel"));
573        assert!(!table.contains(&"--ylabel"));
574        assert!(!table.contains(&"--x-scale"));
575        assert!(!table.contains(&"--y-scale"));
576        assert!(!table.contains(&"--marker"));
577        assert!(!table.contains(&"--line"));
578        assert!(!table.contains(&"--width"));
579        assert!(!table.contains(&"--size"));
580    }
581
582    #[test]
583    fn directives_for_figures_share_data_shape_flags() {
584        // over / by / where / agg / metric apply to both
585        // plot and table — the data-shape group.
586        for kind in [Kind::Plot, Kind::Table] {
587            let flags: Vec<&str> = directives_for(kind).map(|d| d.cli_flag).collect();
588            for required in ["--over", "--by", "--where", "--agg", "--metric"] {
589                assert!(
590                    flags.contains(&required),
591                    "{kind:?} missing data-shape flag {required}"
592                );
593            }
594        }
595    }
596
597    #[test]
598    fn directives_for_text_excludes_figure_directives() {
599        let text: Vec<&str> = directives_for(Kind::Text).map(|d| d.cli_flag).collect();
600        assert!(!text.contains(&"--over"));
601        assert!(!text.contains(&"--metric"));
602        assert!(!text.contains(&"--agg"));
603        // But `--label` should apply (text items can carry
604        // a heading title).
605        assert!(text.contains(&"--label"));
606    }
607
608    #[test]
609    fn directive_by_cli_flag_round_trips() {
610        for d in ALL_DIRECTIVES {
611            let found = directive_by_cli_flag(d.cli_flag)
612                .unwrap_or_else(|| panic!("missing {}", d.cli_flag));
613            assert_eq!(found, d);
614            // Stripped form (`foo` vs `--foo`) also resolves.
615            let stripped = d.cli_flag.trim_start_matches("--");
616            let found_stripped = directive_by_cli_flag(stripped)
617                .unwrap_or_else(|| panic!("missing stripped {}", stripped));
618            assert_eq!(found_stripped, d);
619        }
620    }
621
622    #[test]
623    fn directive_by_yaml_keyword_round_trips() {
624        for d in ALL_DIRECTIVES {
625            assert_eq!(directive_by_yaml_keyword(d.yaml_directive), Some(d));
626        }
627        assert_eq!(directive_by_yaml_keyword("nonexistent"), None);
628    }
629
630    #[test]
631    fn closed_sets_are_non_empty_and_sorted_or_explicit() {
632        // PALETTE_NAMES is sorted alphabetically (per
633        // SRD-46 §"Palettes" — stable indexing rule).
634        let mut sorted = PALETTE_NAMES.to_vec();
635        sorted.sort();
636        // PALETTE_NAMES first lists the default `wong`,
637        // then alphabetic — verify the alphabetic suffix.
638        // The SRD explicitly notes "sorted alphabetically
639        // for stable indexing"; we keep `wong` in slot 0
640        // because it's the documented default. The numeric
641        // index = enumerated position.
642        assert!(!PALETTE_NAMES.is_empty());
643        for set in [AGG_FNS, LINE_STYLES, MARKER_SHAPES, AXIS_SCALES] {
644            assert!(!set.is_empty());
645        }
646    }
647
648    #[test]
649    fn style_is_repeatable_other_directives_are_not() {
650        let style = directive_by_cli_flag("--style").unwrap();
651        assert!(style.repeatable, "--style must be repeatable");
652        for d in ALL_DIRECTIVES {
653            if d.cli_flag != "--style" {
654                assert!(!d.repeatable, "{} should not be repeatable", d.cli_flag);
655            }
656        }
657    }
658
659    #[test]
660    fn kind_mask_figures_covers_plot_and_table_only() {
661        let m = KindMask::FIGURES;
662        assert!(m.contains(Kind::Plot));
663        assert!(m.contains(Kind::Table));
664        assert!(!m.contains(Kind::Text));
665        assert!(!m.contains(Kind::File));
666        assert!(!m.contains(Kind::Details));
667    }
668}