Skip to main content

nmbrs_runtime/readouts/builtins/
phase_summary.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `phase_summary` — the post-run summary line readout.
5//!
6//! Renders the `[ok] [N/total] name duration` form the
7//! TUI observer's post-run roll-up emits today. Push 8
8//! ships the readout itself; the live observer keeps its
9//! direct format for now (Push 8b wires it through the
10//! readout engine alongside the rest of the post-run
11//! integration).
12//!
13//! Available so workloads can bind it via the `readouts:`
14//! block — e.g. as an alternate `on_phase_end` body that
15//! emits the bracket form instead of the ✓ form:
16//!
17//! ```yaml
18//! readouts:
19//!   on_phase_end: phase_summary
20//! ```
21
22use std::fmt::Write as _;
23
24use crate::lifecycle::SubjectKind;
25use crate::readouts::buf::ReadoutBuf;
26use crate::readouts::context::{LifecycleState, ReadoutContext};
27use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
28
29pub struct PhaseSummary;
30
31impl Readout for PhaseSummary {
32    fn name(&self) -> &'static str {
33        "phase_summary"
34    }
35    fn accepts(&self) -> &'static [SubjectKind] {
36        &[SubjectKind::Phase]
37    }
38
39    fn render(
40        &self,
41        ctx: &dyn ReadoutContext,
42        lod: Lod,
43        mode: ContentMode,
44        opts: &ReadoutOptions,
45        out: &mut dyn ReadoutBuf,
46    ) -> usize {
47        // Push 8 surface: Labeled / Value only. The
48        // explanation overlay falls through to a
49        // descriptor; other LODs stub to zero bytes.
50        match (lod, mode) {
51            (Lod::Labeled, ContentMode::Value) => render_labeled_value(ctx, opts, out),
52            (Lod::Labeled, ContentMode::Explanation) => render_labeled_explanation(ctx, out),
53            _ => 0,
54        }
55    }
56}
57
58fn render_labeled_value(
59    ctx: &dyn ReadoutContext,
60    opts: &ReadoutOptions,
61    out: &mut dyn ReadoutBuf,
62) -> usize {
63    // Color palette per docs/guide/color_style.md:
64    //   [ok] → OK (green), [!!] → ERROR (red),
65    //   [..] → INFO (sky), [  ] → MUTED (dim),
66    //   phase name → bold INFO, duration → MUTED.
67    let color = ctx.use_color();
68    let bold = if color { "\x1b[1m" } else { "" };
69    let dim = if color { "\x1b[2m" } else { "" };
70    let yellow = if color { "\x1b[33m" } else { "" };
71    let blue = if color { "\x1b[34m" } else { "" };
72    let green = if color { "\x1b[32m" } else { "" };
73    let red = if color { "\x1b[1;31m" } else { "" };
74    let reset = if color { "\x1b[0m" } else { "" };
75
76    // Status marker mirrors the TUI observer's existing
77    // bracket vocabulary — same characters so the
78    // post-run summary reads the same whether it routed
79    // through the legacy direct emit or through the
80    // engine. (Push 8b's whole point.)
81    let (marker, suffix) = match ctx.subject_state() {
82        LifecycleState::Completed => (format!("{green}[ok]{reset}"), String::new()),
83        LifecycleState::Running => (
84            format!("{blue}[..]{reset}"),
85            format!(" {dim}(still running){reset}"),
86        ),
87        LifecycleState::Pending => (
88            format!("{dim}[  ]{reset}"),
89            format!(" {dim}(not run){reset}"),
90        ),
91        LifecycleState::Failed(err) => (format!("{red}[!!]{reset}"), format!(" ({err})")),
92    };
93    let seq_part: String = match ctx.subject_seq() {
94        Some((s, t)) => format!("{dim}[{s}/{t}]{reset} "),
95        None => String::new(),
96    };
97    let depth_indent = ctx.depth_indent();
98    let phase_name = ctx.subject_name();
99    let labels = ctx.subject_labels();
100    // Push 9e: `show_labels=true` opts into rendering the
101    // iter-tuple coord-path inline as ` ({labels})` between
102    // the name and the duration / suffix. Default off — the
103    // post-run summary's primary row hides them since the
104    // scope-header rows above already carry the coords.
105    // The failed-phase inset sets it on so each failure
106    // line is self-contained without scrolling.
107    let show_labels = opts.get_bool("show_labels").unwrap_or(false);
108    let labels_part = if labels.is_empty() || !show_labels {
109        String::new()
110    } else {
111        format!(" {bold}{yellow}({labels}){reset}")
112    };
113    let elapsed = ctx.elapsed_secs();
114    let dur_part = if elapsed > 0.0 {
115        format!(" {dim}{elapsed:.2}s{reset}")
116    } else {
117        String::new()
118    };
119
120    // Memo row: phase displays surface the latest published memo
121    // as `[[ <memo> ]]` — a detail row BELOW the bracket-form
122    // header (SRD-92 header-first composition). Empty memo → no
123    // row. Bold yellow per the style guide's EMPHASIS tier.
124    let memo = ctx.phase_memo();
125    let memo_row = if memo.is_empty() {
126        String::new()
127    } else {
128        let bold_yellow = if color { "\x1b[1;33m" } else { "" };
129        format!("\n{depth_indent}    {bold_yellow}[[ {memo} ]]{reset}")
130    };
131
132    let mut tmp = String::with_capacity(160);
133    let _ = write!(
134        &mut tmp,
135        "{depth_indent}{marker} {seq_part}{bold}{blue}{phase_name}{reset}{labels_part}{dur_part}{suffix}{memo_row}",
136    );
137    let len = tmp.len();
138    let _ = out.write_str(&tmp);
139    len
140}
141
142fn render_labeled_explanation(_ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
143    let s = "[ok|!!] [idx/total] phase-name (elapsed)";
144    let _ = out.write_str(s);
145    s.len()
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::readouts::OptionValue;
152    use crate::readouts::buf::StringBuf;
153
154    struct TestCtx {
155        phase_name: String,
156        phase_seq: Option<(usize, usize)>,
157        phase_labels: String,
158        elapsed_secs: f64,
159        depth_indent: String,
160        state: LifecycleState,
161    }
162    impl TestCtx {
163        // Replaces #[derive(Default)] now that LifecycleState
164        // requires explicit construction.
165        fn defaults() -> Self {
166            Self {
167                phase_name: String::new(),
168                phase_seq: None,
169                phase_labels: String::new(),
170                elapsed_secs: 0.0,
171                depth_indent: String::new(),
172                state: LifecycleState::Running,
173            }
174        }
175    }
176    impl ReadoutContext for TestCtx {
177        fn subject_name(&self) -> &str {
178            &self.phase_name
179        }
180        fn subject_seq(&self) -> Option<(usize, usize)> {
181            self.phase_seq
182        }
183        fn subject_labels(&self) -> &str {
184            &self.phase_labels
185        }
186        fn cycles_completed(&self) -> u64 {
187            0
188        }
189        fn cycles_total(&self) -> u64 {
190            0
191        }
192        fn ops_ok(&self) -> u64 {
193            0
194        }
195        fn errors(&self) -> u64 {
196            0
197        }
198        fn retries(&self) -> u64 {
199            0
200        }
201        fn concurrency(&self) -> usize {
202            1
203        }
204        fn elapsed_secs(&self) -> f64 {
205            self.elapsed_secs
206        }
207        fn consumed(&self) -> u64 {
208            0
209        }
210        fn status_metric_chips(&self) -> String {
211            String::new()
212        }
213        fn depth_indent(&self) -> &str {
214            &self.depth_indent
215        }
216        fn use_color(&self) -> bool {
217            false
218        }
219        fn event(&self) -> crate::lifecycle::EventType {
220            crate::lifecycle::EventType::PhaseEnd
221        }
222        fn subject_state(&self) -> LifecycleState {
223            self.state.clone()
224        }
225    }
226
227    fn render(ctx: &TestCtx) -> String {
228        let mut s = String::new();
229        let mut buf = StringBuf::new(&mut s);
230        PhaseSummary.render(
231            ctx,
232            Lod::Labeled,
233            ContentMode::Value,
234            &ReadoutOptions::new(),
235            &mut buf,
236        );
237        s
238    }
239
240    #[test]
241    fn completed_with_seq_and_duration() {
242        let ctx = TestCtx {
243            phase_name: "setup".into(),
244            phase_seq: Some((1, 2)),
245            elapsed_secs: 0.02,
246            state: LifecycleState::Completed,
247            ..TestCtx::defaults()
248        };
249        assert_eq!(render(&ctx), "[ok] [1/2] setup 0.02s");
250    }
251
252    #[test]
253    fn completed_no_seq_no_duration() {
254        let ctx = TestCtx {
255            phase_name: "x".into(),
256            state: LifecycleState::Completed,
257            ..TestCtx::defaults()
258        };
259        assert_eq!(render(&ctx), "[ok] x");
260    }
261
262    #[test]
263    fn failed_state_emits_double_bang_and_error() {
264        let ctx = TestCtx {
265            phase_name: "load".into(),
266            phase_seq: Some((2, 3)),
267            elapsed_secs: 1.0,
268            state: LifecycleState::Failed("boom".into()),
269            ..TestCtx::defaults()
270        };
271        assert_eq!(render(&ctx), "[!!] [2/3] load 1.00s (boom)");
272    }
273
274    #[test]
275    fn pending_state_emits_blank_marker() {
276        let ctx = TestCtx {
277            phase_name: "verify".into(),
278            phase_seq: Some((3, 3)),
279            state: LifecycleState::Pending,
280            ..TestCtx::defaults()
281        };
282        assert_eq!(render(&ctx), "[  ] [3/3] verify (not run)");
283    }
284
285    #[test]
286    fn running_state_emits_double_dot_marker() {
287        let ctx = TestCtx {
288            phase_name: "run".into(),
289            phase_seq: Some((1, 1)),
290            elapsed_secs: 0.5,
291            state: LifecycleState::Running,
292            ..TestCtx::defaults()
293        };
294        assert_eq!(render(&ctx), "[..] [1/1] run 0.50s (still running)");
295    }
296
297    #[test]
298    fn explanation_describes_each_field() {
299        let ctx = TestCtx::defaults();
300        let mut s = String::new();
301        let mut buf = StringBuf::new(&mut s);
302        let n = PhaseSummary.render(
303            &ctx,
304            Lod::Labeled,
305            ContentMode::Explanation,
306            &ReadoutOptions::new(),
307            &mut buf,
308        );
309        assert!(n > 0);
310        assert!(s.contains("phase-name"));
311        assert!(s.contains("idx/total"));
312        assert!(s.contains("elapsed"));
313    }
314
315    #[test]
316    fn other_lods_are_zero_bytes() {
317        let ctx = TestCtx {
318            phase_name: "x".into(),
319            ..TestCtx::defaults()
320        };
321        for lod in [Lod::Compact, Lod::Expanded] {
322            let mut s = String::new();
323            let mut buf = StringBuf::new(&mut s);
324            let n = PhaseSummary.render(
325                &ctx,
326                lod,
327                ContentMode::Value,
328                &ReadoutOptions::new(),
329                &mut buf,
330            );
331            assert_eq!(n, 0, "{lod:?} should render zero bytes");
332        }
333    }
334
335    #[test]
336    fn colorized_emits_ansi_for_status_and_name() {
337        // When use_color is on, the bracket marker, phase
338        // name, and duration MUST carry ANSI codes per
339        // docs/guide/color_style.md. This is a regression
340        // guard — pre-Push the post-run summary went to the
341        // surface as plain text even on a TTY.
342        struct TestCtxColor;
343        impl ReadoutContext for TestCtxColor {
344            fn subject_name(&self) -> &str {
345                "setup"
346            }
347            fn subject_seq(&self) -> Option<(usize, usize)> {
348                Some((1, 2))
349            }
350            fn subject_labels(&self) -> &str {
351                ""
352            }
353            fn cycles_completed(&self) -> u64 {
354                0
355            }
356            fn cycles_total(&self) -> u64 {
357                0
358            }
359            fn ops_ok(&self) -> u64 {
360                0
361            }
362            fn errors(&self) -> u64 {
363                0
364            }
365            fn retries(&self) -> u64 {
366                0
367            }
368            fn concurrency(&self) -> usize {
369                1
370            }
371            fn elapsed_secs(&self) -> f64 {
372                0.5
373            }
374            fn consumed(&self) -> u64 {
375                0
376            }
377            fn status_metric_chips(&self) -> String {
378                String::new()
379            }
380            fn depth_indent(&self) -> &str {
381                ""
382            }
383            fn use_color(&self) -> bool {
384                true
385            }
386            fn event(&self) -> crate::lifecycle::EventType {
387                crate::lifecycle::EventType::PhaseEnd
388            }
389            fn subject_state(&self) -> LifecycleState {
390                LifecycleState::Completed
391            }
392        }
393        let ctx = TestCtxColor;
394        let mut s = String::new();
395        let mut buf = StringBuf::new(&mut s);
396        PhaseSummary.render(
397            &ctx,
398            Lod::Labeled,
399            ContentMode::Value,
400            &ReadoutOptions::new(),
401            &mut buf,
402        );
403        // Green wrapper around the [ok] marker.
404        assert!(
405            s.contains("\x1b[32m[ok]\x1b[0m"),
406            "expected green [ok] marker, got: {s:?}"
407        );
408        // Bold + blue around the phase name.
409        assert!(
410            s.contains("\x1b[1m\x1b[34msetup\x1b[0m"),
411            "expected bold-blue phase name, got: {s:?}"
412        );
413        // Dim duration tail.
414        assert!(
415            s.contains("\x1b[2m0.50s\x1b[0m"),
416            "expected dim duration, got: {s:?}"
417        );
418    }
419
420    #[test]
421    fn show_labels_inserts_iter_tuple_between_name_and_dur() {
422        // Push 9e: failed-phase inset uses this option to
423        // surface the iter-tuple coord-path inline so the
424        // failure block in `failures:` is self-contained.
425        let ctx = TestCtx {
426            phase_name: "ann_query".into(),
427            phase_labels: "profile=alpha, k=10".into(),
428            elapsed_secs: 0.0,
429            state: LifecycleState::Failed("connection lost".into()),
430            ..TestCtx::defaults()
431        };
432        let mut s = String::new();
433        let mut buf = StringBuf::new(&mut s);
434        let mut opts = ReadoutOptions::new();
435        opts.set("show_labels", OptionValue::Bool(true));
436        PhaseSummary.render(&ctx, Lod::Labeled, ContentMode::Value, &opts, &mut buf);
437        assert_eq!(s, "[!!] ann_query (profile=alpha, k=10) (connection lost)",);
438    }
439
440    #[test]
441    fn default_omits_labels_even_when_present() {
442        // Without `show_labels=true`, labels are dropped —
443        // the post-run summary's primary tree carries them
444        // in scope-header rows above the phase row, so
445        // duplicating them would be noisy.
446        let ctx = TestCtx {
447            phase_name: "ann_query".into(),
448            phase_labels: "profile=alpha".into(),
449            state: LifecycleState::Completed,
450            ..TestCtx::defaults()
451        };
452        let mut s = String::new();
453        let mut buf = StringBuf::new(&mut s);
454        PhaseSummary.render(
455            &ctx,
456            Lod::Labeled,
457            ContentMode::Value,
458            &ReadoutOptions::new(),
459            &mut buf,
460        );
461        assert_eq!(s, "[ok] ann_query");
462    }
463}