Skip to main content

nmbrs_runtime/readouts/builtins/
phase_starting.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `phase_starting` — opt-in pre-phase header. Push 2
5//! removed the unconditional `▶ phase 'X' starting` row
6//! from the live output; this readout brings it back as a
7//! workload-bound option, default-targetting `on_phase_start`.
8//!
9//! Compact / Labeled / Expanded show progressively more
10//! context (name only → name + seq → name + seq + iter
11//! tuple labels). Explanation describes the row's purpose.
12
13use std::fmt::Write as _;
14
15use crate::lifecycle::SubjectKind;
16use crate::readouts::buf::ReadoutBuf;
17use crate::readouts::context::ReadoutContext;
18use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
19
20pub struct PhaseStarting;
21
22impl Readout for PhaseStarting {
23    fn name(&self) -> &'static str {
24        "phase_starting"
25    }
26    fn accepts(&self) -> &'static [SubjectKind] {
27        &[SubjectKind::Phase]
28    }
29
30    fn render(
31        &self,
32        ctx: &dyn ReadoutContext,
33        lod: Lod,
34        mode: ContentMode,
35        _opts: &ReadoutOptions,
36        out: &mut dyn ReadoutBuf,
37    ) -> usize {
38        match (lod, mode) {
39            (Lod::Compact, ContentMode::Value) => render_compact(ctx, out),
40            (Lod::Labeled, ContentMode::Value) => render_labeled(ctx, out),
41            (Lod::Expanded, ContentMode::Value) => render_expanded(ctx, out),
42            (_, ContentMode::Explanation) => render_explanation(out),
43        }
44    }
45}
46
47fn render_compact(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
48    let color = ctx.use_color();
49    let green = if color { "\x1b[32m" } else { "" };
50    let reset = if color { "\x1b[0m" } else { "" };
51    let name = ctx.subject_name();
52    let depth_indent = ctx.depth_indent();
53    let mut tmp = String::with_capacity(64);
54    let _ = write!(&mut tmp, "{depth_indent}{green}▶{reset} {name}");
55    let len = tmp.len();
56    let _ = out.write_str(&tmp);
57    len
58}
59
60fn render_labeled(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
61    // Same prefix shape as `phase_outcome` so the START / DONE
62    // pair line up vertically:
63    //   {indent}▶ [idx/total] [name] (coords) starting
64    //   {indent}✓ [idx/total] [name] (coords) 100%
65    let color = ctx.use_color();
66    let green = if color { "\x1b[32m" } else { "" };
67    let bold = if color { "\x1b[1m" } else { "" };
68    let dim = if color { "\x1b[2m" } else { "" };
69    let blue = if color { "\x1b[34m" } else { "" };
70    let reset = if color { "\x1b[0m" } else { "" };
71    let name = ctx.subject_name();
72    let labels = ctx.subject_labels();
73    let depth_indent = ctx.depth_indent();
74    let seq_part: String = match ctx.subject_seq() {
75        Some((s, t)) => format!("{dim}[{s}/{t}]{reset} "),
76        None => String::new(),
77    };
78    let seq_visible: usize = match ctx.subject_seq() {
79        Some((s, t)) => format!("[{s}/{t}] ").chars().count(),
80        None => 0,
81    };
82    // SRD-? — phase_starting is the entry into the
83    // ACTIVE phase. Use the full-stack lens
84    // (`summarize_changed_only: false`) so the operator
85    // sees the complete coord context as the phase begins;
86    // changed values relative to the prior completed
87    // phase remain highlighted. This call only READS the
88    // tracker; the corresponding `phase_outcome` at end
89    // advances it.
90    let head_consumed: usize = depth_indent.chars().count()
91        + 2  // ▶ + space
92        + seq_visible
93        + 2  // [ and ]
94        + name.chars().count();
95    let coords_part = super::phase_outcome::format_coords_block(
96        labels,
97        color,
98        head_consumed,
99        &format!("{depth_indent}  "),
100        /* summarize_changed_only */ false,
101    );
102    let mut tmp = String::with_capacity(160);
103    let _ = write!(
104        &mut tmp,
105        "{depth_indent}{green}▶{reset} {seq_part}{bold}{blue}[{name}]{reset}{coords_part} starting",
106    );
107    let len = tmp.len();
108    let _ = out.write_str(&tmp);
109    len
110}
111
112fn render_expanded(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
113    // Same prefix as Labeled; iter-tuple coords on their own
114    // line below (matching phase_outcome's Expanded shape).
115    let color = ctx.use_color();
116    let green = if color { "\x1b[32m" } else { "" };
117    let bold = if color { "\x1b[1m" } else { "" };
118    let dim = if color { "\x1b[2m" } else { "" };
119    let blue = if color { "\x1b[34m" } else { "" };
120    let reset = if color { "\x1b[0m" } else { "" };
121    let name = ctx.subject_name();
122    let labels = ctx.subject_labels();
123    let depth_indent = ctx.depth_indent();
124    let seq_part: String = match ctx.subject_seq() {
125        Some((s, t)) => format!("{dim}[{s}/{t}]{reset} "),
126        None => String::new(),
127    };
128    let mut tmp = String::with_capacity(192);
129    let _ = write!(
130        &mut tmp,
131        "{depth_indent}{green}▶{reset} {seq_part}{bold}{blue}[{name}]{reset} starting",
132    );
133    if !labels.is_empty() {
134        // SRD-? — full coord stack on its own line for the
135        // expanded form, with change-highlight against the
136        // prior completed phase. Read-only against the
137        // tracker (phase_outcome is the advancement event).
138        let coords_continuation_indent = format!("{depth_indent}  ");
139        let coords_head_consumed = depth_indent.chars().count() + 2;
140        let coords_payload = super::phase_outcome::format_coords_block(
141            labels,
142            color,
143            coords_head_consumed,
144            &coords_continuation_indent,
145            /* summarize_changed_only */ false,
146        );
147        // The helper returns a leading-space payload;
148        // strip it because we already have the continuation
149        // indent providing the visual offset.
150        let payload = coords_payload.strip_prefix(' ').unwrap_or(&coords_payload);
151        if !payload.is_empty() {
152            let _ = write!(&mut tmp, "\n{depth_indent}  {payload}");
153        }
154    }
155    let len = tmp.len();
156    let _ = out.write_str(&tmp);
157    len
158}
159
160fn render_explanation(out: &mut dyn ReadoutBuf) -> usize {
161    let s = "▶ {phase-name} ({idx}/{total}) starting — opt-in pre-phase header";
162    let _ = out.write_str(s);
163    s.len()
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::readouts::buf::StringBuf;
170
171    #[derive(Default)]
172    struct TestCtx {
173        name: String,
174        seq: Option<(usize, usize)>,
175        labels: String,
176        use_color: bool,
177    }
178    impl ReadoutContext for TestCtx {
179        fn subject_name(&self) -> &str {
180            &self.name
181        }
182        fn subject_seq(&self) -> Option<(usize, usize)> {
183            self.seq
184        }
185        fn subject_labels(&self) -> &str {
186            &self.labels
187        }
188        fn cycles_completed(&self) -> u64 {
189            0
190        }
191        fn cycles_total(&self) -> u64 {
192            0
193        }
194        fn ops_ok(&self) -> u64 {
195            0
196        }
197        fn errors(&self) -> u64 {
198            0
199        }
200        fn retries(&self) -> u64 {
201            0
202        }
203        fn concurrency(&self) -> usize {
204            0
205        }
206        fn elapsed_secs(&self) -> f64 {
207            0.0
208        }
209        fn consumed(&self) -> u64 {
210            0
211        }
212        fn status_metric_chips(&self) -> String {
213            String::new()
214        }
215        fn depth_indent(&self) -> &str {
216            ""
217        }
218        fn use_color(&self) -> bool {
219            self.use_color
220        }
221        fn event(&self) -> crate::lifecycle::EventType {
222            crate::lifecycle::EventType::PhaseStart
223        }
224    }
225
226    fn render(ctx: &TestCtx, lod: Lod) -> String {
227        let mut s = String::new();
228        let mut buf = StringBuf::new(&mut s);
229        PhaseStarting.render(
230            ctx,
231            lod,
232            ContentMode::Value,
233            &ReadoutOptions::new(),
234            &mut buf,
235        );
236        s
237    }
238
239    #[test]
240    fn labeled_with_seq() {
241        let ctx = TestCtx {
242            name: "run".into(),
243            seq: Some((3, 8)),
244            ..Default::default()
245        };
246        // Prefix shape matches phase_outcome so the start/done
247        // pair line up: ▶ [seq] [name] [(coords)] starting.
248        assert_eq!(render(&ctx, Lod::Labeled), "▶ [3/8] [run] starting");
249    }
250
251    #[test]
252    fn labeled_without_seq() {
253        let ctx = TestCtx {
254            name: "setup".into(),
255            ..Default::default()
256        };
257        assert_eq!(render(&ctx, Lod::Labeled), "▶ [setup] starting");
258    }
259
260    #[test]
261    fn compact_drops_seq_and_starting_word() {
262        let ctx = TestCtx {
263            name: "run".into(),
264            seq: Some((3, 8)),
265            ..Default::default()
266        };
267        assert_eq!(render(&ctx, Lod::Compact), "▶ run");
268    }
269
270    #[test]
271    fn expanded_includes_iter_labels() {
272        let ctx = TestCtx {
273            name: "run".into(),
274            seq: Some((3, 8)),
275            labels: "(profile=alpha, k=10)".into(),
276            ..Default::default()
277        };
278        let out = render(&ctx, Lod::Expanded);
279        assert!(out.contains("[3/8]"), "got: {out:?}");
280        assert!(out.contains("[run]"), "got: {out:?}");
281        assert!(out.contains("(profile=alpha, k=10)"), "got: {out:?}");
282        assert!(out.lines().count() >= 2);
283    }
284}