Skip to main content

nmbrs_runtime/readouts/builtins/
session_summary.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `session_summary` — the session-level rollup line:
5//! `phases:  X completed, Y failed, Z not run (of N total)`.
6//!
7//! Reads session-scope totals from
8//! [`ReadoutContext::session_phases_*`]. Default slot is
9//! `on_session_end`; workloads can also bind it elsewhere
10//! (e.g. mid-run snapshot via `on_update`) but the totals
11//! only make sense at session-scope contexts.
12
13use std::fmt::Write as _;
14
15use crate::lifecycle::SubjectKind;
16use crate::readouts::buf::ReadoutBuf;
17use crate::readouts::context::ReadoutContext;
18use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
19
20pub struct SessionSummary;
21
22impl Readout for SessionSummary {
23    fn name(&self) -> &'static str {
24        "session_summary"
25    }
26    fn accepts(&self) -> &'static [SubjectKind] {
27        &[SubjectKind::Session]
28    }
29
30    fn render(
31        &self,
32        ctx: &dyn ReadoutContext,
33        lod: Lod,
34        mode: ContentMode,
35        _opts: &ReadoutOptions,
36        out: &mut dyn ReadoutBuf,
37    ) -> usize {
38        match (lod, mode) {
39            (Lod::Compact, ContentMode::Value) => render_compact(ctx, out),
40            (Lod::Labeled, ContentMode::Value) => render_labeled(ctx, out),
41            (Lod::Expanded, ContentMode::Value) => render_expanded(ctx, out),
42            (_, ContentMode::Explanation) => render_explanation(out),
43        }
44    }
45}
46
47/// Compact: single-line tallies, no labels — matches the
48/// existing observer's bracket form.
49fn render_compact(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
50    let color = ctx.use_color();
51    let dim = if color { "\x1b[2m" } else { "" };
52    let green = if color { "\x1b[32m" } else { "" };
53    let red = if color { "\x1b[1;31m" } else { "" };
54    let reset = if color { "\x1b[0m" } else { "" };
55    let f = ctx.session_phases_failed();
56    let p = ctx.session_phases_pending();
57    let fail_color = if f > 0 { red } else { dim };
58    // Pending phases render dim regardless of count (no emphasis colour
59    // is defined for "not yet run").
60    let pending_color = dim;
61    let mut tmp = String::with_capacity(64);
62    let _ = write!(
63        &mut tmp,
64        "{green}{c}{reset}/{fail_color}{f}{reset}/{pending_color}{p}{reset}/{dim}{t}{reset}",
65        c = ctx.session_phases_completed(),
66        t = ctx.session_phases_total(),
67    );
68    let len = tmp.len();
69    let _ = out.write_str(&tmp);
70    len
71}
72
73/// Labeled: full-prose form matching the observer's
74/// pre-engine rollup `phases:  X completed, Y failed,
75/// Z not run (of N total)`.
76fn render_labeled(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
77    let tmp = labeled_phase_rollup(
78        ctx.session_phases_completed(),
79        ctx.session_phases_failed(),
80        ctx.session_phases_pending(),
81        ctx.session_phases_total(),
82        ctx.use_color(),
83    );
84    let len = tmp.len();
85    let _ = out.write_str(&tmp);
86    len
87}
88
89/// The canonical labeled phase-rollup line —
90/// `phases:  C completed, F failed, P not run (of T total)` — built
91/// from raw counts. Shared by the `session_summary` readout (which
92/// pulls the counts from session-scope totals) and any caller that
93/// needs the identical text from its own tallies, e.g. the in-process
94/// example walker synthesising a **per-execution** rollup for rule
95/// matching (its session-scope summary spans every concurrent
96/// execution, so it counts its own [`PhaseRecord`](crate::concurrent)s
97/// instead). One formatter ⇒ no drift between the two.
98///
99/// Per `docs/guide/color_style.md`: `phases:` is HEADER (bold), counts
100/// colored per status (OK/ERROR/MUTED), total MUTED, `of N total`
101/// parenthetical dim. `color=false` yields plain text.
102pub fn labeled_phase_rollup(
103    completed: usize,
104    failed: usize,
105    pending: usize,
106    total: usize,
107    color: bool,
108) -> String {
109    let bold = if color { "\x1b[1m" } else { "" };
110    let dim = if color { "\x1b[2m" } else { "" };
111    let green = if color { "\x1b[32m" } else { "" };
112    let red = if color { "\x1b[1;31m" } else { "" };
113    let reset = if color { "\x1b[0m" } else { "" };
114    let fail_color = if failed > 0 { red } else { dim };
115    let mut tmp = String::with_capacity(128);
116    let _ = write!(
117        &mut tmp,
118        "{bold}phases:{reset}  \
119         {green}{completed}{reset} completed, \
120         {fail_color}{failed}{reset} failed, \
121         {dim}{pending}{reset} not run \
122         {dim}(of {total} total){reset}",
123    );
124    tmp
125}
126
127/// Expanded: per-line breakdown — same data, friendlier
128/// to scan for debugging / scrollback.
129fn render_expanded(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
130    let color = ctx.use_color();
131    let bold = if color { "\x1b[1m" } else { "" };
132    let dim = if color { "\x1b[2m" } else { "" };
133    let green = if color { "\x1b[32m" } else { "" };
134    let red = if color { "\x1b[1;31m" } else { "" };
135    let reset = if color { "\x1b[0m" } else { "" };
136    let f = ctx.session_phases_failed();
137    let fail_color = if f > 0 { red } else { dim };
138    let mut tmp = String::with_capacity(192);
139    let _ = write!(
140        &mut tmp,
141        "{bold}session totals{reset}\n  \
142         {dim}completed:{reset}  {green}{c}{reset}\n  \
143         {dim}failed:{reset}     {fail_color}{f}{reset}\n  \
144         {dim}not run:{reset}    {dim}{p}{reset}\n  \
145         {dim}total:{reset}      {dim}{t}{reset}",
146        c = ctx.session_phases_completed(),
147        p = ctx.session_phases_pending(),
148        t = ctx.session_phases_total(),
149    );
150    let len = tmp.len();
151    let _ = out.write_str(&tmp);
152    len
153}
154
155fn render_explanation(out: &mut dyn ReadoutBuf) -> usize {
156    let s = "phases:  <completed-count> completed, <failed-count> failed, \
157             <pending-count> not run (of <total> total)";
158    let _ = out.write_str(s);
159    s.len()
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::readouts::buf::StringBuf;
166
167    #[derive(Default)]
168    struct TestCtx {
169        completed: usize,
170        failed: usize,
171        pending: usize,
172        total: usize,
173    }
174    impl ReadoutContext for TestCtx {
175        fn subject_name(&self) -> &str {
176            "session"
177        }
178        fn subject_seq(&self) -> Option<(usize, usize)> {
179            None
180        }
181        fn subject_labels(&self) -> &str {
182            ""
183        }
184        fn cycles_completed(&self) -> u64 {
185            0
186        }
187        fn cycles_total(&self) -> u64 {
188            0
189        }
190        fn ops_ok(&self) -> u64 {
191            0
192        }
193        fn errors(&self) -> u64 {
194            0
195        }
196        fn retries(&self) -> u64 {
197            0
198        }
199        fn concurrency(&self) -> usize {
200            0
201        }
202        fn elapsed_secs(&self) -> f64 {
203            0.0
204        }
205        fn consumed(&self) -> u64 {
206            0
207        }
208        fn status_metric_chips(&self) -> String {
209            String::new()
210        }
211        fn depth_indent(&self) -> &str {
212            ""
213        }
214        fn use_color(&self) -> bool {
215            false
216        }
217        fn event(&self) -> crate::lifecycle::EventType {
218            crate::lifecycle::EventType::SessionEnd
219        }
220        fn session_phases_completed(&self) -> usize {
221            self.completed
222        }
223        fn session_phases_failed(&self) -> usize {
224            self.failed
225        }
226        fn session_phases_pending(&self) -> usize {
227            self.pending
228        }
229        fn session_phases_total(&self) -> usize {
230            self.total
231        }
232    }
233
234    fn render(ctx: &TestCtx, lod: Lod) -> String {
235        let mut s = String::new();
236        let mut buf = StringBuf::new(&mut s);
237        SessionSummary.render(
238            ctx,
239            lod,
240            ContentMode::Value,
241            &ReadoutOptions::new(),
242            &mut buf,
243        );
244        s
245    }
246
247    #[test]
248    fn labeled_matches_pre_engine_format() {
249        let ctx = TestCtx {
250            completed: 7,
251            failed: 1,
252            pending: 0,
253            total: 8,
254        };
255        assert_eq!(
256            render(&ctx, Lod::Labeled),
257            "phases:  7 completed, 1 failed, 0 not run (of 8 total)",
258        );
259    }
260
261    #[test]
262    fn compact_packs_into_slash_form() {
263        let ctx = TestCtx {
264            completed: 5,
265            failed: 2,
266            pending: 1,
267            total: 8,
268        };
269        assert_eq!(render(&ctx, Lod::Compact), "5/2/1/8");
270    }
271
272    #[test]
273    fn expanded_breaks_onto_multiple_lines() {
274        let ctx = TestCtx {
275            completed: 1,
276            failed: 0,
277            pending: 0,
278            total: 1,
279        };
280        let out = render(&ctx, Lod::Expanded);
281        assert!(out.lines().count() >= 4);
282        assert!(out.contains("completed:  1"));
283        assert!(out.contains("total:      1"));
284    }
285
286    #[test]
287    fn explanation_shows_field_descriptors() {
288        let ctx = TestCtx::default();
289        let mut s = String::new();
290        let mut buf = StringBuf::new(&mut s);
291        SessionSummary.render(
292            &ctx,
293            Lod::Labeled,
294            ContentMode::Explanation,
295            &ReadoutOptions::new(),
296            &mut buf,
297        );
298        assert!(s.contains("completed-count"));
299        assert!(s.contains("failed-count"));
300        assert!(s.contains("pending-count"));
301    }
302}