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}