nmbrs_runtime/readouts/builtins/
phase_summary.rs1use 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 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 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 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 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 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 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 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 assert!(
405 s.contains("\x1b[32m[ok]\x1b[0m"),
406 "expected green [ok] marker, got: {s:?}"
407 );
408 assert!(
410 s.contains("\x1b[1m\x1b[34msetup\x1b[0m"),
411 "expected bold-blue phase name, got: {s:?}"
412 );
413 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 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 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}