Skip to main content

nmbrs_runtime/readouts/
format.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Small presentation helpers shared across readouts.
5//!
6//! Duration formatting, rate auto-scaling, the braille
7//! progress bar, and the spinner frame cycle. These were
8//! private to `nmbrs-runtime::activity` before Push 2 and
9//! now live next to the readouts that consume them.
10
11/// Standard 10-frame braille spinner cycle. Picks a frame
12/// deterministically from `tick % 10` so a refresh actor
13/// firing at a steady cadence renders smooth animation.
14pub fn spinner_frame(tick: u64) -> char {
15    static FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
16    FRAMES[(tick as usize) % FRAMES.len()]
17}
18
19/// Per-cell completion bar — variant of [`braille_bar`] used
20/// when the phase's total extent is small enough that each
21/// cell can represent ONE operation rather than a percentage
22/// slice. Renders ballot-box glyphs:
23///
24/// - `☒` (U+2612) — completed with an error
25/// - `☑` (U+2611) — completed successfully
26/// - `☐` (U+2610) — pending (not yet completed)
27///
28/// Cells are grouped in `errors → successes → pending` order
29/// so a glance reads worst-news-first; with zero errors the
30/// bar degenerates cleanly to the conventional
31/// `☑☑☑…☐☐☐…` shape.
32///
33/// `total` is the cell count (= number of operations, capped
34/// at 10 to match the visual width of the braille variant).
35/// `successes` and `errors` are clamped to `total` so a
36/// brief over-count race during refresh doesn't render a
37/// malformed bar.
38pub fn ballot_bar(total: u64, successes: u64, errors: u64) -> String {
39    if total == 0 {
40        return String::new();
41    }
42    let total = (total as usize).min(10);
43    let errors = (errors as usize).min(total);
44    let successes = (successes as usize).min(total - errors);
45    let pending = total - errors - successes;
46    let mut s = String::with_capacity(total * 3);
47    for _ in 0..errors {
48        s.push('\u{2612}');
49    } // ☒
50    for _ in 0..successes {
51        s.push('\u{2611}');
52    } // ☑
53    for _ in 0..pending {
54        s.push('\u{2610}');
55    } // ☐
56    s
57}
58
59/// 10-character braille completion bar. `pct` is clamped to
60/// [0, 100]; each char represents 10 percentage points
61/// with 8 within-char sub-levels via the standard bottom-up
62/// braille fill pattern, so the bar fills smoothly at
63/// ~1.25-percent resolution.
64pub fn braille_bar(pct: f64, width: usize) -> String {
65    static FILL: [char; 9] = [
66        '\u{2800}', // ⠀  empty
67        '\u{2840}', // ⡀  +dot 7
68        '\u{28C0}', // ⣀  +dot 8
69        '\u{28C4}', // ⣄  +dot 3
70        '\u{28E4}', // ⣤  +dot 6
71        '\u{28E6}', // ⣦  +dot 2
72        '\u{28F6}', // ⣶  +dot 5
73        '\u{28F7}', // ⣷  +dot 1
74        '\u{28FF}', // ⣿  full (+dot 4)
75    ];
76    if width == 0 {
77        return String::new();
78    }
79    let bounded = pct.clamp(0.0, 100.0);
80    let total = (bounded / 100.0 * (width as f64) * 8.0).round() as usize;
81    let total = total.min(width * 8);
82    let full = total / 8;
83    let part = total % 8;
84    let mut s = String::with_capacity(width * 3);
85    for _ in 0..full {
86        s.push(FILL[8]);
87    }
88    if full < width {
89        s.push(FILL[part]);
90        for _ in (full + 1)..width {
91            s.push(FILL[0]);
92        }
93    }
94    s
95}
96
97/// Compact ETA ladder: under a minute → `Ns`; under an
98/// hour → `NmMMs`; otherwise → `NhMMm`. Returns `—` for
99/// non-finite / negative inputs so a stalled rate doesn't
100/// produce a misleading number.
101pub fn format_eta(remaining_secs: f64) -> String {
102    if !remaining_secs.is_finite() || remaining_secs < 0.0 {
103        return "—".to_string();
104    }
105    let secs = remaining_secs.round() as u64;
106    if secs < 60 {
107        format!("{secs}s")
108    } else if secs < 3600 {
109        format!("{}m{:02}s", secs / 60, secs % 60)
110    } else {
111        format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
112    }
113}
114
115/// Compact 8-char session-elapsed clock with a unit suffix
116/// (`s`/`m`/`h`) that scales as the run gets longer. The
117/// fractional precision shrinks step-by-step so the integer
118/// part stays bounded and the total width is constant — log
119/// rows column-align cleanly even when the suffix changes
120/// underneath them.
121///
122/// Layout (always 8 visible chars):
123///
124/// | Magnitude     | Example     | Shape              |
125/// |---------------|-------------|--------------------|
126/// | 0 – 9.99999s  | `1.23456s`  | 1 int + 5 frac + s |
127/// | 10 – 99.9999s | `99.1234s`  | 2 int + 4 frac + s |
128/// | 100 – 999.999s| `999.123s`  | 3 int + 3 frac + s |
129/// | 16m – 1h      | `1.23456m`  | 1 int + 5 frac + m |
130/// | 10m – 100m    | `99.1234m`  | 2 int + 4 frac + m |
131/// | 100m – 1000m  | `999.123m`  | 3 int + 3 frac + m |
132/// | 1h+           | `1.23456h`  | hours scaled       |
133/// | 10h+          | `99.1234h`  |                    |
134/// | 100h+         | `999.123h`  |                    |
135///
136/// Beyond 999.999h (≈ 41 days) the column widens, matching
137/// the `format_elapsed_seconds` precedent for runs longer
138/// than fit in 7 chars: the integer part grows, the
139/// fractional precision stays at 3.
140pub fn format_compact_session_elapsed(secs: f64) -> String {
141    let s = secs.max(0.0);
142    if s < 60.0 * 60.0 * 100.0 {
143        // Within hours threshold — pick the unit that gives
144        // an integer part of 1-3 digits and use the matching
145        // fractional precision.
146        if s < 10.0 {
147            format!("{s:.5}s")
148        } else if s < 100.0 {
149            format!("{s:.4}s")
150        } else if s < 1000.0 {
151            format!("{s:.3}s")
152        } else {
153            let m = s / 60.0;
154            if m < 10.0 {
155                format!("{m:.5}m")
156            } else if m < 100.0 {
157                format!("{m:.4}m")
158            } else if m < 1000.0 {
159                format!("{m:.3}m")
160            } else {
161                let h = s / 3600.0;
162                if h < 10.0 {
163                    format!("{h:.5}h")
164                } else {
165                    format!("{h:.4}h")
166                }
167            }
168        }
169    } else {
170        // 100h+ — integer part is ≥3 digits.
171        let h = s / 3600.0;
172        format!("{h:.3}h")
173    }
174}
175
176/// ANSI color span surrounding a compact session-elapsed
177/// string. Color tracks magnitude so a quick glance reveals
178/// how deep into the run the log line was:
179///
180/// - Sub-minute → dim (faint), the typical bring-up phase
181/// - Sub-hour → default (no override), the steady-state
182/// - 1h+ → bold (emphasized), long-running attention cue
183///
184/// Returns `(open, close)`. Both are empty strings when
185/// `color` is false so the formatter stays usable in
186/// pipelined / NO_COLOR contexts.
187pub fn session_elapsed_color(secs: f64, color: bool) -> (&'static str, &'static str) {
188    if !color {
189        return ("", "");
190    }
191    if secs < 60.0 {
192        ("\x1b[2m", "\x1b[0m") // dim — early-run
193    } else if secs < 3600.0 {
194        ("", "") // default — mid-run
195    } else {
196        ("\x1b[1m", "\x1b[0m") // bold — long-run
197    }
198}
199
200/// Auto-scaled throughput rate.
201pub fn format_rate(rate: f64) -> String {
202    if rate >= 1_000_000.0 {
203        format!("{:.1}M/s", rate / 1_000_000.0)
204    } else if rate >= 1_000.0 {
205        format!("{:.1}K/s", rate / 1_000.0)
206    } else {
207        format!("{:.0}/s", rate)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn ballot_bar_empty_total_returns_empty_string() {
217        assert_eq!(ballot_bar(0, 0, 0), "");
218    }
219
220    /// The compact session timer keeps an 8-char-wide field
221    /// across the seconds/minutes/hours boundaries so log
222    /// rows column-align cleanly regardless of run length.
223    #[test]
224    fn compact_session_elapsed_seconds_band() {
225        assert_eq!(format_compact_session_elapsed(0.0), "0.00000s");
226        assert_eq!(format_compact_session_elapsed(1.23456), "1.23456s");
227        assert_eq!(format_compact_session_elapsed(9.99999), "9.99999s");
228        assert_eq!(format_compact_session_elapsed(10.0), "10.0000s");
229        assert_eq!(format_compact_session_elapsed(99.9999), "99.9999s");
230        assert_eq!(format_compact_session_elapsed(100.0), "100.000s");
231        assert_eq!(format_compact_session_elapsed(999.999), "999.999s");
232    }
233
234    #[test]
235    fn compact_session_elapsed_minutes_band() {
236        // 1000s ≈ 16.67m — into the minutes band.
237        assert_eq!(format_compact_session_elapsed(1000.0), "16.6667m");
238        // Crossing through 1h.
239        assert_eq!(format_compact_session_elapsed(3599.0), "59.9833m");
240        assert_eq!(format_compact_session_elapsed(3600.0), "60.0000m");
241        // Approaching the hours band.
242        assert_eq!(format_compact_session_elapsed(59940.0), "999.000m");
243    }
244
245    #[test]
246    fn compact_session_elapsed_hours_band() {
247        // 60000s = 16.667h — hours band.
248        assert_eq!(format_compact_session_elapsed(60000.0), "16.6667h");
249        // Long runs widen the integer part but keep 3 frac.
250        assert_eq!(format_compact_session_elapsed(360000.0), "100.000h");
251    }
252
253    /// Every value in 0..=999h must produce exactly 8 visible
254    /// characters so the gutter stays column-aligned.
255    #[test]
256    fn compact_session_elapsed_fixed_width_under_a_thousand_hours() {
257        let samples = [
258            0.0, 0.5, 1.0, 9.999, 10.0, 99.99, 100.0, 999.999, 1000.0, 5000.0, 59940.0, 60000.0,
259            360000.0, 3500000.0,
260        ];
261        for s in samples {
262            let out = format_compact_session_elapsed(s);
263            assert_eq!(
264                out.chars().count(),
265                8,
266                "elapsed={s} produced {out:?} (width != 8)"
267            );
268        }
269    }
270
271    /// Color span tracks magnitude buckets.
272    #[test]
273    fn session_elapsed_color_buckets() {
274        // No color → empty spans regardless of magnitude.
275        assert_eq!(session_elapsed_color(0.5, false), ("", ""));
276        assert_eq!(session_elapsed_color(3600.0, false), ("", ""));
277        // With color, dim under a minute, default under an
278        // hour, bold beyond.
279        let (open_sub, _) = session_elapsed_color(0.5, true);
280        assert_eq!(open_sub, "\x1b[2m");
281        let (open_mid, _) = session_elapsed_color(120.0, true);
282        assert_eq!(open_mid, "");
283        let (open_long, _) = session_elapsed_color(7200.0, true);
284        assert_eq!(open_long, "\x1b[1m");
285    }
286
287    #[test]
288    fn ballot_bar_groups_errors_first_then_successes_then_pending() {
289        // 10 ops: 2 errors, 5 successes, 3 pending.
290        assert_eq!(ballot_bar(10, 5, 2), "☒☒☑☑☑☑☑☐☐☐");
291    }
292
293    #[test]
294    fn ballot_bar_all_successful_degenerates_to_check_only_then_pending() {
295        assert_eq!(ballot_bar(5, 3, 0), "☑☑☑☐☐");
296    }
297
298    #[test]
299    fn ballot_bar_clamps_oversize_total_to_ten() {
300        // Caller passing total > 10 (caller's threshold logic
301        // bugged) clamps so the bar never exceeds the visual
302        // width budget.
303        assert_eq!(ballot_bar(15, 0, 0).chars().count(), 10);
304    }
305
306    #[test]
307    fn ballot_bar_clamps_overflowing_counters() {
308        // Counter race during refresh — successes + errors
309        // exceeds total. Errors take priority, successes get
310        // the remainder, no pending.
311        assert_eq!(ballot_bar(3, 99, 1), "☒☑☑");
312    }
313}