Skip to main content

nmbrs_runtime/readouts/builtins/
truncated_phases.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `truncated_phases` — the `(… and N more phases not
5//! listed)` rollup that follows the post-run summary's
6//! per-phase rows when the tail was truncated past a failure.
7//!
8//! When a scenario fails early in a long phase list, the
9//! post-run summary trims the pending tail to a small
10//! window so the operator's terminal isn't drowned in
11//! "[  ] not run" rows. This readout renders the message
12//! summarising what was dropped, plus a pointer at
13//! `dryrun=phase` for the full plan.
14//!
15//! Reads
16//! [`ReadoutContext::session_phases_truncated`]. Renders
17//! zero bytes when the count is 0 — callers fire it
18//! unconditionally and rely on the readout's own
19//! "is there anything to say?" check.
20
21use std::fmt::Write as _;
22
23use crate::lifecycle::SubjectKind;
24use crate::readouts::buf::ReadoutBuf;
25use crate::readouts::context::ReadoutContext;
26use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
27
28pub struct TruncatedPhases;
29
30impl Readout for TruncatedPhases {
31    fn name(&self) -> &'static str {
32        "truncated_phases"
33    }
34    fn accepts(&self) -> &'static [SubjectKind] {
35        &[SubjectKind::Session]
36    }
37
38    fn render(
39        &self,
40        ctx: &dyn ReadoutContext,
41        lod: Lod,
42        mode: ContentMode,
43        _opts: &ReadoutOptions,
44        out: &mut dyn ReadoutBuf,
45    ) -> usize {
46        let count = ctx.session_phases_truncated();
47        if count == 0 {
48            return 0;
49        }
50        let color = ctx.use_color();
51        match (lod, mode) {
52            (Lod::Compact, ContentMode::Value) => render_compact(count, color, out),
53            (Lod::Labeled, ContentMode::Value) => render_labeled(count, color, out),
54            (Lod::Expanded, ContentMode::Value) => render_expanded(count, color, out),
55            (_, ContentMode::Explanation) => render_explanation(out),
56        }
57    }
58}
59
60fn render_compact(count: usize, color: bool, out: &mut dyn ReadoutBuf) -> usize {
61    let dim = if color { "\x1b[2m" } else { "" };
62    let reset = if color { "\x1b[0m" } else { "" };
63    let mut tmp = String::with_capacity(48);
64    let _ = write!(&mut tmp, "{dim}(… {count} more){reset}");
65    let len = tmp.len();
66    let _ = out.write_str(&tmp);
67    len
68}
69
70fn render_labeled(count: usize, color: bool, out: &mut dyn ReadoutBuf) -> usize {
71    // Two-line form: rollup + tip. Both rendered as MUTED
72    // (dim) per docs/guide/color_style.md — this is
73    // informational tail, not primary signal.
74    let dim = if color { "\x1b[2m" } else { "" };
75    let reset = if color { "\x1b[0m" } else { "" };
76    let suffix = if count == 1 { "" } else { "s" };
77    let mut tmp = String::with_capacity(128);
78    let _ = write!(
79        &mut tmp,
80        "{dim}(… and {count} more phase{suffix} not listed){reset}\n\
81{dim}tip: run with dryrun=phase to see the full plan{reset}",
82    );
83    let len = tmp.len();
84    let _ = out.write_str(&tmp);
85    len
86}
87
88fn render_expanded(count: usize, color: bool, out: &mut dyn ReadoutBuf) -> usize {
89    // Expanded adds one extra contextualising line above
90    // the rollup explaining *why* the tail was truncated.
91    // Same data; just spelled out for the operator who's
92    // reading the post-run summary cold.
93    let dim = if color { "\x1b[2m" } else { "" };
94    let reset = if color { "\x1b[0m" } else { "" };
95    let suffix = if count == 1 { "" } else { "s" };
96    let mut tmp = String::with_capacity(192);
97    let _ = write!(
98        &mut tmp,
99        "{dim}post-failure tail truncated to keep the summary readable{reset}\n\
100{dim}(… and {count} more phase{suffix} not listed){reset}\n\
101{dim}tip: run with dryrun=phase to see the full plan{reset}",
102    );
103    let len = tmp.len();
104    let _ = out.write_str(&tmp);
105    len
106}
107
108fn render_explanation(out: &mut dyn ReadoutBuf) -> usize {
109    let s = "(… and <count> more phases not listed) — \
110             tail of the phase list trimmed past the last \
111             failure; tip points at dryrun=phase for the \
112             full plan";
113    let _ = out.write_str(s);
114    s.len()
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::readouts::buf::StringBuf;
121
122    struct TestCtx {
123        truncated: usize,
124    }
125    impl ReadoutContext for TestCtx {
126        fn subject_name(&self) -> &str {
127            "session"
128        }
129        fn session_phases_truncated(&self) -> usize {
130            self.truncated
131        }
132        fn event(&self) -> crate::lifecycle::EventType {
133            crate::lifecycle::EventType::SessionEnd
134        }
135    }
136
137    fn render(ctx: &TestCtx, lod: Lod) -> String {
138        let mut s = String::new();
139        let mut buf = StringBuf::new(&mut s);
140        TruncatedPhases.render(
141            ctx,
142            lod,
143            ContentMode::Value,
144            &ReadoutOptions::new(),
145            &mut buf,
146        );
147        s
148    }
149
150    #[test]
151    fn zero_count_renders_no_bytes() {
152        let ctx = TestCtx { truncated: 0 };
153        let mut s = String::new();
154        let mut buf = StringBuf::new(&mut s);
155        let n = TruncatedPhases.render(
156            &ctx,
157            Lod::Labeled,
158            ContentMode::Value,
159            &ReadoutOptions::new(),
160            &mut buf,
161        );
162        assert_eq!(n, 0);
163        assert!(s.is_empty());
164    }
165
166    #[test]
167    fn labeled_matches_pre_engine_format() {
168        // Byte-equivalent to the prior observer.rs:596
169        // direct eprintln pair (the two lines `(... and N
170        // more phase[s] not listed)` + `tip: …`).
171        let ctx = TestCtx { truncated: 7 };
172        assert_eq!(
173            render(&ctx, Lod::Labeled),
174            "(… and 7 more phases not listed)\n\
175tip: run with dryrun=phase to see the full plan",
176        );
177    }
178
179    #[test]
180    fn singular_one_phase_drops_plural_suffix() {
181        let ctx = TestCtx { truncated: 1 };
182        assert!(render(&ctx, Lod::Labeled).contains("1 more phase not listed"));
183    }
184
185    #[test]
186    fn compact_packs_into_one_line() {
187        let ctx = TestCtx { truncated: 12 };
188        assert_eq!(render(&ctx, Lod::Compact), "(… 12 more)");
189    }
190
191    #[test]
192    fn expanded_adds_context_line() {
193        let ctx = TestCtx { truncated: 3 };
194        let s = render(&ctx, Lod::Expanded);
195        assert!(s.contains("post-failure tail truncated"));
196        assert!(s.contains("3 more phases"));
197        assert!(s.contains("dryrun=phase"));
198        // Multi-line.
199        assert_eq!(s.lines().count(), 3);
200    }
201
202    #[test]
203    fn explanation_describes_the_rollup() {
204        let ctx = TestCtx { truncated: 5 };
205        let mut s = String::new();
206        let mut buf = StringBuf::new(&mut s);
207        let n = TruncatedPhases.render(
208            &ctx,
209            Lod::Labeled,
210            ContentMode::Explanation,
211            &ReadoutOptions::new(),
212            &mut buf,
213        );
214        assert!(n > 0);
215        assert!(s.contains("<count>"));
216        assert!(s.contains("dryrun=phase"));
217    }
218}