Skip to main content

nmbrs_runtime/readouts/
readout.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The [`Readout`] trait + the orthogonal [`Lod`] and
5//! [`ContentMode`] axes the engine renders against.
6//!
7//! See `docs/SRD/63_status_readouts.md` §1 for the design
8//! contract.
9
10use super::buf::ReadoutBuf;
11use super::context::ReadoutContext;
12use crate::lifecycle::SubjectKind;
13
14/// Level-of-Detail axis. See SRD-63 §3.
15///
16/// - [`Lod::Compact`] — smallest useful form for a trained
17///   operator (single glyph cluster, no labels).
18/// - [`Lod::Labeled`] — fits as much labelled info as the line
19///   width allows; wraps to additional lines if needed.
20/// - [`Lod::Expanded`] — maximum detail, multi-line, supports
21///   auxiliary visuals.
22///
23/// SRD-63 §3.3 invariant: lower LODs are strict information
24/// subsets of higher LODs (`fields(compact) ⊆ fields(labeled)
25/// ⊆ fields(expanded)`). This is a contract on each readout
26/// author, not a runtime check.
27#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
28pub enum Lod {
29    Compact,
30    Labeled,
31    Expanded,
32}
33
34/// Default LOD — `Labeled`, the canonical "show as much
35/// labelled info as fits" form. Matches the body parser's
36/// hard-coded fallback when a step has no `lod=` option,
37/// and the registry's `BakedBody::from_single` baseline.
38impl Default for Lod {
39    fn default() -> Self {
40        Lod::Labeled
41    }
42}
43
44/// Content axis, orthogonal to [`Lod`]. See SRD-63 §3.2.
45///
46/// - [`ContentMode::Value`] — the actual data, normal render.
47/// - [`ContentMode::Explanation`] — same shape and width, but
48///   text describes what the glyphs / abbreviations mean.
49///   Drives the explanation-overlay toggle.
50///
51/// In Push 1 every built-in stubs `Explanation` to a
52/// zero-byte render; Push 7 fills them in.
53#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
54pub enum ContentMode {
55    Value,
56    Explanation,
57}
58
59/// Per-call readout options. Storage = small inline `Vec<(String, OptionValue)>`;
60/// option counts are typically 0-3 so a hashmap is overkill.
61/// SRD-63 §5.1 option grammar — every `key=value` pair the
62/// body parser produces (other than the structural keys
63/// `lod` / `layout` / `color` / `style` which have
64/// dedicated paths) lands here.
65///
66/// Lookup is linear — fine at the option counts we expect.
67/// Two accessor families:
68///
69/// - **Lenient** (`get_str`, `get_int`, …) — return `None`
70///   for both "missing key" and "wrong type." Right for
71///   readouts that fall back to a sensible default.
72/// - **Strict** (`try_get_str`, `try_get_int`, …) — return
73///   `Ok(None)` on missing, `Err(OptionTypeMismatch)` on
74///   type mismatch. Right for readouts that should reject
75///   typo'd configurations rather than silently ignore them.
76#[derive(Default, Clone, Debug)]
77pub struct ReadoutOptions {
78    kv: Vec<(String, OptionValue)>,
79}
80
81/// Error returned by the `try_get_*` accessor family when
82/// the key is present but holds a value of the wrong type.
83/// `None` is reserved for the missing-key case.
84#[derive(Debug, Clone)]
85pub struct OptionTypeMismatch {
86    pub key: String,
87    pub expected: &'static str,
88    pub actual: OptionValue,
89}
90
91impl std::fmt::Display for OptionTypeMismatch {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(
94            f,
95            "option {key:?} expected {exp}, got {act:?}",
96            key = self.key,
97            exp = self.expected,
98            act = self.actual
99        )
100    }
101}
102
103impl std::error::Error for OptionTypeMismatch {}
104
105impl ReadoutOptions {
106    /// Construct an empty option set.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Insert / overwrite. Last-one-wins on duplicate
112    /// keys, matching the standard YAML / kv-list
113    /// convention.
114    pub fn set(&mut self, key: impl Into<String>, value: OptionValue) {
115        let key = key.into();
116        if let Some(slot) = self.kv.iter_mut().find(|(k, _)| *k == key) {
117            slot.1 = value;
118        } else {
119            self.kv.push((key, value));
120        }
121    }
122
123    /// Borrow the value for `key`, if any.
124    pub fn get(&self, key: &str) -> Option<&OptionValue> {
125        self.kv.iter().find(|(k, _)| k == key).map(|(_, v)| v)
126    }
127
128    /// Lenient: borrow as `&str` if the key holds a
129    /// string value. Returns `None` for missing key OR
130    /// wrong type. Use [`try_get_str`](Self::try_get_str)
131    /// when the distinction matters.
132    pub fn get_str(&self, key: &str) -> Option<&str> {
133        match self.get(key)? {
134            OptionValue::Str(s) => Some(s),
135            _ => None,
136        }
137    }
138
139    /// Lenient: extract the integer value, if any.
140    pub fn get_int(&self, key: &str) -> Option<i64> {
141        match self.get(key)? {
142            OptionValue::Int(n) => Some(*n),
143            _ => None,
144        }
145    }
146
147    /// Lenient: extract the float value, casting from
148    /// `Int` when needed.
149    pub fn get_float(&self, key: &str) -> Option<f64> {
150        match self.get(key)? {
151            OptionValue::Float(f) => Some(*f),
152            OptionValue::Int(n) => Some(*n as f64),
153            _ => None,
154        }
155    }
156
157    /// Lenient: extract the bool value, if any.
158    pub fn get_bool(&self, key: &str) -> Option<bool> {
159        match self.get(key)? {
160            OptionValue::Bool(b) => Some(*b),
161            _ => None,
162        }
163    }
164
165    /// Strict: `Ok(None)` on missing key, `Ok(Some(...))`
166    /// on string match, `Err(...)` when the key holds a
167    /// non-string value. Per `feedback_never_ignore_silently`:
168    /// callers that mean to reject typo'd options use this
169    /// instead of [`get_str`](Self::get_str).
170    pub fn try_get_str(&self, key: &str) -> Result<Option<&str>, OptionTypeMismatch> {
171        match self.get(key) {
172            None => Ok(None),
173            Some(OptionValue::Str(s)) => Ok(Some(s)),
174            Some(other) => Err(OptionTypeMismatch {
175                key: key.to_string(),
176                expected: "string",
177                actual: other.clone(),
178            }),
179        }
180    }
181
182    /// Strict integer accessor. See [`try_get_str`](Self::try_get_str).
183    pub fn try_get_int(&self, key: &str) -> Result<Option<i64>, OptionTypeMismatch> {
184        match self.get(key) {
185            None => Ok(None),
186            Some(OptionValue::Int(n)) => Ok(Some(*n)),
187            Some(other) => Err(OptionTypeMismatch {
188                key: key.to_string(),
189                expected: "integer",
190                actual: other.clone(),
191            }),
192        }
193    }
194
195    /// Strict float accessor. Accepts `Int` (cast to f64)
196    /// alongside `Float`; rejects everything else.
197    pub fn try_get_float(&self, key: &str) -> Result<Option<f64>, OptionTypeMismatch> {
198        match self.get(key) {
199            None => Ok(None),
200            Some(OptionValue::Float(f)) => Ok(Some(*f)),
201            Some(OptionValue::Int(n)) => Ok(Some(*n as f64)),
202            Some(other) => Err(OptionTypeMismatch {
203                key: key.to_string(),
204                expected: "number",
205                actual: other.clone(),
206            }),
207        }
208    }
209
210    /// Strict bool accessor.
211    pub fn try_get_bool(&self, key: &str) -> Result<Option<bool>, OptionTypeMismatch> {
212        match self.get(key) {
213            None => Ok(None),
214            Some(OptionValue::Bool(b)) => Ok(Some(*b)),
215            Some(other) => Err(OptionTypeMismatch {
216                key: key.to_string(),
217                expected: "bool",
218                actual: other.clone(),
219            }),
220        }
221    }
222
223    /// True iff at least one option is set.
224    pub fn is_empty(&self) -> bool {
225        self.kv.is_empty()
226    }
227
228    /// Iterate every (key, value) pair.
229    pub fn iter(&self) -> impl Iterator<Item = (&str, &OptionValue)> {
230        self.kv.iter().map(|(k, v)| (k.as_str(), v))
231    }
232}
233
234/// Typed option value. The body parser produces `Bool` /
235/// `Int` / `Float` / `Str` from the lexed token; the
236/// structural keys `lod=` / `layout=` / `color=` /
237/// `style=` parse into dedicated slots on the render step,
238/// not into this enum, so there's no `Lod` variant —
239/// readouts that want to read a LOD should look at the
240/// step's baked `lod`, not query the option store.
241#[derive(Clone, Debug)]
242pub enum OptionValue {
243    Bool(bool),
244    Int(i64),
245    Float(f64),
246    Str(String),
247}
248
249/// A named, pure rendering unit. See SRD-63 §1 for the design
250/// contract.
251///
252/// Implementations are stateless — every render is driven
253/// entirely by the [`ReadoutContext`] and [`ReadoutOptions`]
254/// passed in. The trait is `Send + Sync` so a single
255/// registry instance can be shared across the executor and
256/// the surface threads without locking.
257pub trait Readout: Send + Sync {
258    /// Stable, lower-snake-case identifier. Workloads
259    /// reference readouts by this name.
260    fn name(&self) -> &'static str;
261
262    /// Subject kinds this readout is willing to render
263    /// against. The binder validates at bake-time that
264    /// every event slot's subject kind is in this list,
265    /// so a workload binding `phase_outcome` to
266    /// `on_session_end` fails loudly rather than rendering
267    /// silent zeros. Required: every readout declares
268    /// what kinds it works against. No default — silent
269    /// fallback to `[Phase]` would defeat the safety net
270    /// the validation layer is built on.
271    fn accepts(&self) -> &'static [SubjectKind];
272
273    /// Render this readout at the given LOD and content
274    /// mode, drawing data from `ctx`, writing into `out`.
275    /// Returns the rendered byte width so the caller can do
276    /// alignment without a second measurement pass.
277    ///
278    /// Hot-path contract: must not allocate beyond what the
279    /// output buffer's growth requires.
280    fn render(
281        &self,
282        ctx: &dyn ReadoutContext,
283        lod: Lod,
284        mode: ContentMode,
285        opts: &ReadoutOptions,
286        out: &mut dyn ReadoutBuf,
287    ) -> usize;
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn try_get_str_distinguishes_missing_from_wrong_type() {
296        let mut opts = ReadoutOptions::new();
297        opts.set("present", OptionValue::Str("hi".into()));
298        opts.set("wrong_type", OptionValue::Int(7));
299
300        assert!(matches!(opts.try_get_str("absent"), Ok(None)));
301        assert!(matches!(opts.try_get_str("present"), Ok(Some("hi"))));
302        assert!(opts.try_get_str("wrong_type").is_err());
303    }
304
305    #[test]
306    fn try_get_int_rejects_string_typo() {
307        let mut opts = ReadoutOptions::new();
308        opts.set("precision", OptionValue::Str("3".into()));
309        let err = opts.try_get_int("precision").unwrap_err();
310        assert_eq!(err.expected, "integer");
311        assert_eq!(err.key, "precision");
312    }
313
314    #[test]
315    fn try_get_float_accepts_int() {
316        let mut opts = ReadoutOptions::new();
317        opts.set("ratio", OptionValue::Int(2));
318        assert_eq!(opts.try_get_float("ratio").unwrap(), Some(2.0));
319    }
320}