Skip to main content

nmbrs_runtime/readouts/
parse.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Body-grammar parser for the workload `readouts:` block.
5//! See SRD-63 §5.1.
6//!
7//! Push 3's grammar:
8//!
9//! - **Items** are whitespace-separated. Each item is one
10//!   of:
11//!   1. **Readout call** — bare lower-snake-case
12//!      identifier matching a registered readout name.
13//!      Followed (optionally) by space-separated
14//!      `key=value` options up to the next non-`key=`
15//!      token.
16//!   2. **Parameterised form** — `name:arg` is sugar for
17//!      `name pattern=arg` (the readout's primary option,
18//!      currently the `metric:pattern` shorthand —
19//!      generalised in Push 4).
20//!   3. **Quoted literal** — `"text"` or `'text'`.
21//!      Preserved verbatim.
22//!   4. **Bare punctuation** — a non-identifier byte at an
23//!      item boundary becomes a single-character literal.
24//!
25//! Push 3 punts:
26//! - Color directives (`@RED`, `[#hex]`) — Push 4.
27//! - Wildcard event-slot bindings — Push 4.
28//! - Composition / override resolver (`+`-prefix) — Push 4.
29
30use std::str::Chars;
31
32use super::binder::{BakedBody, LayoutMode, RenderStep};
33use super::color::ColorSpec;
34use super::readout::{Lod, OptionValue, ReadoutOptions};
35use super::registry::Registry;
36
37/// Parse a body string into a [`BakedBody`].
38///
39/// Returns a list of warnings alongside the result so the
40/// workload-load step can promote them to errors under
41/// strict mode (per SRD-15).
42pub fn bake(body: &str) -> Result<(BakedBody, Vec<String>), String> {
43    let mut lex = Lexer::new(body);
44    let mut steps: Vec<RenderStep> = Vec::new();
45    let warnings: Vec<String> = Vec::new();
46    let mut first_item = true;
47
48    while let Some(token) = lex.next_token()? {
49        // Color / style directives are zero-width — they
50        // wrap the *next* item, so they don't get a
51        // joining space and don't reset `first_item`.
52        if let Token::ColorDirective(spec) = token {
53            steps.push(RenderStep::ColorDirective(spec));
54            continue;
55        }
56        // Inter-item joining whitespace renders as a single
57        // space between item outputs. Skipped on the first
58        // item.
59        if !first_item {
60            steps.push(RenderStep::Literal(" ".to_string()));
61        }
62        first_item = false;
63
64        match token {
65            Token::ColorDirective(_) => unreachable!(),
66            Token::Quoted(s) => {
67                steps.push(RenderStep::Literal(s));
68            }
69            Token::Punct(c) => {
70                steps.push(RenderStep::Literal(c.to_string()));
71            }
72            Token::Ident(name) => {
73                // Look ahead for `key=value` options. They
74                // belong to this readout call; the next
75                // non-key=value token starts a fresh item.
76                let mut options = ReadoutOptions::new();
77                let mut lod = Lod::default();
78                let mut layout = LayoutMode::Auto;
79                let mut color: Option<ColorSpec> = None;
80                let mut primary_arg: Option<String> = None;
81
82                // `name:arg` form — sugar for the primary
83                // option. Detected by the lexer pre-pulling
84                // the colon and value when it saw them
85                // attached to the identifier.
86                if let Some(arg) = lex.consume_attached_colon_arg() {
87                    primary_arg = Some(arg);
88                }
89
90                while lex.peek_option().is_some() {
91                    let (key, value) = lex.consume_option_pair()?;
92                    apply_option(
93                        &key,
94                        value,
95                        &mut lod,
96                        &mut layout,
97                        &mut color,
98                        &mut options,
99                        &mut primary_arg,
100                    )?;
101                }
102
103                let readout = Registry::lookup(&name).ok_or_else(|| {
104                    let known = Registry::all_names().join(", ");
105                    format!("readouts: unknown readout name '{name}'. Known: {known}")
106                })?;
107                // Push 9b: the `name:arg` colon-shorthand
108                // routes its argument into the option store
109                // under the conventional key `pattern` —
110                // that's what every readout's primary
111                // option currently uses (matching the
112                // SRD-63 §5.1 example
113                // `metric:recall* ≡ metric pattern="recall*"`).
114                if let Some(arg) = primary_arg {
115                    options.set("pattern", OptionValue::Str(arg));
116                }
117                steps.push(RenderStep::Render {
118                    readout,
119                    lod,
120                    layout,
121                    options,
122                    color,
123                });
124            }
125        }
126    }
127
128    Ok((BakedBody::from_steps(steps), warnings))
129}
130
131fn apply_option(
132    key: &str,
133    value: OptionValue,
134    lod: &mut Lod,
135    layout: &mut LayoutMode,
136    color: &mut Option<ColorSpec>,
137    options: &mut ReadoutOptions,
138    _primary_arg: &mut Option<String>,
139) -> Result<(), String> {
140    match key {
141        "lod" => {
142            *lod = parse_lod(&value).map_err(|e| format!("readouts: lod=…: {e}"))?;
143        }
144        "layout" => {
145            *layout = parse_layout(&value).map_err(|e| format!("readouts: layout=…: {e}"))?;
146        }
147        "color" | "style" => {
148            // `color=` and `style=` are aliases — both
149            // route through the same parser. The
150            // distinction in §5.2 is editorial only:
151            // `style=ERROR` reads better than `color=ERROR`,
152            // but both produce an `ColorSpec::Style(...)`
153            // entry that resolves through the active
154            // palette at render time.
155            let token = match value {
156                OptionValue::Str(s) => s,
157                other => {
158                    return Err(format!(
159                        "readouts: {key}= must be a string token (RED, BRIGHT_GREEN, \
160                     #aabbcc, ERROR, …); got {other:?}"
161                    ));
162                }
163            };
164            *color = Some(ColorSpec::parse(&token).ok_or_else(|| {
165                format!(
166                    "readouts: {key}=…: unknown colour / style '{token}' \
167                     (RED|BRIGHT_RED|#rrggbb|ERROR|INFO|…)"
168                )
169            })?);
170        }
171        // Push 9b: every other key falls through to the
172        // option store. Readouts read their own
173        // domain-specific options (precision=, unit=,
174        // pattern=, …) via `ReadoutOptions::get_*`.
175        // Unknown keys aren't an error — readouts decide
176        // which keys they care about — so an option that
177        // no readout consumes is just ignored.
178        other => {
179            options.set(other, value);
180        }
181    }
182    Ok(())
183}
184
185fn parse_lod(value: &OptionValue) -> Result<Lod, String> {
186    match value {
187        OptionValue::Str(s) => match s.as_str() {
188            "compact" | "1" => Ok(Lod::Compact),
189            "labeled" | "2" => Ok(Lod::Labeled),
190            "expanded" | "3" => Ok(Lod::Expanded),
191            other => Err(format!("unknown LOD '{other}' (compact/labeled/expanded)")),
192        },
193        OptionValue::Int(1) => Ok(Lod::Compact),
194        OptionValue::Int(2) => Ok(Lod::Labeled),
195        OptionValue::Int(3) => Ok(Lod::Expanded),
196        other => Err(format!("LOD must be name or 1..=3, got {other:?}")),
197    }
198}
199
200fn parse_layout(value: &OptionValue) -> Result<LayoutMode, String> {
201    match value {
202        OptionValue::Str(s) => match s.as_str() {
203            "auto" => Ok(LayoutMode::Auto),
204            "inline" => Ok(LayoutMode::Inline),
205            "block" => Ok(LayoutMode::Block),
206            other => Err(format!("unknown layout '{other}' (auto/inline/block)")),
207        },
208        other => Err(format!("layout must be a string, got {other:?}")),
209    }
210}
211
212// ── Lexer ───────────────────────────────────────────────
213
214#[derive(Debug, PartialEq)]
215enum Token {
216    Ident(String),
217    Quoted(String),
218    Punct(char),
219    /// `@RED` / `@INFO` / `@#aabbcc` / `[#aabbcc]`. Single-
220    /// shot inline color directive — wraps the next non-
221    /// directive item.
222    ColorDirective(ColorSpec),
223}
224
225struct Lexer<'a> {
226    src: &'a str,
227    pos: usize,
228    /// `name:arg` attaches the colon and arg to the
229    /// identifier; the lexer parks that arg here so the
230    /// caller can pull it after the Ident token.
231    pending_colon_arg: Option<String>,
232}
233
234impl<'a> Lexer<'a> {
235    fn new(src: &'a str) -> Self {
236        Self {
237            src,
238            pos: 0,
239            pending_colon_arg: None,
240        }
241    }
242
243    fn rest(&self) -> &str {
244        &self.src[self.pos..]
245    }
246
247    fn skip_ws(&mut self) {
248        while let Some(c) = self.rest().chars().next() {
249            if c.is_whitespace() {
250                self.pos += c.len_utf8();
251            } else {
252                break;
253            }
254        }
255    }
256
257    fn next_token(&mut self) -> Result<Option<Token>, String> {
258        self.skip_ws();
259        let mut chars = self.rest().chars();
260        let Some(c) = chars.next() else {
261            return Ok(None);
262        };
263        if c == '"' || c == '\'' {
264            return self.read_quoted(c).map(Some);
265        }
266        if c == '@' {
267            return self.read_at_color().map(Some);
268        }
269        if c == '[' && self.rest().starts_with("[#") {
270            return self.read_bracketed_hex().map(Some);
271        }
272        if is_ident_start(c) {
273            return self.read_ident().map(Some);
274        }
275        // Skip `key=…` here? No — `key=` starts with an
276        // ident byte, handled above. A bare non-ident byte
277        // becomes a single-char literal.
278        self.pos += c.len_utf8();
279        Ok(Some(Token::Punct(c)))
280    }
281
282    /// Read an `@TOKEN` color directive. Token is either
283    /// an uppercase color/style name (`@RED`, `@INFO`) or
284    /// a hex literal (`@#aabbcc`).
285    fn read_at_color(&mut self) -> Result<Token, String> {
286        self.pos += 1; // consume '@'
287        let start = self.pos;
288        while let Some(c) = self.rest().chars().next() {
289            if c.is_ascii_alphanumeric() || c == '_' || c == '#' {
290                self.pos += c.len_utf8();
291            } else {
292                break;
293            }
294        }
295        let raw = &self.src[start..self.pos];
296        if raw.is_empty() {
297            return Err("readouts: stray `@` with no colour token after it".to_string());
298        }
299        let spec = ColorSpec::parse(raw).ok_or_else(|| {
300            format!(
301                "readouts: unknown colour / style '@{raw}' (RED|BRIGHT_RED|#rrggbb|ERROR|INFO|…)"
302            )
303        })?;
304        Ok(Token::ColorDirective(spec))
305    }
306
307    /// Read a `[#hex]` bracketed hex color directive.
308    fn read_bracketed_hex(&mut self) -> Result<Token, String> {
309        self.pos += 1; // consume '['
310        let start = self.pos;
311        while let Some(c) = self.rest().chars().next() {
312            if c == ']' {
313                break;
314            }
315            self.pos += c.len_utf8();
316        }
317        let raw = &self.src[start..self.pos];
318        if !self.rest().starts_with(']') {
319            return Err(format!(
320                "readouts: unterminated `[…]` color directive at byte {}",
321                start - 1
322            ));
323        }
324        self.pos += 1; // consume ']'
325        let spec = ColorSpec::parse(raw).ok_or_else(|| {
326            format!(
327                "readouts: invalid bracketed colour '[{raw}]' (expected `[#rrggbb]` or `[#rgb]`)"
328            )
329        })?;
330        Ok(Token::ColorDirective(spec))
331    }
332
333    fn read_quoted(&mut self, quote: char) -> Result<Token, String> {
334        let mut chars = self.rest().chars();
335        chars.next(); // consume opening quote
336        let inner_start = self.pos + quote.len_utf8();
337        let mut inner_end = inner_start;
338        for c in chars {
339            if c == quote {
340                break;
341            }
342            inner_end += c.len_utf8();
343        }
344        if inner_end >= self.src.len() {
345            return Err(format!("unterminated quoted literal at byte {}", self.pos));
346        }
347        let s = self.src[inner_start..inner_end].to_string();
348        self.pos = inner_end + quote.len_utf8();
349        Ok(Token::Quoted(s))
350    }
351
352    fn read_ident(&mut self) -> Result<Token, String> {
353        let start = self.pos;
354        while let Some(c) = self.rest().chars().next() {
355            if is_ident_cont(c) {
356                self.pos += c.len_utf8();
357            } else {
358                break;
359            }
360        }
361        let name = self.src[start..self.pos].to_string();
362
363        // Attached colon-arg: `name:arg` where `arg` is a
364        // non-whitespace run that may contain glob chars
365        // (`*`, `?`), digits, etc. but no `=`.
366        if self.rest().starts_with(':') && !self.rest().starts_with(":=")
367        // future-proof
368        {
369            // peek: is the next byte after `:` something
370            // that could start a value? Quoted, ident, glob
371            // char, digit. If yes, attach.
372            let after_colon = &self.src[self.pos + 1..];
373            if let Some(c) = after_colon.chars().next()
374                && (is_ident_start(c)
375                    || c == '"'
376                    || c == '\''
377                    || c == '*'
378                    || c == '?'
379                    || c.is_ascii_digit())
380            {
381                self.pos += 1; // consume ':'
382                let arg = self.read_unquoted_value()?;
383                self.pending_colon_arg = Some(arg);
384            }
385        }
386        Ok(Token::Ident(name))
387    }
388
389    fn consume_attached_colon_arg(&mut self) -> Option<String> {
390        self.pending_colon_arg.take()
391    }
392
393    /// Look ahead: is the next item a `key=value` (or
394    /// `key:value` colon-shorthand for structural keys)
395    /// that belongs to the current readout call? SRD-63
396    /// Push 9f extends peek to honour the colon form for
397    /// `lod:compact` / `layout:block` / `color:RED` /
398    /// `style:ERROR` — symmetric with the long form. Bare
399    /// names (no separator) terminate option parsing and
400    /// start a fresh item per the prior contract.
401    fn peek_option(&mut self) -> Option<()> {
402        self.skip_ws();
403        let rest = self.rest();
404        let mut chars = rest.chars();
405        let c = chars.next()?;
406        if !is_ident_start(c) {
407            return None;
408        }
409        // Walk an identifier and check for `=` or `:` after it.
410        let mut idx = c.len_utf8();
411        for c in chars {
412            if is_ident_cont(c) {
413                idx += c.len_utf8();
414            } else {
415                break;
416            }
417        }
418        let after = &rest[idx..];
419        let key = &rest[..idx];
420        if after.starts_with('=') {
421            Some(())
422        } else if after.starts_with(':') && is_structural_key(key) {
423            // Only structural keys (lod / layout / color /
424            // style) accept the colon shorthand. Other
425            // `name:value` patterns are reserved for the
426            // per-readout primary-arg shorthand (see
427            // `consume_attached_colon_arg`).
428            Some(())
429        } else {
430            None
431        }
432    }
433
434    fn consume_option_pair(&mut self) -> Result<(String, OptionValue), String> {
435        self.skip_ws();
436        // Read key.
437        let start = self.pos;
438        while let Some(c) = self.rest().chars().next() {
439            if is_ident_cont(c) {
440                self.pos += c.len_utf8();
441            } else {
442                break;
443            }
444        }
445        let key = self.src[start..self.pos].to_string();
446        // Accept either `=` or `:` (the latter only for
447        // structural keys, matching `peek_option`'s gate).
448        match self.rest().chars().next() {
449            Some('=') => {
450                self.pos += 1;
451            }
452            Some(':') if is_structural_key(&key) => {
453                self.pos += 1;
454            }
455            _ => {
456                return Err(format!(
457                    "expected `=` (or `:` for lod/layout/color/style) after option key '{key}'"
458                ));
459            }
460        }
461
462        // Read value: quoted or unquoted.
463        let value = match self.rest().chars().next() {
464            Some('"') => OptionValue::Str(self.read_quoted_value('"')?),
465            Some('\'') => OptionValue::Str(self.read_quoted_value('\'')?),
466            Some(_) => {
467                let raw = self.read_unquoted_value()?;
468                // Try int → float → bool → string.
469                if let Ok(i) = raw.parse::<i64>() {
470                    OptionValue::Int(i)
471                } else if let Ok(f) = raw.parse::<f64>() {
472                    OptionValue::Float(f)
473                } else if raw == "true" {
474                    OptionValue::Bool(true)
475                } else if raw == "false" {
476                    OptionValue::Bool(false)
477                } else {
478                    OptionValue::Str(raw)
479                }
480            }
481            None => return Err(format!("missing value for option '{key}'")),
482        };
483        Ok((key, value))
484    }
485}
486
487/// True for the four structural option keys that accept
488/// the `key:value` colon-shorthand (alongside the long
489/// `key=value` form). Other keys must use `=` so the
490/// per-readout primary-arg shorthand
491/// (`metric:recall*` ≡ `metric pattern="recall*"`) can
492/// stay unambiguous.
493fn is_structural_key(key: &str) -> bool {
494    matches!(key, "lod" | "layout" | "color" | "style")
495}
496
497impl<'a> Lexer<'a> {
498    fn read_quoted_value(&mut self, quote: char) -> Result<String, String> {
499        // Same logic as read_quoted but returns just the string.
500        let inner_start = self.pos + quote.len_utf8();
501        let mut chars = self.src[self.pos..].chars();
502        chars.next(); // open quote
503        let mut inner_end = inner_start;
504        for c in chars {
505            if c == quote {
506                break;
507            }
508            inner_end += c.len_utf8();
509        }
510        if inner_end >= self.src.len() {
511            return Err(format!("unterminated quoted value at byte {}", self.pos));
512        }
513        let s = self.src[inner_start..inner_end].to_string();
514        self.pos = inner_end + quote.len_utf8();
515        Ok(s)
516    }
517
518    fn read_unquoted_value(&mut self) -> Result<String, String> {
519        let start = self.pos;
520        while let Some(c) = self.rest().chars().next() {
521            if c.is_whitespace() || c == '=' {
522                break;
523            }
524            self.pos += c.len_utf8();
525        }
526        Ok(self.src[start..self.pos].to_string())
527    }
528}
529
530fn is_ident_start(c: char) -> bool {
531    c.is_ascii_alphabetic() || c == '_'
532}
533
534fn is_ident_cont(c: char) -> bool {
535    c.is_ascii_alphanumeric() || c == '_'
536}
537
538// `Chars` import not used here, but kept available for
539// future reset / lookahead helpers.
540#[allow(dead_code)]
541fn _unused_chars_marker(_: Chars<'_>) {}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn parses_single_readout_name() {
549        let (baked, _) = bake("phase_outcome").expect("parse");
550        // One step: a single render call.
551        assert_eq!(baked.steps.len(), 1);
552        assert!(matches!(baked.steps[0], RenderStep::Render { .. }));
553    }
554
555    #[test]
556    fn parses_multiple_readouts_with_joining_space() {
557        // phase_outcome phase_status → render, " ", render
558        let (baked, _) = bake("phase_outcome phase_status").expect("parse");
559        assert_eq!(baked.steps.len(), 3);
560        match &baked.steps[1] {
561            RenderStep::Literal(s) => assert_eq!(s, " "),
562            _ => panic!("expected joining literal at index 1"),
563        }
564    }
565
566    #[test]
567    fn parses_quoted_literal() {
568        let (baked, _) = bake(r#"phase_outcome "ok:" phase_status"#).expect("parse");
569        // render, " ", literal "ok:", " ", render
570        assert_eq!(baked.steps.len(), 5);
571        match &baked.steps[2] {
572            RenderStep::Literal(s) => assert_eq!(s, "ok:"),
573            other => panic!("expected literal, got {:?}", other.discriminant()),
574        }
575    }
576
577    #[test]
578    fn unknown_readout_name_is_error() {
579        let err = bake("not_a_real_readout").unwrap_err();
580        assert!(
581            err.contains("unknown readout name 'not_a_real_readout'"),
582            "wrong message: {err}"
583        );
584    }
585
586    #[test]
587    fn parses_lod_option() {
588        let (baked, _) = bake("phase_status lod=compact").expect("parse");
589        match &baked.steps[0] {
590            RenderStep::Render { lod, .. } => {
591                assert_eq!(*lod, Lod::Compact);
592            }
593            _ => panic!("expected Render"),
594        }
595    }
596
597    #[test]
598    fn parses_lod_numeric_alias() {
599        let (baked, _) = bake("phase_status lod=2").expect("parse");
600        match &baked.steps[0] {
601            RenderStep::Render { lod, .. } => assert_eq!(*lod, Lod::Labeled),
602            _ => panic!("expected Render"),
603        }
604    }
605
606    #[test]
607    fn parses_layout_option() {
608        let (baked, _) = bake("phase_status layout=block").expect("parse");
609        match &baked.steps[0] {
610            RenderStep::Render { layout, .. } => {
611                assert_eq!(*layout, LayoutMode::Block);
612            }
613            _ => panic!("expected Render"),
614        }
615    }
616
617    #[test]
618    fn rejects_unknown_lod_value() {
619        let err = bake("phase_status lod=enormous").unwrap_err();
620        assert!(err.contains("unknown LOD"), "wrong message: {err}");
621    }
622
623    #[test]
624    fn rejects_unknown_layout_value() {
625        let err = bake("phase_status layout=sideways").unwrap_err();
626        assert!(err.contains("unknown layout"), "wrong message: {err}");
627    }
628
629    #[test]
630    fn parses_colon_shorthand_arg() {
631        // metric:recall* — colon shorthand attaches the
632        // arg to the readout call. Push 5 will consume
633        // it inside the metric readout; for now we just
634        // verify the lexer doesn't choke and the readout
635        // bakes.
636        let (baked, _) = bake("metric:recall*").expect("parse");
637        assert!(matches!(baked.steps[0], RenderStep::Render { .. }));
638    }
639
640    #[test]
641    fn parses_lod_colon_shorthand() {
642        // Push 9f: structural keys (lod / layout / color /
643        // style) accept the `key:value` shorthand
644        // alongside `key=value`.
645        let (baked, _) = bake("phase_status lod:compact").expect("parse");
646        match &baked.steps[0] {
647            RenderStep::Render { lod, .. } => assert_eq!(*lod, Lod::Compact),
648            _ => panic!("expected Render"),
649        }
650    }
651
652    #[test]
653    fn parses_layout_colon_shorthand() {
654        let (baked, _) = bake("phase_status layout:block").expect("parse");
655        match &baked.steps[0] {
656            RenderStep::Render { layout, .. } => {
657                assert_eq!(*layout, LayoutMode::Block);
658            }
659            _ => panic!("expected Render"),
660        }
661    }
662
663    #[test]
664    fn parses_color_colon_shorthand() {
665        let (baked, _) = bake("phase_outcome color:RED").expect("parse");
666        match &baked.steps[0] {
667            RenderStep::Render { color: Some(c), .. } => {
668                assert_eq!(*c, ColorSpec::Direct("RED"));
669            }
670            _ => panic!("expected Render with colour"),
671        }
672    }
673
674    #[test]
675    fn parses_mixed_colon_and_equals_options() {
676        // Lexer accepts both forms in the same call.
677        let (baked, _) = bake("phase_status lod:compact layout=block").expect("parse");
678        match &baked.steps[0] {
679            RenderStep::Render { lod, layout, .. } => {
680                assert_eq!(*lod, Lod::Compact);
681                assert_eq!(*layout, LayoutMode::Block);
682            }
683            _ => panic!("expected Render"),
684        }
685    }
686
687    #[test]
688    fn non_structural_colon_is_primary_arg_not_option() {
689        // `metric:recall*` is the primary-arg shorthand
690        // (folds into the `pattern` option), NOT a generic
691        // `key:value` option syntax. Confirm by checking the
692        // baked options carry `pattern`, not the literal
693        // bare `metric` key.
694        let (baked, _) = bake("metric:recall*").expect("parse");
695        match &baked.steps[0] {
696            RenderStep::Render { options, .. } => {
697                assert_eq!(options.get_str("pattern"), Some("recall*"));
698            }
699            _ => panic!("expected Render"),
700        }
701    }
702
703    #[test]
704    fn parses_at_color_directive() {
705        let (baked, _) = bake("@RED phase_outcome").expect("parse");
706        // ColorDirective + Render (no leading joining
707        // space because directives are zero-width).
708        assert_eq!(baked.steps.len(), 2);
709        match &baked.steps[0] {
710            RenderStep::ColorDirective(c) => {
711                assert_eq!(*c, ColorSpec::Direct("RED"));
712            }
713            other => panic!("expected ColorDirective, got {other:?}"),
714        }
715        assert!(matches!(baked.steps[1], RenderStep::Render { .. }));
716    }
717
718    #[test]
719    fn parses_at_hex_color() {
720        let (baked, _) = bake("@#7AC166 phase_outcome").expect("parse");
721        match &baked.steps[0] {
722            RenderStep::ColorDirective(c) => {
723                assert_eq!(*c, ColorSpec::Rgb(0x7A, 0xC1, 0x66));
724            }
725            other => panic!("expected ColorDirective, got {other:?}"),
726        }
727    }
728
729    #[test]
730    fn parses_bracketed_hex_color() {
731        let (baked, _) = bake("[#FFF] phase_outcome").expect("parse");
732        match &baked.steps[0] {
733            RenderStep::ColorDirective(c) => {
734                assert_eq!(*c, ColorSpec::Rgb(0xFF, 0xFF, 0xFF));
735            }
736            other => panic!("expected ColorDirective, got {other:?}"),
737        }
738    }
739
740    #[test]
741    fn parses_at_style_name() {
742        let (baked, _) = bake("@ERROR phase_outcome").expect("parse");
743        match &baked.steps[0] {
744            RenderStep::ColorDirective(c) => {
745                assert_eq!(*c, ColorSpec::Style(super::super::color::StyleName::Error));
746            }
747            other => panic!("expected ColorDirective, got {other:?}"),
748        }
749    }
750
751    #[test]
752    fn parses_color_option() {
753        let (baked, _) = bake("phase_outcome color=BLUE").expect("parse");
754        match &baked.steps[0] {
755            RenderStep::Render { color, .. } => {
756                assert_eq!(*color, Some(ColorSpec::Direct("BLUE")));
757            }
758            _ => panic!("expected Render"),
759        }
760    }
761
762    #[test]
763    fn parses_style_option() {
764        let (baked, _) = bake("phase_outcome style=ERROR").expect("parse");
765        match &baked.steps[0] {
766            RenderStep::Render { color, .. } => {
767                assert_eq!(
768                    *color,
769                    Some(ColorSpec::Style(super::super::color::StyleName::Error))
770                );
771            }
772            _ => panic!("expected Render"),
773        }
774    }
775
776    #[test]
777    fn unknown_color_token_is_error() {
778        let err = bake("@notacolor phase_outcome").unwrap_err();
779        assert!(
780            err.contains("unknown colour / style '@notacolor'"),
781            "wrong message: {err}"
782        );
783    }
784
785    #[test]
786    fn unknown_style_option_is_error() {
787        let err = bake("phase_outcome style=notastyle").unwrap_err();
788        assert!(
789            err.contains("unknown colour / style"),
790            "wrong message: {err}"
791        );
792    }
793
794    /// Test-only diagnostic trait: emits a one-token name for
795    /// each `RenderStep` variant when an assertion fails.
796    /// Lives inside the test module so the compiler doesn't
797    /// flag it as dead code on production builds.
798    trait DebugDiscriminant {
799        fn discriminant(&self) -> &'static str;
800    }
801    impl DebugDiscriminant for RenderStep {
802        fn discriminant(&self) -> &'static str {
803            match self {
804                RenderStep::Literal(_) => "Literal",
805                RenderStep::Render { .. } => "Render",
806                RenderStep::ColorDirective(_) => "ColorDirective",
807            }
808        }
809    }
810}