nmbrs_runtime/readouts/builtins/
truncated_phases.rs1use 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 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 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 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 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}