Skip to main content

nmbrs_runtime/readouts/
binder.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`ReadoutBinder`] — runtime adapter between the static
5//! workload binding and the live display surface. See
6//! SRD-63 §7.
7//!
8//! Push 3 ships:
9//!
10//! - The trait surface ([`ReadoutBinder`], [`ReadoutSink`],
11//!   [`LayoutHint`], [`BinderKey`], [`BakedBody`],
12//!   [`RenderStep`]).
13//! - A stateless [`DefaultBinder`] that walks each event's
14//!   bindings in order and renders. Stateful interactive
15//!   variants (focus highlight, LOD overrides,
16//!   overlay-held flag) land in Push 5.
17//! - A line-buffer [`StringSink`] that the terminal-mode
18//!   surface uses; the TUI gets its own `Vec<Span>` sink in
19//!   Push 5.
20
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use super::buf::StringBuf;
25use super::context::ReadoutContext;
26use super::readout::{ContentMode, Lod, Readout, ReadoutOptions};
27use crate::lifecycle::EventType;
28
29/// Reference-counted readout handle. The `Registry` returns
30/// these by wrapping unit-struct builtins in `Arc::new`;
31/// per-workload custom readouts (planned for Push 4 of the
32/// SRD-63 follow-on work) ride the same shape. Cheap to
33/// clone — refcount bump only — and hands a `&dyn Readout`
34/// out via `as_ref` for the actual render call.
35pub type ReadoutHandle = Arc<dyn Readout>;
36
37/// One step in a baked readout body. Either a literal run
38/// of text, a render call against a registered readout, or
39/// a colour / style directive (Push 4) that wraps the next
40/// step in ANSI on/off bytes.
41pub enum RenderStep {
42    /// Literal text emitted verbatim (quoted strings,
43    /// punctuation between readout calls, joining
44    /// whitespace).
45    Literal(String),
46    /// Render a registered readout with the resolved
47    /// options, LOD, and content mode.
48    Render {
49        readout: ReadoutHandle,
50        lod: Lod,
51        layout: LayoutMode,
52        options: ReadoutOptions,
53        /// Per-call colour / style override (from
54        /// `color=` / `style=` options). The binder wraps
55        /// the readout's render in an ANSI on/off pair
56        /// when set.
57        color: Option<crate::readouts::color::ColorSpec>,
58    },
59    /// Inline colour / style directive (`@RED`, `[#hex]`,
60    /// `@INFO`). Single-shot: applies to the next non-
61    /// directive step only. The binder emits ANSI on
62    /// before that step and off after it.
63    ColorDirective(crate::readouts::color::ColorSpec),
64}
65
66impl std::fmt::Debug for RenderStep {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            RenderStep::Literal(s) => write!(f, "Literal({s:?})"),
70            RenderStep::Render {
71                readout,
72                lod,
73                layout,
74                color,
75                ..
76            } => {
77                write!(
78                    f,
79                    "Render {{ name: {:?}, lod: {lod:?}, layout: {layout:?}, color: {color:?} }}",
80                    readout.name()
81                )
82            }
83            RenderStep::ColorDirective(c) => {
84                write!(f, "ColorDirective({c:?})")
85            }
86        }
87    }
88}
89
90/// Layout intent expressed inside a readout body via the
91/// `layout=` option. See SRD-63 §5.3.1. The binder maps
92/// this to a [`LayoutHint`] for the sink at render time.
93#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
94pub enum LayoutMode {
95    /// Default — pick per LOD: compact ⇒ inline,
96    /// labeled / expanded ⇒ block.
97    #[default]
98    Auto,
99    /// Force inline regardless of LOD.
100    Inline,
101    /// Force block regardless of LOD.
102    Block,
103}
104
105/// What the binder writes to the sink for a single render
106/// step. The sink decides how to honour the hint:
107/// terminal-mode flattens to bytes; the TUI applies focus
108/// decoration around `Focused` wrappers.
109pub enum LayoutHint {
110    /// Ok to share a line with adjacent inline-classified
111    /// readouts.
112    InlineCompact,
113    /// Owns its own line(s); sink line-breaks before and
114    /// after.
115    Block,
116    /// Highlighted by the binder's focus state (Push 5);
117    /// sink applies an offset / background-tint per its
118    /// surface conventions, then defers layout to the
119    /// inner hint.
120    Focused(Box<LayoutHint>),
121}
122
123/// Keyboard events the interactive surface forwards to the
124/// binder via [`ReadoutBinder::on_key`]. Push 3's stateless
125/// default binder ignores them; Push 5's
126/// `TuiReadoutBinder` interprets them.
127pub enum BinderKey {
128    /// Move focus to the next readout in the current
129    /// event slot.
130    CycleFocusNext,
131    /// Move focus to the previous readout.
132    CycleFocusPrev,
133    /// Cycle the focused readout's LOD up
134    /// (compact → labeled → expanded → compact).
135    CycleLodUp,
136    /// Cycle the focused readout's LOD down.
137    CycleLodDown,
138    /// Held-key flip — true on key-down, false on key-up.
139    /// While true, every render fires with
140    /// `ContentMode::Explanation`.
141    OverlayHeld(bool),
142}
143
144/// A baked readout body — the artifact the body-grammar
145/// parser produces at workload-load time. Cheap to clone
146/// (steps are owned, but small) and shared across event
147/// fires.
148///
149/// Construction goes through [`new`](Self::new) /
150/// [`from_single`](Self::from_single) /
151/// [`from_steps`](Self::from_steps) — the `steps` field is
152/// `pub(crate)` so the parser can build directly while
153/// preserving room for future invariants (e.g. "first step
154/// must be a Render").
155#[derive(Debug, Default)]
156pub struct BakedBody {
157    pub(crate) steps: Vec<RenderStep>,
158}
159
160impl BakedBody {
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Build from a pre-validated step list. Used by the
166    /// body-grammar parser; tests and integration paths
167    /// that don't go through the parser construct via this
168    /// constructor too.
169    pub fn from_steps(steps: Vec<RenderStep>) -> Self {
170        Self { steps }
171    }
172
173    /// Borrow the step list (read-only). Surfaces that
174    /// need to inspect the baked steps (the TUI binder,
175    /// snapshot capture) take this view rather than
176    /// reaching through the field.
177    pub fn steps(&self) -> &[RenderStep] {
178        &self.steps
179    }
180
181    /// Build from a single registered readout name. Used by
182    /// the workload parser's Form-B path (`on_phase_end:
183    /// phase_outcome`) where no body grammar is involved.
184    pub fn from_single(readout: ReadoutHandle, lod: Lod) -> Self {
185        Self::from_steps(vec![RenderStep::Render {
186            readout,
187            lod,
188            layout: LayoutMode::Auto,
189            options: ReadoutOptions::new(),
190            color: None,
191        }])
192    }
193
194    /// Walk the step list, calling readouts and writing
195    /// literals. The sink mediates layout — the body just
196    /// emits steps in order. Inline colour directives
197    /// (`@RED` / `[#hex]`) wrap the *next* step in ANSI
198    /// on / off bytes; consecutive directives accumulate
199    /// (last one wins for the next step).
200    pub fn fire(&self, ctx: &dyn ReadoutContext, mode: ContentMode, sink: &mut dyn ReadoutSink) {
201        walk_body(self, ctx, mode, sink, &Overrides::default());
202    }
203}
204
205/// Per-fire overrides applied on top of each step's baked
206/// values. Empty for plain [`BakedBody::fire`]; populated
207/// by the TUI binder with focus highlighting and per-body
208/// LOD overrides from the user's keystrokes.
209#[derive(Default, Clone, Copy)]
210struct Overrides {
211    /// Override the baked LOD on every Render step in this
212    /// fire. `None` means "use whatever the step baked".
213    lod: Option<Lod>,
214    /// Wrap the layout hint in `Focused(...)` so the sink
215    /// applies emphasis. `false` means render plain.
216    focused: bool,
217}
218
219/// One walk of a body's step list. The single source of
220/// truth — both the stateless and stateful binders use
221/// it. Inline colour directives (`@RED` / `[#hex]`) wrap
222/// the next step in ANSI on/off bytes; the per-step
223/// `color=` option wins over a pending directive (the
224/// option is the more explicit form).
225fn walk_body(
226    body: &BakedBody,
227    ctx: &dyn ReadoutContext,
228    mode: ContentMode,
229    sink: &mut dyn ReadoutSink,
230    overrides: &Overrides,
231) {
232    let palette = crate::readouts::color::Palette::default();
233    let color_enabled = ctx.use_color();
234    let mut pending_inline: Option<crate::readouts::color::ColorSpec> = None;
235
236    for step in &body.steps {
237        match step {
238            RenderStep::ColorDirective(c) => {
239                pending_inline = Some(c.clone());
240            }
241            RenderStep::Literal(s) => {
242                if let Some(c) = pending_inline.take() {
243                    sink.literal(&c.ansi_open(palette, color_enabled));
244                    sink.literal(s);
245                    sink.literal(c.ansi_close(color_enabled));
246                } else {
247                    sink.literal(s);
248                }
249            }
250            RenderStep::Render {
251                readout,
252                lod,
253                layout,
254                options,
255                color,
256            } => {
257                let effective_lod = overrides.lod.unwrap_or(*lod);
258                let mut hint = layout_hint_for(effective_lod, mode, *layout);
259                if overrides.focused {
260                    hint = LayoutHint::Focused(Box::new(hint));
261                }
262                let effective_color = color.clone().or_else(|| pending_inline.take());
263                if let Some(c) = effective_color {
264                    sink.literal(&c.ansi_open(palette, color_enabled));
265                    sink.render(readout.clone(), ctx, effective_lod, mode, options, hint);
266                    sink.literal(c.ansi_close(color_enabled));
267                } else {
268                    sink.render(readout.clone(), ctx, effective_lod, mode, options, hint);
269                }
270            }
271        }
272    }
273}
274
275/// Per-step layout classification per SRD-63 §7.4.
276/// `mode` doesn't affect layout — the overlay shares shape
277/// and width with the value per §3.2 — so it isn't an
278/// input here. The parameter stays in the signature so a
279/// future mode-aware layout (e.g. an "expand on
280/// Explanation" rule) doesn't require changing every call
281/// site.
282pub fn layout_hint_for(lod: Lod, _mode: ContentMode, layout: LayoutMode) -> LayoutHint {
283    match layout {
284        LayoutMode::Inline => LayoutHint::InlineCompact,
285        LayoutMode::Block => LayoutHint::Block,
286        // Auto: compact ⇒ inline, labeled / expanded ⇒ block.
287        LayoutMode::Auto => match lod {
288            Lod::Compact => LayoutHint::InlineCompact,
289            Lod::Labeled | Lod::Expanded => LayoutHint::Block,
290        },
291    }
292}
293
294// ── Sink ────────────────────────────────────────────────
295
296/// Layout-aware writer the binder drives. Push 3 ships
297/// [`StringSink`] for terminal-mode line emission; Push 5
298/// adds a TUI sink that holds `Vec<Span>`.
299pub trait ReadoutSink {
300    /// Emit a literal run of text. Lives between readout
301    /// renders; honours no layout rule on its own — the
302    /// surrounding renders do.
303    fn literal(&mut self, s: &str);
304
305    /// Render `readout` against `ctx`. The sink applies
306    /// `layout` per its surface conventions before / after
307    /// invoking `readout.render()`.
308    fn render(
309        &mut self,
310        readout: ReadoutHandle,
311        ctx: &dyn ReadoutContext,
312        lod: Lod,
313        mode: ContentMode,
314        options: &ReadoutOptions,
315        layout: LayoutHint,
316    );
317
318    /// Force a line break independent of layout.
319    fn line_break(&mut self);
320}
321
322/// Plain-text line buffer. Concatenates everything into a
323/// single `String`; layout hints `Block` / `InlineCompact`
324/// resolve to "insert a `\n` before/after Block, share
325/// surrounding spaces for Inline." This is what the
326/// terminal-mode `\r\x1b[K…` rewriter consumes.
327///
328/// The sink does not own the eventual stderr write — the
329/// caller pulls bytes out via [`StringSink::take`] and
330/// emits them.
331pub struct StringSink {
332    buf: String,
333    /// True after a Block-classified readout finished, so
334    /// the next non-line-break write inserts a `\n` first.
335    pending_break: bool,
336    /// True on a fresh sink and after an explicit
337    /// `line_break` — the next write doesn't prepend a
338    /// space-or-newline.
339    fresh_line: bool,
340}
341
342impl StringSink {
343    pub fn new() -> Self {
344        Self {
345            buf: String::new(),
346            pending_break: false,
347            fresh_line: true,
348        }
349    }
350
351    pub fn with_capacity(cap: usize) -> Self {
352        Self {
353            buf: String::with_capacity(cap),
354            pending_break: false,
355            fresh_line: true,
356        }
357    }
358
359    /// Consume the sink, returning the rendered string.
360    pub fn take(self) -> String {
361        self.buf
362    }
363
364    /// Borrow the rendered string so far without consuming.
365    pub fn as_str(&self) -> &str {
366        &self.buf
367    }
368}
369
370impl Default for StringSink {
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376impl ReadoutSink for StringSink {
377    fn literal(&mut self, s: &str) {
378        self.flush_pending_break();
379        self.buf.push_str(s);
380        if !s.is_empty() {
381            self.fresh_line = false;
382        }
383    }
384
385    fn render(
386        &mut self,
387        readout: ReadoutHandle,
388        ctx: &dyn ReadoutContext,
389        lod: Lod,
390        mode: ContentMode,
391        options: &ReadoutOptions,
392        layout: LayoutHint,
393    ) {
394        // Strip Focused wrappers — the StringSink has no
395        // visual focus decoration; it's a flat byte stream.
396        // The TUI sink (Push 5) will apply offset / tint
397        // before flattening. The `flatten_layout` return
398        // type carries only the two outcomes the sink can
399        // act on, so the type system removes the
400        // never-reached Focused branch.
401        match flatten_layout(layout) {
402            FlatLayoutHint::Inline => {
403                self.flush_pending_break();
404                let mut buf = StringBuf::new(&mut self.buf);
405                readout.render(ctx, lod, mode, options, &mut buf);
406                self.fresh_line = false;
407            }
408            FlatLayoutHint::Block => {
409                // Insert the block-separator newline up front,
410                // then call the readout. If it writes nothing
411                // (e.g. an error_readout with no errors), roll
412                // back the newline AND preserve the prior
413                // pending_break / fresh_line state so the next
414                // emitter doesn't see a stranded blank line.
415                let snapshot_buf_len = self.buf.len();
416                let snapshot_pending = self.pending_break;
417                let snapshot_fresh = self.fresh_line;
418                let inserted_newline = if !self.fresh_line {
419                    self.buf.push('\n');
420                    true
421                } else {
422                    false
423                };
424                let pre_render_len = self.buf.len();
425                let mut buf = StringBuf::new(&mut self.buf);
426                readout.render(ctx, lod, mode, options, &mut buf);
427                if self.buf.len() == pre_render_len {
428                    // Zero-byte render — roll back so an empty
429                    // readout contributes nothing.
430                    if inserted_newline {
431                        self.buf.truncate(snapshot_buf_len);
432                    }
433                    self.pending_break = snapshot_pending;
434                    self.fresh_line = snapshot_fresh;
435                } else {
436                    self.pending_break = true;
437                    self.fresh_line = false;
438                }
439            }
440        }
441    }
442
443    fn line_break(&mut self) {
444        if !self.fresh_line {
445            self.buf.push('\n');
446        }
447        self.pending_break = false;
448        self.fresh_line = true;
449    }
450}
451
452impl StringSink {
453    fn flush_pending_break(&mut self) {
454        if self.pending_break {
455            if !self.fresh_line {
456                self.buf.push('\n');
457            }
458            self.pending_break = false;
459            self.fresh_line = true;
460        }
461    }
462}
463
464/// The two outcomes a sink that doesn't decorate focus
465/// can act on. `LayoutHint::Focused(inner)` flattens to
466/// whatever `inner` resolves to (recursively, in case the
467/// builder ever stacks wraps — today it doesn't).
468enum FlatLayoutHint {
469    Inline,
470    Block,
471}
472
473fn flatten_layout(layout: LayoutHint) -> FlatLayoutHint {
474    match layout {
475        LayoutHint::InlineCompact => FlatLayoutHint::Inline,
476        LayoutHint::Block => FlatLayoutHint::Block,
477        LayoutHint::Focused(inner) => flatten_layout(*inner),
478    }
479}
480
481// ── Binder trait ────────────────────────────────────────
482
483/// Stateful runtime adapter — drives readouts in response
484/// to events, applies any interactive state (Push 5),
485/// emits ordered render instructions to the sink.
486///
487/// The trait is `&mut self` so stateful impls can mutate
488/// focus / LOD overrides / overlay-held in-place. That's
489/// also why the bound is `Send` (move-across-threads) but
490/// not `Sync` (shared-mutable-access): every fire mutates
491/// state, so concurrent fires would race. Surfaces that
492/// genuinely need cross-thread fire access wrap the binder
493/// in a `Mutex` / channel, not a shared reference.
494pub trait ReadoutBinder: Send {
495    /// Drive every readout bound to `event`. The binder
496    /// applies its interactive state, walks the resolved
497    /// list, and emits render steps to `sink`.
498    fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink);
499
500    /// Forward a keyboard event from the surface. Default
501    /// no-op for non-interactive sinks.
502    fn on_key(&mut self, _key: BinderKey) {}
503}
504
505// ── Default binder ─────────────────────────────────────
506
507/// Stateless default binder. Holds a slot → `Vec<BakedBody>`
508/// map; on `fire(event)` walks the matching slot's bodies
509/// in declaration order. Push 5 introduces a stateful
510/// `TuiReadoutBinder` alongside.
511///
512/// Behaviour:
513/// - No focus state, no LOD overrides, no overlay-held
514///   flag.
515/// - Each baked body fires with `ContentMode::Value` at
516///   the LOD the body itself baked in (per-call
517///   `lod=` option, default `Lod::Labeled`).
518/// - Multiple bodies bound to the same slot fire in
519///   order (the composition rule from SRD-63 §5.5).
520pub struct DefaultBinder {
521    pub(crate) bindings: HashMap<EventType, Vec<BakedBody>>,
522}
523
524impl DefaultBinder {
525    pub fn new() -> Self {
526        Self {
527            bindings: HashMap::new(),
528        }
529    }
530
531    /// Bind a baked body to an event slot. Multiple calls
532    /// with the same event append in order.
533    pub fn bind(&mut self, event: EventType, body: BakedBody) {
534        self.bindings.entry(event).or_default().push(body);
535    }
536
537    /// Replace whatever's at this slot with a single body
538    /// — Form A / B's "scalar means single-element list"
539    /// path.
540    pub fn set(&mut self, event: EventType, body: BakedBody) {
541        self.bindings.insert(event, vec![body]);
542    }
543
544    /// Number of bodies bound to this slot. 0 means the
545    /// event will fire and produce no output — surfaces
546    /// expecting a default render need to seed builtins
547    /// before calling `fire`.
548    pub fn slot_len(&self, event: EventType) -> usize {
549        self.bindings.get(&event).map(|v| v.len()).unwrap_or(0)
550    }
551
552    /// Drain `other`'s bindings into `self`, appending each
553    /// body to its slot. Used by the CLI-override resolver
554    /// to combine the workload-+-default-resolved binder
555    /// with a CLI-supplied extra body.
556    pub fn merge(&mut self, other: DefaultBinder) {
557        for (event, bodies) in other.bindings {
558            self.bindings.entry(event).or_default().extend(bodies);
559        }
560    }
561
562    /// Drop every body bound to `event`. Surfaces that
563    /// want "no readouts at this slot" call this rather
564    /// than binding an empty body.
565    pub fn unbind(&mut self, event: EventType) {
566        self.bindings.remove(&event);
567    }
568
569    /// Remove and return the resolved bodies bound to `event`, leaving the
570    /// slot empty. SRD-100 P2 — the executor extracts the resolved
571    /// `on_update` template from a freshly-built binder to attach to the
572    /// live render handle (`PhaseRenderHandle::bodies`), so the consumer
573    /// can fire it with `&self` without holding the `!Sync` binder.
574    pub fn take_bodies(&mut self, event: EventType) -> Vec<BakedBody> {
575        self.bindings.remove(&event).unwrap_or_default()
576    }
577}
578
579impl Default for DefaultBinder {
580    fn default() -> Self {
581        Self::new()
582    }
583}
584
585impl ReadoutBinder for DefaultBinder {
586    fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink) {
587        if let Some(bodies) = self.bindings.get(&event) {
588            for body in bodies {
589                body.fire(ctx, ContentMode::Value, sink);
590            }
591        }
592    }
593}
594
595/// Build a [`DefaultBinder`] from a workload-level
596/// [`nmbrs_workload::model::ReadoutsBindings`] + a fallback
597/// table of built-in defaults. The workload's bound bodies
598/// replace the matching slot's defaults; unbound slots
599/// fall through to whatever defaults the caller seeds.
600///
601/// Each body string is parsed via
602/// [`crate::readouts::parse::bake`]; parse errors fail the
603/// build with a descriptive error so the caller surfaces it
604/// at workload-load.
605///
606/// Push 3 keeps this thin — Push 4 layers in the
607/// composition / override semantics from SRD-63 §5.4.1
608/// (CLI overrides, `+`-prefix append, silent-override
609/// warning).
610pub fn build_binder_from_workload(
611    bindings: &nmbrs_workload::model::ReadoutsBindings,
612    defaults: &[(EventType, BakedBody)],
613) -> Result<DefaultBinder, String> {
614    use super::parse::bake;
615    let mut binder = DefaultBinder::new();
616
617    // Seed defaults first so unbound slots get them.
618    for (event, body) in defaults {
619        let cloned = BakedBody::from_steps(body.steps.iter().map(clone_step).collect());
620        validate_body_for_event(&cloned, *event)?;
621        binder.bind(*event, cloned);
622    }
623
624    // For every event whose slot has at least one workload
625    // binding, drop the defaults and replace with the
626    // configured bodies.
627    let slots: &[(EventType, &[String])] = &[
628        (
629            EventType::SessionStart,
630            bindings.on_session_start.as_slice(),
631        ),
632        (EventType::SessionEnd, bindings.on_session_end.as_slice()),
633        (EventType::PhaseStart, bindings.on_phase_start.as_slice()),
634        (EventType::PhaseEnd, bindings.on_phase_end.as_slice()),
635        (EventType::EachStart, bindings.on_each_start.as_slice()),
636        (EventType::EachEnd, bindings.on_each_end.as_slice()),
637        (EventType::ScopeStart, bindings.on_scope_start.as_slice()),
638        (EventType::ScopeEnd, bindings.on_scope_end.as_slice()),
639        (EventType::Update, bindings.on_update.as_slice()),
640    ];
641    for (event, bodies) in slots {
642        if bodies.is_empty() {
643            continue;
644        }
645        binder.bindings.remove(event);
646        for body_str in *bodies {
647            let (baked, _warnings) =
648                bake(body_str).map_err(|e| format!("readouts.{}: {e}", event.slot_name()))?;
649            validate_body_for_event(&baked, *event)?;
650            binder.bind(*event, baked);
651        }
652    }
653    Ok(binder)
654}
655
656// ── Stateful TUI binder ─────────────────────────────────
657
658/// Stateful runtime adapter for the TUI surface (SRD-63 §7).
659///
660/// Wraps a [`DefaultBinder`] with three pieces of
661/// interactive state:
662///
663/// - **Focus index** — which baked body in each slot is
664///   currently "selected" by the user. The focused body's
665///   render emits with `LayoutHint::Focused(...)` so the
666///   sink can apply visual emphasis (offset, background
667///   tint).
668/// - **Per-(slot, body) LOD overrides** — keystrokes
669///   cycle the focused body's LOD up / down; the binder
670///   applies overrides on top of the body's baked LOD.
671/// - **Overlay-held flag** — while true, every render
672///   fires with `ContentMode::Explanation` instead of
673///   `Value`. Driven by a held key on the surface.
674///
675/// The binder forwards `fire` through to its inner
676/// `DefaultBinder` for actual rendering, then post-
677/// processes by applying the focus / LOD / overlay state
678/// via a wrapping render walk.
679///
680/// Construction is the same as a default binder — same
681/// slot bindings — but the surface keeps a long-lived
682/// instance so state survives across event fires.
683pub struct TuiReadoutBinder {
684    inner: DefaultBinder,
685    /// Focused body index per slot. `None` means no body
686    /// is focused (or the slot has no bodies).
687    focus: std::collections::HashMap<EventType, Option<usize>>,
688    /// LOD override per `(slot, body_index)` — empty
689    /// means use the baked LOD.
690    lod_overrides: std::collections::HashMap<(EventType, usize), Lod>,
691    /// While true every render emits `Explanation`.
692    overlay_held: bool,
693    /// Last-fired event slot — `on_key` mutates state for
694    /// this slot when no explicit slot is referenced.
695    last_event: Option<EventType>,
696}
697
698impl TuiReadoutBinder {
699    pub fn new() -> Self {
700        Self {
701            inner: DefaultBinder::new(),
702            focus: std::collections::HashMap::new(),
703            lod_overrides: std::collections::HashMap::new(),
704            overlay_held: false,
705            last_event: None,
706        }
707    }
708
709    /// Construct from an existing `DefaultBinder`. Used by
710    /// the activity-init plumbing — build the bindings via
711    /// the standard layered resolver, then wrap in the
712    /// stateful TUI binder for the live surface.
713    pub fn from_default(inner: DefaultBinder) -> Self {
714        Self {
715            inner,
716            focus: std::collections::HashMap::new(),
717            lod_overrides: std::collections::HashMap::new(),
718            overlay_held: false,
719            last_event: None,
720        }
721    }
722
723    pub fn bind(&mut self, event: EventType, body: BakedBody) {
724        self.inner.bind(event, body);
725    }
726
727    pub fn slot_len(&self, event: EventType) -> usize {
728        self.inner.slot_len(event)
729    }
730
731    /// Read the current focus index for a slot. Returns
732    /// `Some(i)` when a body in `event`'s list is focused;
733    /// `None` when the slot has no bodies or focus is
734    /// inactive.
735    pub fn focus_for(&self, event: EventType) -> Option<usize> {
736        self.focus.get(&event).copied().flatten()
737    }
738
739    /// Read the active overlay-held flag. Visible to the
740    /// surface so it can render an "explanation overlay
741    /// active" affordance in chrome.
742    pub fn overlay_held(&self) -> bool {
743        self.overlay_held
744    }
745
746    /// Read the LOD override for a `(slot, body_index)`
747    /// pair, if any.
748    pub fn lod_override(&self, event: EventType, idx: usize) -> Option<Lod> {
749        self.lod_overrides.get(&(event, idx)).copied()
750    }
751}
752
753impl Default for TuiReadoutBinder {
754    fn default() -> Self {
755        Self::new()
756    }
757}
758
759impl ReadoutBinder for TuiReadoutBinder {
760    fn fire(&mut self, event: EventType, ctx: &dyn ReadoutContext, sink: &mut dyn ReadoutSink) {
761        self.last_event = Some(event);
762        // Two paths to Explanation mode:
763        //   1. Local `overlay_held` toggle — set via
764        //      `BinderKey::OverlayHeld(true)`, primarily the
765        //      programmatic test path.
766        //   2. Process-global hold flag stamped by the TUI
767        //      keystroke layer on `?` press, decaying on its
768        //      own auto-revert deadline (see `observer::toggle_explain`).
769        // Either path activates the overlay — first one to
770        // signal wins. The global path is what powers the
771        // operator-visible `?` hold-to-explain UX.
772        let global_held = crate::observer::is_explain_held();
773        let mode = if self.overlay_held || global_held {
774            ContentMode::Explanation
775        } else {
776            ContentMode::Value
777        };
778        let Some(bodies) = self.inner.bindings.get(&event) else {
779            return;
780        };
781        let focus_idx = self.focus.get(&event).copied().flatten();
782        for (i, body) in bodies.iter().enumerate() {
783            // LOD override is a per-(slot, body_index)
784            // entry stamped by the user's `+`/`-` key
785            // cycle. Walk the body's steps replacing each
786            // Render's lod with the override (if any).
787            let override_lod = self.lod_overrides.get(&(event, i)).copied();
788            let focused = focus_idx == Some(i);
789            fire_body_with_overrides(body, ctx, mode, sink, override_lod, focused);
790        }
791    }
792
793    fn on_key(&mut self, key: BinderKey) {
794        match key {
795            BinderKey::OverlayHeld(v) => {
796                self.overlay_held = v;
797            }
798            BinderKey::CycleFocusNext => {
799                self.cycle_focus(1);
800            }
801            BinderKey::CycleFocusPrev => {
802                self.cycle_focus(-1);
803            }
804            BinderKey::CycleLodUp => {
805                self.cycle_focused_lod(1);
806            }
807            BinderKey::CycleLodDown => {
808                self.cycle_focused_lod(-1);
809            }
810        }
811    }
812}
813
814impl TuiReadoutBinder {
815    /// Move the focus pointer for the most-recently-fired
816    /// slot. `delta` is +1 / -1 (cycles wrap).
817    fn cycle_focus(&mut self, delta: i32) {
818        let Some(slot) = self.last_event else {
819            return;
820        };
821        let Some(bodies) = self.inner.bindings.get(&slot) else {
822            return;
823        };
824        let len = bodies.len();
825        if len == 0 {
826            return;
827        }
828        let cur = self.focus.get(&slot).copied().flatten().unwrap_or(0) as i32;
829        let next = (cur + delta).rem_euclid(len as i32) as usize;
830        self.focus.insert(slot, Some(next));
831    }
832
833    /// Cycle the focused body's LOD by `delta` steps
834    /// (compact → labeled → expanded → compact). No-op
835    /// when no body is focused or the slot has no
836    /// bodies.
837    fn cycle_focused_lod(&mut self, delta: i32) {
838        let Some(slot) = self.last_event else {
839            return;
840        };
841        let Some(focus_opt) = self.focus.get(&slot).copied() else {
842            return;
843        };
844        let Some(idx) = focus_opt else {
845            return;
846        };
847
848        // Read the body's baked LOD by walking its first
849        // Render step (the common case — bodies that
850        // start with a Literal don't yield a meaningful
851        // base LOD, so we treat them as Labeled).
852        let bodies = self.inner.bindings.get(&slot);
853        let Some(bodies) = bodies else {
854            return;
855        };
856        let baked_lod = bodies
857            .get(idx)
858            .and_then(first_render_lod)
859            .unwrap_or(Lod::Labeled);
860        let cur = self
861            .lod_overrides
862            .get(&(slot, idx))
863            .copied()
864            .unwrap_or(baked_lod);
865        let next = step_lod(cur, delta);
866        self.lod_overrides.insert((slot, idx), next);
867    }
868}
869
870fn first_render_lod(body: &BakedBody) -> Option<Lod> {
871    body.steps.iter().find_map(|step| match step {
872        RenderStep::Render { lod, .. } => Some(*lod),
873        _ => None,
874    })
875}
876
877fn step_lod(cur: Lod, delta: i32) -> Lod {
878    let order = [Lod::Compact, Lod::Labeled, Lod::Expanded];
879    let pos = order.iter().position(|l| *l == cur).unwrap_or(1) as i32;
880    let next = (pos + delta).rem_euclid(order.len() as i32) as usize;
881    order[next]
882}
883
884/// Walk a baked body with per-fire overrides applied. Thin
885/// wrapper that builds the override struct and delegates
886/// to [`walk_body`] — kept as a named helper so the TUI
887/// binder's call site stays readable.
888fn fire_body_with_overrides(
889    body: &BakedBody,
890    ctx: &dyn ReadoutContext,
891    mode: ContentMode,
892    sink: &mut dyn ReadoutSink,
893    override_lod: Option<Lod>,
894    focused: bool,
895) {
896    walk_body(
897        body,
898        ctx,
899        mode,
900        sink,
901        &Overrides {
902            lod: override_lod,
903            focused,
904        },
905    );
906}
907
908/// Build a binder bound to a single event slot, applying
909/// the SRD-63 §5.4.1 composition / override rules:
910///
911/// - **No workload binding** → seed with `default`.
912/// - **Workload binding with no `+` prefix** → REPLACE
913///   `default` entirely with the workload bodies.
914///   (Rule 2.)
915/// - **Workload binding with one or more `+`-prefixed
916///   entries** → keep `default` and APPEND the prefixed
917///   bodies after it. Plain entries in the same list
918///   still REPLACE the default; mixing `+` and plain in
919///   the same list means "replace with this list (which
920///   happens to also extend somewhere)". (Rule 3.)
921///
922/// Cheap enough to call once per phase or per refresh
923/// tick — Push 3 wires it at activity-init for the two
924/// slots `on_update` and `on_phase_end`.
925pub fn build_event_binder(
926    bindings: &nmbrs_workload::model::ReadoutsBindings,
927    event: EventType,
928    default: BakedBody,
929) -> Result<DefaultBinder, String> {
930    build_event_binder_with_cli(bindings, event, default, None)
931}
932
933/// Same as [`build_event_binder`], with a CLI `--readout`
934/// override layered on top per SRD-63 §8 / Push 8. The
935/// override only applies to the `Update` slot (the only
936/// slot the single `--readout` flag targets); other slots
937/// resolve through the workload + default path. Push 9+
938/// could grow per-event override flags
939/// (`--readout-on-each=…`) if demand arises.
940///
941/// Resolution semantics:
942/// - `cli_override = None`: identical to
943///   [`build_event_binder`] — workload-then-default.
944/// - `cli_override = Some(body)` and `event == Update`:
945///   the body REPLACES whatever the workload + default
946///   path would have bound. A non-default workload
947///   binding being silently replaced emits a warning per
948///   SRD-63 §5.4.1 Rule 2's safety net.
949/// - `cli_override = Some(body)` and `event != Update`:
950///   the override is ignored for this slot — the single
951///   `--readout` flag's contract is on_update only.
952pub fn build_event_binder_with_cli(
953    bindings: &nmbrs_workload::model::ReadoutsBindings,
954    event: EventType,
955    default: BakedBody,
956    cli_override: Option<&str>,
957) -> Result<DefaultBinder, String> {
958    use super::parse::bake;
959
960    if let Some(body_str) = cli_override
961        && event == EventType::Update
962    {
963        // SRD-63 §5.4.1 Rule 2 safety net: warn loudly
964        // when a non-default workload binding is being
965        // silently replaced by the CLI flag.
966        let workload_bodies = bindings.get(event.slot_name());
967        if !workload_bodies.is_empty() {
968            crate::diag!(
969                crate::observer::LogLevel::Warn,
970                "readouts: --readout override [{body}] replaces workload binding {workload:?} \
971                 for slot {slot}. Use a `+` prefix on the override (e.g. `--readout=+x`) \
972                 if you intended to extend rather than replace.",
973                body = body_str,
974                workload = workload_bodies,
975                slot = event.slot_name(),
976            );
977        }
978        let mut binder = DefaultBinder::new();
979        let stripped = body_str.trim_start().strip_prefix('+').unwrap_or(body_str);
980        let plus_prefix = body_str.trim_start().starts_with('+');
981        if plus_prefix {
982            // `+` form on the CLI: keep workload + default
983            // path's bodies and append the override body.
984            let inner = build_event_binder(bindings, event, default)?;
985            binder.merge(inner);
986            let (baked, _) = bake(stripped).map_err(|e| format!("readouts: --readout: {e}"))?;
987            validate_body_for_event(&baked, event)?;
988            binder.bind(event, baked);
989        } else {
990            let (baked, _) = bake(stripped).map_err(|e| format!("readouts: --readout: {e}"))?;
991            validate_body_for_event(&baked, event)?;
992            binder.bind(event, baked);
993        }
994        return Ok(binder);
995    }
996
997    build_event_binder_inner(bindings, event, default)
998}
999
1000fn build_event_binder_inner(
1001    bindings: &nmbrs_workload::model::ReadoutsBindings,
1002    event: EventType,
1003    default: BakedBody,
1004) -> Result<DefaultBinder, String> {
1005    use super::parse::bake;
1006    let mut binder = DefaultBinder::new();
1007    let bodies = bindings.get(event.slot_name());
1008    if bodies.is_empty() {
1009        validate_body_for_event(&default, event)?;
1010        binder.bind(event, default);
1011        return Ok(binder);
1012    }
1013
1014    let any_plain = bodies.iter().any(|b| !b.trim_start().starts_with('+'));
1015    let any_appended = bodies.iter().any(|b| b.trim_start().starts_with('+'));
1016
1017    // Pure-append mode: every entry is `+`-prefixed →
1018    // keep the default and append. Mixed mode (plain +
1019    // append) treats the whole list as a replacement
1020    // that includes the appended entries inline.
1021    if !any_plain && any_appended {
1022        validate_body_for_event(&default, event)?;
1023        binder.bind(event, default);
1024    }
1025
1026    for body_str in bodies {
1027        let stripped = body_str.trim_start().strip_prefix('+').unwrap_or(body_str);
1028        let (baked, _warnings) =
1029            bake(stripped).map_err(|e| format!("readouts.{}: {e}", event.slot_name()))?;
1030        validate_body_for_event(&baked, event)?;
1031        binder.bind(event, baked);
1032    }
1033    Ok(binder)
1034}
1035
1036fn clone_step(step: &RenderStep) -> RenderStep {
1037    match step {
1038        RenderStep::Literal(s) => RenderStep::Literal(s.clone()),
1039        RenderStep::Render {
1040            readout,
1041            lod,
1042            layout,
1043            options,
1044            color,
1045        } => RenderStep::Render {
1046            readout: readout.clone(),
1047            lod: *lod,
1048            layout: *layout,
1049            options: options.clone(),
1050            color: color.clone(),
1051        },
1052        RenderStep::ColorDirective(c) => RenderStep::ColorDirective(c.clone()),
1053    }
1054}
1055
1056/// Bake-time validation: every Render step in `body` must
1057/// accept the firing slot's subject kind. The binder calls
1058/// this before binding, so a workload mistakenly binding
1059/// `phase_status` to `on_session_end` errors at workload-
1060/// load instead of rendering silent zeros at run time.
1061///
1062/// Per `feedback_never_ignore_silently` — every input must
1063/// be acted on or rejected, never discarded.
1064pub fn validate_body_for_event(body: &BakedBody, event: EventType) -> Result<(), String> {
1065    let slot_kind = event.subject_kind();
1066    for step in &body.steps {
1067        if let RenderStep::Render { readout, .. } = step {
1068            let accepted = readout.accepts();
1069            if !accepted.contains(&slot_kind) {
1070                return Err(format!(
1071                    "readouts.{slot}: readout '{name}' does not accept \
1072                     subject kind {slot_kind:?} (accepts {accepted:?})",
1073                    slot = event.slot_name(),
1074                    name = readout.name(),
1075                ));
1076            }
1077        }
1078    }
1079    Ok(())
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use crate::readouts::Registry;
1086
1087    // ── Bake-time subject-kind validation ─────────────────
1088
1089    #[test]
1090    fn validate_rejects_phase_readout_at_session_slot() {
1091        // phase_status accepts only Phase. Binding it at
1092        // `on_session_end` (Session-kind subject) should
1093        // error at workload-load — not silently render zeros.
1094        let bindings = nmbrs_workload::model::ReadoutsBindings {
1095            on_session_end: vec!["phase_status".to_string()],
1096            ..Default::default()
1097        };
1098        let res = build_event_binder(&bindings, EventType::SessionEnd, BakedBody::new());
1099        let err = match res {
1100            Ok(_) => panic!("expected validation error"),
1101            Err(e) => e,
1102        };
1103        assert!(err.contains("phase_status"), "{err}");
1104        assert!(err.contains("does not accept"), "{err}");
1105        assert!(err.contains("Session"), "{err}");
1106    }
1107
1108    #[test]
1109    fn validate_accepts_session_summary_at_session_slot() {
1110        let bindings = nmbrs_workload::model::ReadoutsBindings {
1111            on_session_end: vec!["session_summary".to_string()],
1112            ..Default::default()
1113        };
1114        assert!(
1115            build_event_binder(&bindings, EventType::SessionEnd, BakedBody::new(),).is_ok(),
1116            "session_summary at on_session_end should be valid"
1117        );
1118    }
1119
1120    #[test]
1121    fn validate_accepts_trace_at_every_slot() {
1122        // trace declares it accepts all subject kinds —
1123        // useful as a wildcard diagnostic.
1124        // One case: an event kind paired with a setter that binds `trace`
1125        // at the matching slot.
1126        type SlotCase = (EventType, fn(&mut nmbrs_workload::model::ReadoutsBindings));
1127        let cases: &[SlotCase] = &[
1128            (EventType::SessionEnd, |b| {
1129                b.on_session_end = vec!["trace".into()]
1130            }),
1131            (EventType::PhaseEnd, |b| {
1132                b.on_phase_end = vec!["trace".into()]
1133            }),
1134            (EventType::EachEnd, |b| b.on_each_end = vec!["trace".into()]),
1135            (EventType::ScopeEnd, |b| {
1136                b.on_scope_end = vec!["trace".into()]
1137            }),
1138        ];
1139        for (event, set_slot) in cases {
1140            let mut bindings = nmbrs_workload::model::ReadoutsBindings::default();
1141            set_slot(&mut bindings);
1142            assert!(
1143                build_event_binder(&bindings, *event, BakedBody::new()).is_ok(),
1144                "trace at {event:?} should validate"
1145            );
1146        }
1147    }
1148
1149    /// Sink a Default-bound `phase_outcome` against a tiny
1150    /// hand-rolled context, assert the rendered string is
1151    /// non-empty and contains the expected ✓.
1152    #[test]
1153    fn default_binder_fires_phase_outcome_at_phase_end() {
1154        struct Ctx;
1155        impl ReadoutContext for Ctx {
1156            fn subject_name(&self) -> &str {
1157                "setup"
1158            }
1159            fn subject_seq(&self) -> Option<(usize, usize)> {
1160                Some((1, 2))
1161            }
1162            fn subject_labels(&self) -> &str {
1163                ""
1164            }
1165            fn cycles_completed(&self) -> u64 {
1166                3
1167            }
1168            fn cycles_total(&self) -> u64 {
1169                3
1170            }
1171            fn ops_ok(&self) -> u64 {
1172                3
1173            }
1174            fn errors(&self) -> u64 {
1175                0
1176            }
1177            fn retries(&self) -> u64 {
1178                0
1179            }
1180            fn concurrency(&self) -> usize {
1181                1
1182            }
1183            fn elapsed_secs(&self) -> f64 {
1184                0.01
1185            }
1186            fn consumed(&self) -> u64 {
1187                3
1188            }
1189            fn status_metric_chips(&self) -> String {
1190                String::new()
1191            }
1192            fn depth_indent(&self) -> &str {
1193                ""
1194            }
1195            fn use_color(&self) -> bool {
1196                false
1197            }
1198            fn event(&self) -> EventType {
1199                EventType::PhaseEnd
1200            }
1201        }
1202        let mut binder = DefaultBinder::new();
1203        let phase_outcome = Registry::lookup("phase_outcome").unwrap();
1204        binder.set(
1205            EventType::PhaseEnd,
1206            BakedBody::from_single(phase_outcome, Lod::Labeled),
1207        );
1208
1209        let mut sink = StringSink::new();
1210        binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
1211
1212        let out = sink.take();
1213        assert!(out.contains("✓"), "phase_outcome's ✓ missing: {out}");
1214        assert!(out.contains("[setup]"), "phase name missing: {out}");
1215        // Single-placement rule: the margin owns [n/N]; the body must
1216        // NOT carry a seq prefix.
1217        assert!(!out.contains("[1/2]"), "seq must not appear in body: {out}");
1218    }
1219
1220    #[test]
1221    fn default_binder_dispatches_only_to_matching_event() {
1222        struct Ctx;
1223        impl ReadoutContext for Ctx {
1224            fn subject_name(&self) -> &str {
1225                "x"
1226            }
1227            fn subject_seq(&self) -> Option<(usize, usize)> {
1228                None
1229            }
1230            fn subject_labels(&self) -> &str {
1231                ""
1232            }
1233            fn cycles_completed(&self) -> u64 {
1234                0
1235            }
1236            fn cycles_total(&self) -> u64 {
1237                0
1238            }
1239            fn ops_ok(&self) -> u64 {
1240                0
1241            }
1242            fn errors(&self) -> u64 {
1243                0
1244            }
1245            fn retries(&self) -> u64 {
1246                0
1247            }
1248            fn concurrency(&self) -> usize {
1249                1
1250            }
1251            fn elapsed_secs(&self) -> f64 {
1252                0.0
1253            }
1254            fn consumed(&self) -> u64 {
1255                0
1256            }
1257            fn status_metric_chips(&self) -> String {
1258                String::new()
1259            }
1260            fn depth_indent(&self) -> &str {
1261                ""
1262            }
1263            fn use_color(&self) -> bool {
1264                false
1265            }
1266            fn event(&self) -> EventType {
1267                EventType::PhaseEnd
1268            }
1269        }
1270        let mut binder = DefaultBinder::new();
1271        let phase_outcome = Registry::lookup("phase_outcome").unwrap();
1272        binder.set(
1273            EventType::PhaseEnd,
1274            BakedBody::from_single(phase_outcome, Lod::Labeled),
1275        );
1276
1277        // Fire the wrong event — sink should stay empty.
1278        let mut sink = StringSink::new();
1279        binder.fire(EventType::Update, &Ctx, &mut sink);
1280        assert_eq!(sink.take(), "");
1281    }
1282
1283    #[test]
1284    fn string_sink_block_inserts_newlines() {
1285        let mut sink = StringSink::new();
1286        sink.literal("a");
1287        // simulate a block-render with a fake step:
1288        // we can't easily call a Readout here, so test
1289        // line-break behaviour directly.
1290        sink.line_break();
1291        sink.literal("b");
1292        sink.line_break();
1293        // Multiple consecutive line_breaks are idempotent.
1294        sink.line_break();
1295        sink.literal("c");
1296        assert_eq!(sink.take(), "a\nb\nc");
1297    }
1298
1299    /// Regression: a Block-classified readout that writes nothing
1300    /// must contribute nothing — no stranded blank line. The
1301    /// failure shape was `error_readout` (empty errors) emitting
1302    /// its block-separator newline before discovering it had no
1303    /// content, leaving the prior block-separator pending state +
1304    /// the new newline = visible blank row between phase outcomes.
1305    #[test]
1306    fn empty_block_render_contributes_no_blank_line() {
1307        use crate::lifecycle::SubjectKind;
1308        use crate::readouts::buf::ReadoutBuf;
1309        struct EmptyReadout;
1310        impl Readout for EmptyReadout {
1311            fn name(&self) -> &'static str {
1312                "empty"
1313            }
1314            fn accepts(&self) -> &'static [SubjectKind] {
1315                &[SubjectKind::Phase]
1316            }
1317            fn render(
1318                &self,
1319                _ctx: &dyn ReadoutContext,
1320                _lod: Lod,
1321                _mode: ContentMode,
1322                _opts: &ReadoutOptions,
1323                _out: &mut dyn ReadoutBuf,
1324            ) -> usize {
1325                0
1326            }
1327        }
1328        struct Filler;
1329        impl Readout for Filler {
1330            fn name(&self) -> &'static str {
1331                "filler"
1332            }
1333            fn accepts(&self) -> &'static [SubjectKind] {
1334                &[SubjectKind::Phase]
1335            }
1336            fn render(
1337                &self,
1338                _ctx: &dyn ReadoutContext,
1339                _lod: Lod,
1340                _mode: ContentMode,
1341                _opts: &ReadoutOptions,
1342                out: &mut dyn ReadoutBuf,
1343            ) -> usize {
1344                let _ = out.write_str("filler-text");
1345                "filler-text".len()
1346            }
1347        }
1348        struct Ctx;
1349        impl ReadoutContext for Ctx {
1350            fn subject_name(&self) -> &str {
1351                "x"
1352            }
1353            fn subject_seq(&self) -> Option<(usize, usize)> {
1354                None
1355            }
1356            fn subject_labels(&self) -> &str {
1357                ""
1358            }
1359            fn cycles_completed(&self) -> u64 {
1360                0
1361            }
1362            fn cycles_total(&self) -> u64 {
1363                0
1364            }
1365            fn ops_ok(&self) -> u64 {
1366                0
1367            }
1368            fn errors(&self) -> u64 {
1369                0
1370            }
1371            fn retries(&self) -> u64 {
1372                0
1373            }
1374            fn concurrency(&self) -> usize {
1375                0
1376            }
1377            fn elapsed_secs(&self) -> f64 {
1378                0.0
1379            }
1380            fn consumed(&self) -> u64 {
1381                0
1382            }
1383            fn status_metric_chips(&self) -> String {
1384                String::new()
1385            }
1386            fn depth_indent(&self) -> &str {
1387                ""
1388            }
1389            fn use_color(&self) -> bool {
1390                false
1391            }
1392            fn event(&self) -> EventType {
1393                EventType::PhaseEnd
1394            }
1395        }
1396        let mut sink = StringSink::new();
1397        let opts = ReadoutOptions::new();
1398        let h_filler: ReadoutHandle = std::sync::Arc::new(Filler);
1399        let h_empty: ReadoutHandle = std::sync::Arc::new(EmptyReadout);
1400
1401        // Block A: writes content.
1402        sink.render(
1403            h_filler.clone(),
1404            &Ctx,
1405            Lod::Labeled,
1406            ContentMode::Value,
1407            &opts,
1408            LayoutHint::Block,
1409        );
1410        // Block B: empty render. Must NOT introduce a blank line.
1411        sink.render(
1412            h_empty,
1413            &Ctx,
1414            Lod::Labeled,
1415            ContentMode::Value,
1416            &opts,
1417            LayoutHint::Block,
1418        );
1419        // Block C: writes content.
1420        sink.render(
1421            h_filler,
1422            &Ctx,
1423            Lod::Labeled,
1424            ContentMode::Value,
1425            &opts,
1426            LayoutHint::Block,
1427        );
1428
1429        // Expected: filler + \n + filler — single block separator,
1430        // not filler + \n + \n + filler (which would be the stranded-
1431        // blank-line failure).
1432        assert_eq!(sink.take(), "filler-text\nfiller-text");
1433    }
1434
1435    #[test]
1436    fn layout_hint_auto_picks_inline_for_compact() {
1437        assert!(matches!(
1438            layout_hint_for(Lod::Compact, ContentMode::Value, LayoutMode::Auto),
1439            LayoutHint::InlineCompact
1440        ));
1441        assert!(matches!(
1442            layout_hint_for(Lod::Labeled, ContentMode::Value, LayoutMode::Auto),
1443            LayoutHint::Block
1444        ));
1445        assert!(matches!(
1446            layout_hint_for(Lod::Expanded, ContentMode::Value, LayoutMode::Auto),
1447            LayoutHint::Block
1448        ));
1449    }
1450
1451    #[test]
1452    fn layout_hint_inline_overrides_lod() {
1453        // Force inline at expanded LOD (workload author's
1454        // explicit choice; sink's job to detect overflow).
1455        assert!(matches!(
1456            layout_hint_for(Lod::Expanded, ContentMode::Value, LayoutMode::Inline),
1457            LayoutHint::InlineCompact
1458        ));
1459    }
1460
1461    #[test]
1462    fn layout_hint_block_overrides_lod() {
1463        // Force block at compact LOD (workload author wants
1464        // emphasis on a normally-inline readout).
1465        assert!(matches!(
1466            layout_hint_for(Lod::Compact, ContentMode::Value, LayoutMode::Block),
1467            LayoutHint::Block
1468        ));
1469    }
1470
1471    // ── Composition / override resolver ──────────────────
1472
1473    fn empty_bindings() -> nmbrs_workload::model::ReadoutsBindings {
1474        nmbrs_workload::model::ReadoutsBindings::default()
1475    }
1476
1477    fn default_phase_outcome() -> BakedBody {
1478        BakedBody::from_single(Registry::lookup("phase_outcome").unwrap(), Lod::Labeled)
1479    }
1480
1481    #[test]
1482    fn no_workload_binding_uses_default() {
1483        // Slot is empty → builder uses the supplied default.
1484        let bindings = empty_bindings();
1485        let binder =
1486            build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1487        assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
1488    }
1489
1490    #[test]
1491    fn plain_workload_binding_replaces_default() {
1492        // Rule 2: a plain (non-prefixed) workload binding
1493        // REPLACES the default fully.
1494        let mut bindings = empty_bindings();
1495        bindings.on_phase_end = vec!["trace".to_string()];
1496        let binder =
1497            build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1498        // One body bound — the workload's, default dropped.
1499        assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
1500    }
1501
1502    #[test]
1503    fn plus_prefix_workload_binding_appends_to_default() {
1504        // Rule 3: every entry `+`-prefixed → KEEP default
1505        // and append.
1506        let mut bindings = empty_bindings();
1507        bindings.on_phase_end = vec!["+trace".to_string()];
1508        let binder =
1509            build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1510        // Two bodies: the default + the appended trace.
1511        assert_eq!(binder.slot_len(EventType::PhaseEnd), 2);
1512    }
1513
1514    #[test]
1515    fn multiple_plus_prefix_appends_in_order() {
1516        let mut bindings = empty_bindings();
1517        bindings.on_phase_end = vec!["+trace".to_string(), "+trace".to_string()];
1518        let binder =
1519            build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1520        // default + 2 appended.
1521        assert_eq!(binder.slot_len(EventType::PhaseEnd), 3);
1522    }
1523
1524    #[test]
1525    fn cli_override_replaces_workload_binding_at_update() {
1526        let mut bindings = empty_bindings();
1527        bindings.on_update = vec!["phase_status".to_string()];
1528        let binder = build_event_binder_with_cli(
1529            &bindings,
1530            EventType::Update,
1531            default_phase_outcome(),
1532            Some("trace"),
1533        )
1534        .unwrap();
1535        // CLI override → exactly one body (the override),
1536        // workload's binding dropped.
1537        assert_eq!(binder.slot_len(EventType::Update), 1);
1538    }
1539
1540    #[test]
1541    fn cli_override_plus_prefix_appends_to_workload_resolved() {
1542        let mut bindings = empty_bindings();
1543        bindings.on_update = vec!["phase_status".to_string()];
1544        let binder = build_event_binder_with_cli(
1545            &bindings,
1546            EventType::Update,
1547            default_phase_outcome(),
1548            Some("+trace"),
1549        )
1550        .unwrap();
1551        // workload phase_status + appended trace = 2 bodies.
1552        assert_eq!(binder.slot_len(EventType::Update), 2);
1553    }
1554
1555    #[test]
1556    fn cli_override_only_applies_to_update_slot() {
1557        let bindings = empty_bindings();
1558        let binder = build_event_binder_with_cli(
1559            &bindings,
1560            EventType::PhaseEnd,
1561            default_phase_outcome(),
1562            Some("trace"),
1563        )
1564        .unwrap();
1565        // PhaseEnd ignores --readout — falls back to default.
1566        assert_eq!(binder.slot_len(EventType::PhaseEnd), 1);
1567        // The default body is phase_outcome, not trace; verify
1568        // by re-firing and checking output starts with ✓.
1569        struct Ctx;
1570        impl ReadoutContext for Ctx {
1571            fn subject_name(&self) -> &str {
1572                "x"
1573            }
1574            fn subject_seq(&self) -> Option<(usize, usize)> {
1575                None
1576            }
1577            fn subject_labels(&self) -> &str {
1578                ""
1579            }
1580            fn cycles_completed(&self) -> u64 {
1581                0
1582            }
1583            fn cycles_total(&self) -> u64 {
1584                0
1585            }
1586            fn ops_ok(&self) -> u64 {
1587                0
1588            }
1589            fn errors(&self) -> u64 {
1590                0
1591            }
1592            fn retries(&self) -> u64 {
1593                0
1594            }
1595            fn concurrency(&self) -> usize {
1596                1
1597            }
1598            fn elapsed_secs(&self) -> f64 {
1599                0.0
1600            }
1601            fn consumed(&self) -> u64 {
1602                0
1603            }
1604            fn status_metric_chips(&self) -> String {
1605                String::new()
1606            }
1607            fn depth_indent(&self) -> &str {
1608                ""
1609            }
1610            fn use_color(&self) -> bool {
1611                false
1612            }
1613            fn event(&self) -> EventType {
1614                EventType::PhaseEnd
1615            }
1616        }
1617        let mut binder_local = binder;
1618        let mut sink = StringSink::new();
1619        binder_local.fire(EventType::PhaseEnd, &Ctx, &mut sink);
1620        let out = sink.take();
1621        assert!(
1622            out.contains("✓"),
1623            "default phase_outcome body should fire: {out}"
1624        );
1625    }
1626
1627    #[test]
1628    fn mixed_plain_and_plus_treats_whole_list_as_replacement() {
1629        // Plain entry present in the list → REPLACE mode
1630        // applies to every entry (the `+` prefix becomes
1631        // editorial only, the binding drops the default).
1632        let mut bindings = empty_bindings();
1633        bindings.on_phase_end = vec!["trace".to_string(), "+trace".to_string()];
1634        let binder =
1635            build_event_binder(&bindings, EventType::PhaseEnd, default_phase_outcome()).unwrap();
1636        // Two bodies (the workload's two), no default.
1637        assert_eq!(binder.slot_len(EventType::PhaseEnd), 2);
1638    }
1639
1640    // ── TuiReadoutBinder ─────────────────────────────────
1641
1642    fn make_tui_binder_with_two_bodies() -> TuiReadoutBinder {
1643        let mut binder = TuiReadoutBinder::new();
1644        let phase_outcome = Registry::lookup("phase_outcome").unwrap();
1645        let trace = Registry::lookup("trace").unwrap();
1646        binder.bind(
1647            EventType::PhaseEnd,
1648            BakedBody::from_single(phase_outcome, Lod::Labeled),
1649        );
1650        binder.bind(
1651            EventType::PhaseEnd,
1652            BakedBody::from_single(trace, Lod::Labeled),
1653        );
1654        binder
1655    }
1656
1657    #[test]
1658    fn tui_binder_overlay_held_toggles_mode() {
1659        let mut binder = TuiReadoutBinder::new();
1660        assert!(!binder.overlay_held());
1661        binder.on_key(BinderKey::OverlayHeld(true));
1662        assert!(binder.overlay_held());
1663        binder.on_key(BinderKey::OverlayHeld(false));
1664        assert!(!binder.overlay_held());
1665    }
1666
1667    /// The process-global `?`-toggle flag is an OR-with-local
1668    /// path into Explanation mode: when on, every `fire()`
1669    /// dispatches with `ContentMode::Explanation` regardless
1670    /// of the binder's local `overlay_held` toggle. This is
1671    /// what powers the TUI's `?`-to-explain UX without
1672    /// threading a channel into each binder.
1673    ///
1674    /// Toggle semantics (not hold): one press → on; second
1675    /// press → off. Tests below cover both transitions plus
1676    /// the auto-repeat debounce. We avoid the 10 s auto-revert
1677    /// by manually toggling off rather than waiting for the
1678    /// deadline.
1679    ///
1680    /// Tests in this module that exercise the process-global
1681    /// `EXPLAIN_HELD_UNTIL_NS` / `EXPLAIN_LAST_PRESS_NS` atomics
1682    /// in observer.rs serialize against each other via the
1683    /// mutex below. Production code is correct (debounce +
1684    /// auto-revert is the intended behavior); the issue is that
1685    /// cargo's default parallel test execution lets two tests
1686    /// race on shared static state.
1687    #[test]
1688    fn global_explain_toggle_flips_explanation_mode() {
1689        let _guard = EXPLAIN_GLOBAL_TEST_LOCK
1690            .lock()
1691            .unwrap_or_else(|p| p.into_inner());
1692        // Force baseline off by toggling until off. (A previous
1693        // test may have left it on; the debounce + 10 s deadline
1694        // mean we can't just wait for decay in a unit test.)
1695        // Two toggles in sequence: first might be no-op due to
1696        // debounce, second always applies after a short sleep.
1697        std::thread::sleep(std::time::Duration::from_millis(300));
1698        if crate::observer::is_explain_held() {
1699            crate::observer::toggle_explain();
1700            std::thread::sleep(std::time::Duration::from_millis(300));
1701        }
1702        assert!(
1703            !crate::observer::is_explain_held(),
1704            "baseline: explain flag MUST be off"
1705        );
1706        // First press → on.
1707        crate::observer::toggle_explain();
1708        assert!(
1709            crate::observer::is_explain_held(),
1710            "first press MUST turn the flag on"
1711        );
1712        // Second press past the debounce window → off.
1713        std::thread::sleep(std::time::Duration::from_millis(300));
1714        crate::observer::toggle_explain();
1715        assert!(
1716            !crate::observer::is_explain_held(),
1717            "second press MUST turn the flag off"
1718        );
1719    }
1720
1721    static EXPLAIN_GLOBAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1722
1723    /// Auto-repeat debounce: a second `toggle_explain` call
1724    /// within 250 ms of the first is swallowed. The state
1725    /// stays where the first press left it.
1726    #[test]
1727    fn global_explain_toggle_debounces_auto_repeat() {
1728        let _guard = EXPLAIN_GLOBAL_TEST_LOCK
1729            .lock()
1730            .unwrap_or_else(|p| p.into_inner());
1731        // Force baseline off, with appropriate spacing.
1732        std::thread::sleep(std::time::Duration::from_millis(300));
1733        if crate::observer::is_explain_held() {
1734            crate::observer::toggle_explain();
1735            std::thread::sleep(std::time::Duration::from_millis(300));
1736        }
1737        assert!(!crate::observer::is_explain_held(), "baseline: off");
1738        // First press → on.
1739        crate::observer::toggle_explain();
1740        assert!(crate::observer::is_explain_held());
1741        // Immediately repeated → should be swallowed (debounce).
1742        crate::observer::toggle_explain();
1743        assert!(
1744            crate::observer::is_explain_held(),
1745            "rapid second press MUST be debounced, leaving state on"
1746        );
1747        // Clean up: wait for debounce window, toggle off.
1748        std::thread::sleep(std::time::Duration::from_millis(300));
1749        crate::observer::toggle_explain();
1750    }
1751
1752    #[test]
1753    fn tui_binder_focus_cycles_through_slot() {
1754        struct Ctx;
1755        impl ReadoutContext for Ctx {
1756            fn subject_name(&self) -> &str {
1757                "x"
1758            }
1759            fn subject_seq(&self) -> Option<(usize, usize)> {
1760                None
1761            }
1762            fn subject_labels(&self) -> &str {
1763                ""
1764            }
1765            fn cycles_completed(&self) -> u64 {
1766                0
1767            }
1768            fn cycles_total(&self) -> u64 {
1769                0
1770            }
1771            fn ops_ok(&self) -> u64 {
1772                0
1773            }
1774            fn errors(&self) -> u64 {
1775                0
1776            }
1777            fn retries(&self) -> u64 {
1778                0
1779            }
1780            fn concurrency(&self) -> usize {
1781                1
1782            }
1783            fn elapsed_secs(&self) -> f64 {
1784                0.0
1785            }
1786            fn consumed(&self) -> u64 {
1787                0
1788            }
1789            fn status_metric_chips(&self) -> String {
1790                String::new()
1791            }
1792            fn depth_indent(&self) -> &str {
1793                ""
1794            }
1795            fn use_color(&self) -> bool {
1796                false
1797            }
1798            fn event(&self) -> EventType {
1799                EventType::PhaseEnd
1800            }
1801        }
1802        let mut binder = make_tui_binder_with_two_bodies();
1803        // First fire to set last_event.
1804        let mut sink = StringSink::new();
1805        binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
1806        // No focus stamped yet.
1807        assert_eq!(binder.focus_for(EventType::PhaseEnd), None);
1808
1809        binder.on_key(BinderKey::CycleFocusNext);
1810        // Focus is now at body 1 — we treated `None`
1811        // as "before-first" so the next-cycle starts from 0
1812        // and adds delta=1 → 1.
1813        assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(1));
1814
1815        binder.on_key(BinderKey::CycleFocusNext);
1816        // Wraps back to 0 (two bodies).
1817        assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(0));
1818
1819        binder.on_key(BinderKey::CycleFocusPrev);
1820        // Wraps the other way to 1.
1821        assert_eq!(binder.focus_for(EventType::PhaseEnd), Some(1));
1822    }
1823
1824    #[test]
1825    fn tui_binder_lod_cycle_stamps_override() {
1826        struct Ctx;
1827        impl ReadoutContext for Ctx {
1828            fn subject_name(&self) -> &str {
1829                "x"
1830            }
1831            fn subject_seq(&self) -> Option<(usize, usize)> {
1832                None
1833            }
1834            fn subject_labels(&self) -> &str {
1835                ""
1836            }
1837            fn cycles_completed(&self) -> u64 {
1838                0
1839            }
1840            fn cycles_total(&self) -> u64 {
1841                0
1842            }
1843            fn ops_ok(&self) -> u64 {
1844                0
1845            }
1846            fn errors(&self) -> u64 {
1847                0
1848            }
1849            fn retries(&self) -> u64 {
1850                0
1851            }
1852            fn concurrency(&self) -> usize {
1853                1
1854            }
1855            fn elapsed_secs(&self) -> f64 {
1856                0.0
1857            }
1858            fn consumed(&self) -> u64 {
1859                0
1860            }
1861            fn status_metric_chips(&self) -> String {
1862                String::new()
1863            }
1864            fn depth_indent(&self) -> &str {
1865                ""
1866            }
1867            fn use_color(&self) -> bool {
1868                false
1869            }
1870            fn event(&self) -> EventType {
1871                EventType::PhaseEnd
1872            }
1873        }
1874        let mut binder = make_tui_binder_with_two_bodies();
1875        let mut sink = StringSink::new();
1876        binder.fire(EventType::PhaseEnd, &Ctx, &mut sink);
1877        binder.on_key(BinderKey::CycleFocusNext); // focus → body 1
1878
1879        // Initial baked LOD is Labeled. Cycle up → Expanded.
1880        binder.on_key(BinderKey::CycleLodUp);
1881        assert_eq!(
1882            binder.lod_override(EventType::PhaseEnd, 1),
1883            Some(Lod::Expanded)
1884        );
1885
1886        // Cycle up again → wraps to Compact.
1887        binder.on_key(BinderKey::CycleLodUp);
1888        assert_eq!(
1889            binder.lod_override(EventType::PhaseEnd, 1),
1890            Some(Lod::Compact)
1891        );
1892
1893        // Cycle down → back to Expanded (wrap).
1894        binder.on_key(BinderKey::CycleLodDown);
1895        assert_eq!(
1896            binder.lod_override(EventType::PhaseEnd, 1),
1897            Some(Lod::Expanded)
1898        );
1899    }
1900
1901    #[test]
1902    fn step_lod_cycles_three_levels() {
1903        assert_eq!(step_lod(Lod::Compact, 1), Lod::Labeled);
1904        assert_eq!(step_lod(Lod::Labeled, 1), Lod::Expanded);
1905        assert_eq!(step_lod(Lod::Expanded, 1), Lod::Compact);
1906        assert_eq!(step_lod(Lod::Compact, -1), Lod::Expanded);
1907    }
1908}