1use std::fmt::Write as _;
38
39use crate::lifecycle::SubjectKind;
40use crate::readouts::buf::ReadoutBuf;
41use crate::readouts::context::ReadoutContext;
42use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
43
44pub struct ErrorReadout;
45
46impl Readout for ErrorReadout {
47 fn name(&self) -> &'static str {
48 "error_readout"
49 }
50 fn accepts(&self) -> &'static [SubjectKind] {
51 &[SubjectKind::Phase]
52 }
53
54 fn render(
55 &self,
56 ctx: &dyn ReadoutContext,
57 lod: Lod,
58 mode: ContentMode,
59 _opts: &ReadoutOptions,
60 out: &mut dyn ReadoutBuf,
61 ) -> usize {
62 let errors = ctx.outcome_errors();
63 if errors.is_empty() {
64 return 0;
65 }
66 if matches!(mode, ContentMode::Explanation) {
68 return render_explanation(out);
69 }
70 let failed_ops = ctx.errors().saturating_sub(ctx.retries());
80 let phase_failed = failed_ops > 0 || ctx.outcome().is_failure();
81 if !phase_failed {
82 return 0;
83 }
84 match (lod, mode) {
85 (Lod::Compact, ContentMode::Value) => render_compact(ctx, out),
86 (Lod::Labeled, ContentMode::Value) => render_labeled(ctx, out),
87 (Lod::Expanded, ContentMode::Value) => render_expanded(ctx, out),
88 (_, ContentMode::Explanation) => render_explanation(out),
90 }
91 }
92}
93
94fn total_and_captured(ctx: &dyn ReadoutContext) -> (u64, u64) {
102 let captured = ctx.outcome_errors().len() as u64;
103 let total = ctx.errors().max(captured);
104 (total, captured)
105}
106
107fn render_compact(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
108 let color = ctx.use_color();
109 let red = if color { "\x1b[31m" } else { "" };
110 let dim = if color { "\x1b[2m" } else { "" };
111 let reset = if color { "\x1b[0m" } else { "" };
112 let (total, _) = total_and_captured(ctx);
113 let mut tmp = String::with_capacity(32);
114 let _ = write!(
115 &mut tmp,
116 "{dim}({reset}{red}errors:{total}{reset}{dim}){reset}"
117 );
118 let len = tmp.len();
119 let _ = out.write_str(&tmp);
120 len
121}
122
123fn indent_continuations(s: &str, prefix: &str) -> String {
133 let mut out = String::with_capacity(s.len() + prefix.len() * 4);
134 let mut first = true;
135 for line in s.split('\n') {
136 if first {
137 first = false;
138 } else {
139 out.push('\n');
140 out.push_str(prefix);
141 }
142 out.push_str(line);
143 }
144 out
145}
146
147fn render_labeled(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
148 let errors = ctx.outcome_errors();
154 let color = ctx.use_color();
155 let red = if color { "\x1b[31m" } else { "" };
156 let dim = if color { "\x1b[2m" } else { "" };
157 let reset = if color { "\x1b[0m" } else { "" };
158 let indent = ctx.depth_indent();
159 let first = errors.first().expect("checked non-empty by caller");
160 let (total, captured) = total_and_captured(ctx);
161 let continuation = format!("{indent} ");
162 let msg = indent_continuations(&first.message, &continuation);
163 let mut tmp = String::with_capacity(96 + msg.len());
164 let _ = write!(
165 &mut tmp,
166 "{indent} {red}errors:{reset} {red}[{class}]{reset} {msg}",
167 class = first.class,
168 );
169 if total > 1 {
170 let more = total - 1;
178 let cap_note = if captured < total {
179 format!("; {captured} captured")
180 } else {
181 String::new()
182 };
183 let _ = write!(
184 &mut tmp,
185 "\n{indent} {dim}(+{more} more{cap_note} — see Expanded LOD or `nmbrs replay`){reset}"
186 );
187 }
188 let len = tmp.len();
189 let _ = out.write_str(&tmp);
190 len
191}
192
193fn render_expanded(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
194 let errors = ctx.outcome_errors();
195 let color = ctx.use_color();
196 let red = if color { "\x1b[31m" } else { "" };
197 let dim = if color { "\x1b[2m" } else { "" };
198 let reset = if color { "\x1b[0m" } else { "" };
199 let indent = ctx.depth_indent();
200 let msg_continuation = format!("{indent} ");
201 let detail_continuation = format!("{indent} ");
202 let (total, captured) = total_and_captured(ctx);
203 let mut tmp = String::with_capacity(256);
204 let header = if captured < total {
209 format!("{captured} of {total} captured")
210 } else {
211 format!("{total}")
212 };
213 let _ = write!(
214 &mut tmp,
215 "{indent} {red}errors{reset} {dim}({header}){reset}:"
216 );
217 for e in errors {
218 let msg = indent_continuations(&e.message, &msg_continuation);
219 let _ = write!(
220 &mut tmp,
221 "\n{indent} {red}[{class}]{reset} {msg}",
222 class = e.class
223 );
224 if let Some(c) = e.cycle {
225 let _ = write!(&mut tmp, "\n{indent} {dim}cycle:{reset} {c}");
226 }
227 if let Some(op) = &e.op_name {
228 let _ = write!(&mut tmp, "\n{indent} {dim}op:{reset} {op}");
229 }
230 if let Some(t) = &e.op_template {
231 let t = indent_continuations(t, &detail_continuation);
232 let _ = write!(&mut tmp, "\n{indent} {dim}op-template:{reset} {t}");
233 }
234 if let Some(r) = &e.op_resolved {
235 let r = indent_continuations(r, &detail_continuation);
236 let _ = write!(&mut tmp, "\n{indent} {dim}op-resolved:{reset} {r}");
237 }
238 }
239 if captured < total {
240 let _ = write!(
244 &mut tmp,
245 "\n{indent} {dim}(+{} more occurred — not captured (buffer cap)){reset}",
246 total - captured
247 );
248 }
249 let len = tmp.len();
250 let _ = out.write_str(&tmp);
251 len
252}
253
254fn render_explanation(out: &mut dyn ReadoutBuf) -> usize {
255 let s = "error_readout — per-phase error block; renders only when the phase recorded \
256 at least one structured error. Compact = count summary; Labeled = first error \
257 line with extra-count tail; Expanded = full per-error block with cycle / \
258 op-template / op-resolved detail";
259 let _ = out.write_str(s);
260 s.len()
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::phase_outcome::PhaseErrorDetail;
267 use crate::readouts::buf::StringBuf;
268
269 #[derive(Default)]
270 struct TestCtx {
271 errors: Vec<PhaseErrorDetail>,
272 total_errors: u64,
276 retries: u64,
280 failed: bool,
283 indent: String,
284 color: bool,
285 }
286
287 impl ReadoutContext for TestCtx {
288 fn subject_name(&self) -> &str {
289 ""
290 }
291 fn subject_seq(&self) -> Option<(usize, usize)> {
292 None
293 }
294 fn subject_labels(&self) -> &str {
295 ""
296 }
297 fn cycles_completed(&self) -> u64 {
298 0
299 }
300 fn cycles_total(&self) -> u64 {
301 0
302 }
303 fn ops_ok(&self) -> u64 {
304 0
305 }
306 fn errors(&self) -> u64 {
307 self.total_errors
308 }
309 fn retries(&self) -> u64 {
310 self.retries
311 }
312 fn concurrency(&self) -> usize {
313 0
314 }
315 fn elapsed_secs(&self) -> f64 {
316 0.0
317 }
318 fn consumed(&self) -> u64 {
319 0
320 }
321 fn status_metric_chips(&self) -> String {
322 String::new()
323 }
324 fn depth_indent(&self) -> &str {
325 &self.indent
326 }
327 fn use_color(&self) -> bool {
328 self.color
329 }
330 fn event(&self) -> crate::lifecycle::EventType {
331 crate::lifecycle::EventType::PhaseEnd
332 }
333 fn outcome_errors(&self) -> &[PhaseErrorDetail] {
334 &self.errors
335 }
336 fn outcome(&self) -> crate::phase_outcome::Outcome {
337 if self.failed {
338 crate::phase_outcome::Outcome::failed()
339 } else {
340 crate::phase_outcome::Outcome::completed()
341 }
342 }
343 }
344
345 fn err(class: &str, msg: &str, cycle: Option<u64>) -> PhaseErrorDetail {
346 PhaseErrorDetail {
347 class: class.into(),
348 message: msg.into(),
349 op_name: None,
350 cycle,
351 op_template: None,
352 op_resolved: None,
353 at_nanos: 0,
354 retryable: false,
355 }
356 }
357
358 fn render(ctx: &TestCtx, lod: Lod) -> String {
359 let mut s = String::new();
360 let mut buf = StringBuf::new(&mut s);
361 ErrorReadout.render(
362 ctx,
363 lod,
364 ContentMode::Value,
365 &ReadoutOptions::new(),
366 &mut buf,
367 );
368 s
369 }
370
371 #[test]
372 fn empty_errors_renders_nothing() {
373 let ctx = TestCtx::default();
374 assert_eq!(render(&ctx, Lod::Labeled), "");
375 assert_eq!(render(&ctx, Lod::Compact), "");
376 assert_eq!(render(&ctx, Lod::Expanded), "");
377 }
378
379 #[test]
380 fn recovered_errors_render_nothing() {
381 let captured: Vec<_> = (0..64)
387 .map(|i| err("WriteTimeout", "timed out", Some(i)))
388 .collect();
389 let ctx = TestCtx {
390 errors: captured,
391 total_errors: 200,
392 retries: 200, failed: false, ..Default::default()
395 };
396 assert_eq!(
397 render(&ctx, Lod::Compact),
398 "",
399 "no chip for fully-recovered errors"
400 );
401 assert_eq!(
402 render(&ctx, Lod::Labeled),
403 "",
404 "no block for fully-recovered errors"
405 );
406 assert_eq!(
407 render(&ctx, Lod::Expanded),
408 "",
409 "no block for fully-recovered errors"
410 );
411 }
412
413 #[test]
414 fn partial_failure_still_renders() {
415 let captured: Vec<_> = (0..64).map(|i| err("X", "e", Some(i))).collect();
418 let ctx = TestCtx {
419 errors: captured,
420 total_errors: 200,
421 retries: 195,
422 failed: false,
423 ..Default::default()
424 };
425 let out = render(&ctx, Lod::Labeled);
426 assert!(
427 out.contains("errors:"),
428 "5 ops failed → block must render: {out:?}"
429 );
430 }
431
432 #[test]
433 fn failed_status_renders_even_when_count_math_zero() {
434 let ctx = TestCtx {
438 errors: vec![err("StopErr", "fatal", Some(0))],
439 total_errors: 1,
440 retries: 1, failed: true, ..Default::default()
443 };
444 let out = render(&ctx, Lod::Labeled);
445 assert!(
446 out.contains("StopErr"),
447 "failed phase must render the block regardless of count math: {out:?}"
448 );
449 }
450
451 #[test]
452 fn compact_shows_count_only() {
453 let ctx = TestCtx {
454 errors: vec![err("X", "one", None), err("Y", "two", None)],
455 failed: true,
456 ..Default::default()
457 };
458 let out = render(&ctx, Lod::Compact);
459 assert!(out.contains("errors:2"));
460 assert!(!out.contains("one"));
461 assert!(!out.contains("two"));
462 }
463
464 #[test]
465 fn labeled_shows_first_error_with_more_count() {
466 let ctx = TestCtx {
467 errors: vec![
468 err("CqlParseError", "syntax", Some(7)),
469 err("CqlParseError", "syntax", Some(8)),
470 err("CqlParseError", "syntax", Some(9)),
471 ],
472 failed: true,
473 ..Default::default()
474 };
475 let out = render(&ctx, Lod::Labeled);
476 assert!(
481 !out.starts_with('\n'),
482 "no leading newline — binder owns the separator: {out:?}"
483 );
484 assert!(out.contains("[CqlParseError]"));
485 assert!(out.contains("syntax"));
486 assert!(out.contains("+2 more"));
487 }
488
489 #[test]
490 fn labeled_reports_true_total_and_capped_count() {
491 let captured: Vec<_> = (0..64)
495 .map(|i| err("WriteTimeout", "timed out", Some(i)))
496 .collect();
497 let ctx = TestCtx {
498 errors: captured,
499 total_errors: 200,
500 ..Default::default()
501 };
502 let out = render(&ctx, Lod::Labeled);
503 assert!(
504 out.contains("+199 more"),
505 "tail must report the true remainder (200-1): {out:?}"
506 );
507 assert!(
508 out.contains("64 captured"),
509 "tail must note how many were captured: {out:?}"
510 );
511 assert!(
512 out.contains("nmbrs replay") || out.contains("Expanded LOD"),
513 "tail must point at a source that actually holds the detail: {out:?}"
514 );
515 assert!(
516 !out.contains("session.log"),
517 "tail must not point at session.log — errors aren't recorded there: {out:?}"
518 );
519 }
520
521 #[test]
522 fn compact_uses_true_total_not_capped_buffer() {
523 let captured: Vec<_> = (0..64).map(|i| err("X", "e", Some(i))).collect();
524 let ctx = TestCtx {
525 errors: captured,
526 total_errors: 200,
527 ..Default::default()
528 };
529 let out = render(&ctx, Lod::Compact);
530 assert!(
531 out.contains("errors:200"),
532 "compact chip must show the true total, not the capped buffer length: {out:?}"
533 );
534 }
535
536 #[test]
537 fn expanded_header_shows_captured_of_total_when_capped() {
538 let captured: Vec<_> = (0..64).map(|i| err("X", "e", Some(i))).collect();
539 let ctx = TestCtx {
540 errors: captured,
541 total_errors: 200,
542 ..Default::default()
543 };
544 let out = render(&ctx, Lod::Expanded);
545 assert!(
546 out.contains("64 of 200 captured"),
547 "expanded header must show captured/total when capped: {out:?}"
548 );
549 assert!(
550 out.contains("not captured"),
551 "expanded must note the uncaptured remainder: {out:?}"
552 );
553 }
554
555 #[test]
556 fn labeled_all_captured_omits_cap_note() {
557 let captured: Vec<_> = vec![
559 err("X", "a", Some(0)),
560 err("X", "b", Some(1)),
561 err("X", "c", Some(2)),
562 ];
563 let ctx = TestCtx {
564 errors: captured,
565 total_errors: 3,
566 ..Default::default()
567 };
568 let out = render(&ctx, Lod::Labeled);
569 assert!(out.contains("+2 more"), "true remainder: {out:?}");
570 assert!(
571 !out.contains("captured"),
572 "no cap note when everything was captured: {out:?}"
573 );
574 }
575
576 #[test]
577 fn labeled_single_error_omits_more_suffix() {
578 let ctx = TestCtx {
579 errors: vec![err("X", "only", None)],
580 failed: true,
581 ..Default::default()
582 };
583 let out = render(&ctx, Lod::Labeled);
584 assert!(out.contains("[X]"));
585 assert!(out.contains("only"));
586 assert!(!out.contains("more"));
587 }
588
589 #[test]
590 fn expanded_lists_every_error_with_cycle() {
591 let ctx = TestCtx {
592 errors: vec![err("A", "first", Some(0)), err("B", "second", Some(5))],
593 failed: true,
594 ..Default::default()
595 };
596 let out = render(&ctx, Lod::Expanded);
597 assert!(out.contains("[A]"));
598 assert!(out.contains("first"));
599 assert!(out.contains("cycle:"));
600 assert!(out.contains("[B]"));
601 assert!(out.contains("second"));
602 }
603
604 #[test]
605 fn ansi_emitted_when_color_enabled() {
606 let ctx = TestCtx {
607 errors: vec![err("X", "msg", None)],
608 color: true,
609 failed: true,
610 ..Default::default()
611 };
612 let out = render(&ctx, Lod::Labeled);
613 assert!(out.contains("\x1b[31m"));
614 }
615
616 #[test]
621 fn labeled_multiline_message_keeps_continuation_indent() {
622 let multi = "Cassandra error: timeout\n\
623 statement: INSERT INTO ks.t\n\
624 (a, b, c) VALUES\n\
625 (?, ?, ?)";
626 let ctx = TestCtx {
627 errors: vec![err("cql_error", multi, None)],
628 indent: " ".into(), failed: true,
630 ..Default::default()
631 };
632 let out = render(&ctx, Lod::Labeled);
633 let depth = " "; for (i, line) in out.split('\n').enumerate() {
638 if i == 0 || line.is_empty() {
639 continue;
640 }
641 assert!(
642 line.starts_with(depth),
643 "line {i} broke indent at column 0: {line:?}"
644 );
645 }
646 }
647
648 #[test]
649 fn expanded_multiline_message_keeps_continuation_indent() {
650 let multi = "first line\nsecond line\nthird line";
651 let ctx = TestCtx {
652 errors: vec![err("X", multi, None)],
653 indent: " ".into(),
654 failed: true,
655 ..Default::default()
656 };
657 let out = render(&ctx, Lod::Expanded);
658 let depth = " ";
659 for (i, line) in out.split('\n').enumerate() {
660 if i == 0 || line.is_empty() {
661 continue;
662 }
663 assert!(
664 line.starts_with(depth),
665 "line {i} broke indent at column 0: {line:?}"
666 );
667 }
668 }
669}