Skip to main content

nmbrs_runtime/readouts/builtins/
error_readout.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `error_readout` — phase-end error block.
5//!
6//! Renders the structured error list the executor collected
7//! on `PhaseOutcome.errors` during the phase. Designed to
8//! sit AFTER `phase_outcome` in the `on_phase_end` slot so
9//! the operator sees the normative status line first
10//! (✓/✗/~/…) and then the error detail block when the phase
11//! failed.
12//!
13//! Renders nothing when there's nothing that ULTIMATELY FAILED:
14//! empty error list, OR a phase where every error was retried and
15//! recovered (`failed_ops = errors - retries == 0` and status is not
16//! `Failed`). Recovered/transient errors are surfaced as the
17//! `e:/r:` counters on the phase summary line, not as an error block
18//! — an error block on a *successful* phase reads as a failure.
19//! Their full detail is still in the persisted `phase_errors` rows
20//! (`nmbrs replay`). So binding this to every `on_phase_end` is safe:
21//! successful phases (even retry-heavy ones) produce no extra output.
22//!
23//! Per-LOD shape:
24//! - **Compact** — single-line summary chip: `(errors:N)`,
25//!   or empty when no errors.
26//! - **Labeled** — header line + first error line:
27//!   `errors: [class] message`
28//!   `  (+M more)` when more than one.
29//! - **Expanded** — full per-error block with class, message,
30//!   cycle, op-template, op-resolved. Mirrors the existing
31//!   Expanded LOD of `phase_outcome` but stands alone so it
32//!   can be bound separately.
33//!
34//! Subject kind: Phase (the error list lives on the phase
35//! outcome).
36
37use 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        // Explanation (help text) always renders.
67        if matches!(mode, ContentMode::Explanation) {
68            return render_explanation(out);
69        }
70        // Only surface the error-detail block for ops that ULTIMATELY
71        // FAILED. Retried-and-recovered errors are transient: their
72        // counts already appear in the phase summary's `e:/r:` tail, so
73        // rendering them here would make a *successful* phase look
74        // failed. `failed_ops = errors - retries` mirrors the activity's
75        // own derivation (`activity.rs` end-of-phase); a stopped phase
76        // is `Failed` even if that subtraction happens to be zero.
77        // Recovered-error detail still lives in the persisted
78        // `phase_errors` rows (`nmbrs replay`).
79        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            // Explanation handled above; kept for match exhaustiveness.
89            (_, ContentMode::Explanation) => render_explanation(out),
90        }
91    }
92}
93
94/// True total error count for the phase. `ctx.errors()` is the
95/// uncapped `errors_total` counter (every failed attempt); the
96/// `outcome_errors()` buffer is capped (`PHASE_ERROR_CAPTURE_CAP`),
97/// so the headline count must come from the counter, not the
98/// buffer length — otherwise a phase with 200 errors reads "64".
99/// `.max(captured)` guards the (impossible-in-practice) case of a
100/// context whose counter lags the captured list.
101fn 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
123/// Re-indent every line after the first to `prefix` so an
124/// embedded newline in a driver-supplied message doesn't break
125/// out of the surrounding readout block's indent.
126///
127/// CQL `cassandra-cpp` driver errors carry the offending
128/// statement inline as a multi-line string (e.g. an INSERT
129/// formatted across rows); without re-indentation those rows
130/// land at column 0 instead of nesting under the `errors:`
131/// header.
132fn 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    // No leading newline — the readout binder's Block layout
149    // inserts the separator between bound readouts itself. A
150    // leading `\n` here would produce a doubled separator
151    // (`\n\n` = visible blank row above the error block) when
152    // bound after `phase_outcome` in the `on_phase_end` slot.
153    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        // `+N more` is relative to the TRUE total, not the capped
171        // buffer. When the buffer was capped (captured < total), say
172        // so — and point only at sources that actually hold the
173        // detail: the Expanded LOD (the in-memory captured set) and
174        // `nmbrs replay` (the persisted phase_errors rows). Per-cycle
175        // errors are NOT written to session.log, so the old
176        // "see session.log" pointer was a dead end.
177        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    // No leading newline — the binder's Block layout inserts
205    // the separator. See `render_labeled` for the rationale.
206    // Header shows the TRUE total; when the capture buffer was
207    // capped, note how many of the total are listed below.
208    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        // The capture buffer (PHASE_ERROR_CAPTURE_CAP) filled before
241        // all errors arrived — surface the shortfall so the listed
242        // set isn't mistaken for the whole story.
243        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        /// True `errors_total` count. 0 (default) → `errors()`
273        /// falls back to the captured-list length, preserving the
274        /// pre-cap behaviour for tests that don't exercise capping.
275        total_errors: u64,
276        /// Derived retry count (`errors - failed_ops`). With
277        /// `total_errors`, drives `failed_ops = errors - retries` —
278        /// the gate for whether the block renders at all.
279        retries: u64,
280        /// Marks the phase as failed (stopped) regardless of the
281        /// count math.
282        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        // A successful phase whose errors were all retried-and-
382        // recovered (failed_ops = errors - retries == 0, not Failed)
383        // must produce NO error block in any value LOD — the e:/r:
384        // counters on the summary line carry the info, and an error
385        // block would make a successful phase look failed.
386        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_ops = 200 - 200 = 0
393            failed: false, // phase completed
394            ..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        // Some recovered, some terminal: failed_ops = 200 - 195 = 5 > 0
416        // → the block renders (real failures occurred).
417        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        // Belt-and-suspenders: a stopped phase is Failed even if
435        // failed_ops math happens to be 0 (e.g. a stop on a
436        // non-op-counted error) — the block must still render.
437        let ctx = TestCtx {
438            errors: vec![err("StopErr", "fatal", Some(0))],
439            total_errors: 1,
440            retries: 1,   // failed_ops = 0
441            failed: true, // but the phase stopped
442            ..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        // No leading newline — the binder's Block-layout
477        // dispatcher inserts the separator between bound
478        // readouts. A leading `\n` here would double up to
479        // `\n\n` (visible blank row) above the error block.
480        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        // 64 captured, 200 actually occurred. The tail must report
492        // the TRUE remainder (199), note the captured count (64),
493        // point at a real source, and NOT claim session.log.
494        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        // total == captured → no "; N captured" note, just "+N more".
558        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    /// CQL driver errors carry the offending statement inline
617    /// as a multi-line string. Every line after the first must
618    /// inherit the surrounding readout's indent so the output
619    /// doesn't break out to column 0.
620    #[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(), // 16-space depth indent
629            failed: true,
630            ..Default::default()
631        };
632        let out = render(&ctx, Lod::Labeled);
633        // Every line that ISN'T the leading newline or the
634        // first error line must start with at least the
635        // depth indent — never column 0, never < indent.
636        let depth = "                "; // matches ctx.indent
637        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}