Skip to main content

nmbrs_runtime/readouts/builtins/
scope_header.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `scope_header` — the `· {scope_name}` row that appears
5//! above each iteration of a `for_each` /
6//! `for_combinations` / `do_while` scope group.
7//!
8//! Two emit sites today, both bypassing the engine:
9//!
10//! 1. `nmbrs-tui::observer`'s post-run summary tree walk
11//!    (the `· for_each profile=label_00` rows that nest
12//!    above each phase row).
13//! 2. `nmbrs-tui::log_only_observer`'s `phase_starting`
14//!    scope walker (the live mid-run scope-ancestor
15//!    headers that fire when the run enters a fresh
16//!    iteration).
17//!
18//! Push 8c lands the readout; the observers route through
19//! it in the same push so the two emit sites share one
20//! formatter.
21//!
22//! The readout's renderer reads only the scope name
23//! (carried via `phase_name` on the row's
24//! `ReadoutContext`) — depth-indent chrome stays the
25//! surface's job per SRD-63 §10's layout vs. content
26//! split.
27
28use std::fmt::Write as _;
29
30use crate::lifecycle::SubjectKind;
31use crate::readouts::buf::ReadoutBuf;
32use crate::readouts::context::ReadoutContext;
33use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
34
35pub struct ScopeHeader;
36
37impl Readout for ScopeHeader {
38    fn name(&self) -> &'static str {
39        "scope_header"
40    }
41    fn accepts(&self) -> &'static [SubjectKind] {
42        &[SubjectKind::Iteration]
43    }
44
45    fn render(
46        &self,
47        ctx: &dyn ReadoutContext,
48        lod: Lod,
49        mode: ContentMode,
50        _opts: &ReadoutOptions,
51        out: &mut dyn ReadoutBuf,
52    ) -> usize {
53        match (lod, mode) {
54            (Lod::Compact, ContentMode::Value) => render_value(ctx, out, false),
55            (Lod::Labeled, ContentMode::Value) => render_value(ctx, out, false),
56            (Lod::Expanded, ContentMode::Value) => render_value(ctx, out, true),
57            (_, ContentMode::Explanation) => render_explanation(out),
58        }
59    }
60}
61
62fn render_value(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf, expanded: bool) -> usize {
63    let color = ctx.use_color();
64    let cyan = if color { "\x1b[36m" } else { "" };
65    let italic = if color { "\x1b[3m" } else { "" };
66    let reset = if color { "\x1b[0m" } else { "" };
67    let name = ctx.subject_name();
68    let labels = ctx.subject_labels();
69
70    let mut tmp = String::with_capacity(64);
71    if expanded && !labels.is_empty() {
72        // Expanded: show both name and the iteration tuple
73        // labels on a second indented line. Used by the
74        // active-phase panel's expanded row when the user
75        // wants the full breakdown.
76        let _ = write!(&mut tmp, "{cyan}·{reset} {italic}{name}{reset}\n  {labels}");
77    } else {
78        // Compact / Labeled: single-row form. The TUI
79        // observer's existing scope row matches this byte
80        // for byte (cyan bullet, italic name).
81        let _ = write!(&mut tmp, "{cyan}·{reset} {italic}{name}{reset}");
82    }
83    let len = tmp.len();
84    let _ = out.write_str(&tmp);
85    len
86}
87
88fn render_explanation(out: &mut dyn ReadoutBuf) -> usize {
89    let s = "· {scope-name} — header for the surrounding for_each / do_while scope iteration";
90    let _ = out.write_str(s);
91    s.len()
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::readouts::buf::StringBuf;
98
99    #[derive(Default)]
100    struct TestCtx {
101        name: String,
102        labels: String,
103        use_color: bool,
104    }
105    impl ReadoutContext for TestCtx {
106        fn subject_name(&self) -> &str {
107            &self.name
108        }
109        fn subject_seq(&self) -> Option<(usize, usize)> {
110            None
111        }
112        fn subject_labels(&self) -> &str {
113            &self.labels
114        }
115        fn cycles_completed(&self) -> u64 {
116            0
117        }
118        fn cycles_total(&self) -> u64 {
119            0
120        }
121        fn ops_ok(&self) -> u64 {
122            0
123        }
124        fn errors(&self) -> u64 {
125            0
126        }
127        fn retries(&self) -> u64 {
128            0
129        }
130        fn concurrency(&self) -> usize {
131            0
132        }
133        fn elapsed_secs(&self) -> f64 {
134            0.0
135        }
136        fn consumed(&self) -> u64 {
137            0
138        }
139        fn status_metric_chips(&self) -> String {
140            String::new()
141        }
142        fn depth_indent(&self) -> &str {
143            ""
144        }
145        fn use_color(&self) -> bool {
146            self.use_color
147        }
148        fn event(&self) -> crate::lifecycle::EventType {
149            crate::lifecycle::EventType::EachStart
150        }
151    }
152
153    fn render(ctx: &TestCtx, lod: Lod, mode: ContentMode) -> String {
154        let mut s = String::new();
155        let mut buf = StringBuf::new(&mut s);
156        ScopeHeader.render(ctx, lod, mode, &ReadoutOptions::new(), &mut buf);
157        s
158    }
159
160    #[test]
161    fn labeled_no_color() {
162        let ctx = TestCtx {
163            name: "for_each profile=label_00".into(),
164            ..Default::default()
165        };
166        assert_eq!(
167            render(&ctx, Lod::Labeled, ContentMode::Value),
168            "· for_each profile=label_00",
169        );
170    }
171
172    #[test]
173    fn compact_matches_labeled_for_simple_form() {
174        // SRD-63 §3.3 monotonicity invariant — Compact's
175        // info is a strict subset of Labeled's. For
176        // scope_header the two are identical at the value
177        // level (no extra fields to drop).
178        let ctx = TestCtx {
179            name: "for_each k=10".into(),
180            ..Default::default()
181        };
182        assert_eq!(
183            render(&ctx, Lod::Compact, ContentMode::Value),
184            render(&ctx, Lod::Labeled, ContentMode::Value),
185        );
186    }
187
188    #[test]
189    fn expanded_with_labels_shows_iteration_tuple() {
190        let ctx = TestCtx {
191            name: "for_combinations".into(),
192            labels: "(profile=alpha), (k=10, limit=100)".into(),
193            ..Default::default()
194        };
195        let out = render(&ctx, Lod::Expanded, ContentMode::Value);
196        assert!(out.contains("for_combinations"));
197        assert!(out.contains("(profile=alpha), (k=10, limit=100)"));
198        // Expanded splits onto two lines.
199        assert!(
200            out.lines().count() >= 2,
201            "expanded should be multi-line: {out}"
202        );
203    }
204
205    #[test]
206    fn explanation_describes_the_row() {
207        let ctx = TestCtx {
208            name: "for_each".into(),
209            ..Default::default()
210        };
211        let out = render(&ctx, Lod::Labeled, ContentMode::Explanation);
212        assert!(
213            out.contains("scope-name"),
214            "expected 'scope-name' descriptor: {out}"
215        );
216    }
217
218    #[test]
219    fn ansi_emitted_when_color_enabled() {
220        let ctx = TestCtx {
221            name: "for_each k".into(),
222            use_color: true,
223            ..Default::default()
224        };
225        let out = render(&ctx, Lod::Labeled, ContentMode::Value);
226        // Cyan bullet + italic name + reset bytes.
227        assert!(out.contains("\x1b[36m"), "missing cyan: {out:?}");
228        assert!(out.contains("\x1b[3m"), "missing italic: {out:?}");
229        assert!(out.contains("\x1b[0m"), "missing reset: {out:?}");
230    }
231}