1const PROGRESS_TICK_MS: u64 = 100;
4
5const FRACTION_EPSILON: f64 = 1e-10;
6
7const THOUSANDS_GROUP: usize = 3;
8const ELLIPSIS_LENGTH: usize = 3;
9
10use wowlab_types::constants::SECONDS_PER_MINUTE;
11
12const MINUTES_PER_HOUR: u64 = 60;
13
14use std::{fmt::Display, time::Duration};
15
16use console::style;
17use indicatif::ProgressStyle;
18use tabled::settings::{Alignment, Modify, Style as TableStyle, object::Rows};
19
20pub fn header(text: &str) {
22 eprintln!("{}", style(format!("▸ {text}")).bold().cyan());
23}
24
25pub fn subheader(text: &str) {
27 eprintln!("{}", style(text).bold().white().underlined());
28}
29
30pub fn kv(key: &str, val: &str) {
32 eprintln!(" {} {}", style(format!("{key}:")).dim(), style(val).bold());
33}
34
35pub fn kv_fmt(key: &str, val: impl Display) {
37 kv(key, &val.to_string());
38}
39
40pub fn success(msg: &str) {
42 eprintln!("{} {}", style("✓").green().bold(), msg);
43}
44
45pub fn error(msg: &str) {
47 eprintln!("{} {}", style("✗").red().bold(), msg);
48}
49
50pub fn warning(msg: &str) {
52 eprintln!("{} {}", style("!").yellow().bold(), msg);
53}
54
55pub fn separator() {
57 eprintln!("{}", style("─".repeat(50)).dim());
58}
59
60pub fn banner(name: &str, version: &str) {
62 eprintln!(
63 "{} {}\n",
64 style(name).bold().cyan(),
65 style(format!("v{version}")).dim()
66 );
67}
68
69pub fn detail(text: &str) {
71 eprintln!(" {}", style(text).dim());
72}
73
74pub fn info(text: &str) {
76 eprintln!("{text}");
77}
78
79pub fn json(value: &impl serde::Serialize) {
82 println!(
83 "{}",
84 serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
85 );
86}
87
88pub fn table<I, T>(data: I)
90where
91 I: IntoIterator<Item = T>,
92 T: tabled::Tabled,
93{
94 eprintln!("{}", Table::new(data));
95}
96
97pub fn blank() {
99 eprintln!();
100}
101
102#[derive(Debug)]
104pub struct Table {
105 inner: tabled::Table,
106}
107
108impl Table {
109 pub fn new<I, T>(data: I) -> Self
111 where
112 I: IntoIterator<Item = T>,
113 T: tabled::Tabled,
114 {
115 let mut inner = tabled::Table::new(data);
116
117 inner
118 .with(TableStyle::rounded())
119 .with(Modify::new(Rows::new(1..)).with(Alignment::right()));
120
121 Self { inner }
122 }
123
124 #[must_use]
126 pub fn kv(pairs: &[(&str, String)]) -> Self {
127 let rows: Vec<KvRow> = pairs
128 .iter()
129 .map(|(k, v)| KvRow {
130 key: k.to_string(),
131 value: v.clone(),
132 })
133 .collect();
134
135 Self::new(rows)
136 }
137
138 #[must_use]
140 pub fn render(&self) -> String {
141 self.inner.to_string()
142 }
143}
144
145impl Display for Table {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 write!(f, "{}", self.render())
148 }
149}
150
151#[derive(tabled::Tabled)]
152struct KvRow {
153 #[tabled(rename = "Metric")]
154 key: String,
155 #[tabled(rename = "Value")]
156 value: String,
157}
158
159#[derive(Debug)]
161pub struct ProgressBar {
162 inner: indicatif::ProgressBar,
163}
164
165impl ProgressBar {
166 #[must_use]
172 pub fn new(total: u64, message: &str) -> Self {
173 let inner = indicatif::ProgressBar::new(total);
174
175 inner.set_style(
176 ProgressStyle::default_bar()
177 .template("{spinner:.cyan} [{elapsed_precise}] [{bar:40.cyan/dim}] {pos}/{len} ({percent}%) {msg}")
178 .expect("valid progress bar template literal")
179 .progress_chars("━━╸"),
180 );
181 inner.enable_steady_tick(Duration::from_millis(PROGRESS_TICK_MS));
182 inner.set_message(message.to_string());
183
184 Self { inner }
185 }
186
187 pub fn tick(&self, current: u64) {
189 self.inner.set_position(current);
190 }
191
192 pub fn tick_with_stats(&self, current: u64, mean_dps: f64, throughput: f64) {
194 self.inner.set_position(current);
195 self.inner.set_message(format!(
196 "~{} DPS | {} iter/s",
197 fmt_number(mean_dps),
198 fmt_number(throughput),
199 ));
200 }
201
202 pub fn finish(&self, message: &str) {
204 self.inner.finish_and_clear();
205
206 if !message.is_empty() {
207 success(message);
208 }
209 }
210}
211
212#[must_use]
214pub fn fmt_number(n: f64) -> String {
215 let integer_part = wowlab_types::numeric::f64_to_i64_saturating_trunc(n);
216 let frac = n.fract().abs();
217
218 let int_str = fmt_integer_signed(integer_part);
219
220 if frac < FRACTION_EPSILON {
221 int_str
222 } else {
223 let frac_str = format!("{frac:.2}");
224
225 format!("{}{}", int_str, &frac_str[1..])
226 }
227}
228
229#[must_use]
231pub fn fmt_integer(n: u64) -> String {
232 let s = n.to_string();
233 let mut result = String::with_capacity(s.len() + s.len() / THOUSANDS_GROUP);
234
235 for (i, c) in s.chars().rev().enumerate() {
236 if i > 0 && i % THOUSANDS_GROUP == 0 {
237 result.push(',');
238 }
239
240 result.push(c);
241 }
242
243 result.chars().rev().collect()
244}
245
246#[must_use]
248pub fn fmt_pct(n: f64) -> String {
249 format!("{:.1}%", n * 100.0)
250}
251
252#[must_use]
254pub fn fmt_duration(secs: f64) -> String {
255 if secs < SECONDS_PER_MINUTE {
256 return format!("{secs:.1}s");
257 }
258
259 let mins = wowlab_types::numeric::f64_to_u64_saturating_floor(secs / SECONDS_PER_MINUTE);
260 let rem = secs % SECONDS_PER_MINUTE;
261
262 if mins < MINUTES_PER_HOUR {
263 return format!("{mins}m {rem:.1}s");
264 }
265
266 let hours = mins / MINUTES_PER_HOUR;
267 let rem_mins = mins % MINUTES_PER_HOUR;
268
269 format!("{hours}h {rem_mins}m {rem:.0}s")
270}
271
272#[must_use]
274pub fn truncate(s: &str, max: usize) -> String {
275 if s.chars().count() <= max {
276 s.to_string()
277 } else {
278 let prefix = s
279 .chars()
280 .take(max.saturating_sub(ELLIPSIS_LENGTH))
281 .collect::<String>();
282
283 format!("{prefix}...")
284 }
285}
286
287fn fmt_integer_signed(n: i64) -> String {
288 let (prefix, abs) = if n.is_negative() {
289 ("-", n.unsigned_abs())
290 } else {
291 ("", n.unsigned_abs())
292 };
293
294 format!("{}{}", prefix, fmt_integer(abs))
295}
296
297#[cfg(test)]
298mod tests {
299 use googletest::prelude::*;
300 use rstest::rstest;
301
302 use super::*;
303
304 #[gtest]
305 fn test_fmt_integer() -> Result<()> {
306 verify_that!(fmt_integer(0), eq("0"))?;
307 verify_that!(fmt_integer(999), eq("999"))?;
308 verify_that!(fmt_integer(1_000), eq("1,000"))?;
309
310 verify_that!(fmt_integer(1_234_567), eq("1,234,567"))
311 }
312
313 #[gtest]
314 fn test_fmt_number() -> Result<()> {
315 verify_that!(fmt_number(1234.56), eq("1,234.56"))?;
316 verify_that!(fmt_number(42.0), eq("42"))?;
317
318 verify_that!(fmt_number(0.99), eq("0.99"))
319 }
320
321 #[gtest]
322 fn test_fmt_pct() -> Result<()> {
323 verify_that!(fmt_pct(0.2567), eq("25.7%"))?;
324 verify_that!(fmt_pct(1.0), eq("100.0%"))?;
325
326 verify_that!(fmt_pct(0.0), eq("0.0%"))
327 }
328
329 #[gtest]
330 fn test_fmt_duration() -> Result<()> {
331 verify_that!(fmt_duration(5.0), eq("5.0s"))?;
332 verify_that!(fmt_duration(65.3), eq("1m 5.3s"))?;
333
334 verify_that!(fmt_duration(3725.0), eq("1h 2m 5s"))
335 }
336
337 #[gtest]
338 #[rstest]
339 #[case::neg_int_and_frac(-1234.56, "-1,234.56")]
340 #[case::neg_no_frac(-42.0, "-42")]
341 #[case::neg_half_sign_loss(-0.5, "0.50")]
343 #[case::zero(0.0, "0")]
344 fn fmt_number_negatives(#[case] input: f64, #[case] expected: &str) -> Result<()> {
345 verify_that!(fmt_number(input), eq(expected))
346 }
347
348 #[gtest]
349 #[rstest]
350 #[case::three_groups(999_999_999, "999,999,999")]
351 fn fmt_integer_boundary(#[case] input: u64, #[case] expected: &str) -> Result<()> {
352 verify_that!(fmt_integer(input), eq(expected))
353 }
354
355 #[gtest]
356 #[rstest]
357 #[case::exactly_one_minute(60.0, "1m 0.0s")]
358 #[case::exactly_one_hour(3600.0, "1h 0m 0s")]
359 #[case::just_under_minute(59.9, "59.9s")]
360 fn fmt_duration_boundaries(#[case] input: f64, #[case] expected: &str) -> Result<()> {
361 verify_that!(fmt_duration(input), eq(expected))
362 }
363
364 #[gtest]
365 #[rstest]
366 #[case::negative_ratio(-0.5, "-50.0%")]
367 fn fmt_pct_negative(#[case] input: f64, #[case] expected: &str) -> Result<()> {
368 verify_that!(fmt_pct(input), eq(expected))
369 }
370
371 #[gtest]
372 #[rstest]
373 #[case::under_max("hello", 10, "hello")]
374 #[case::over_max("hello world", 8, "hello...")]
375 #[case::max_below_ellipsis("abcdef", 2, "...")]
376 #[case::exact_boundary("abc", 3, "abc")]
377 #[case::unicode_exact_boundary("éé", 2, "éé")]
378 #[case::unicode_truncation("héllo world", 8, "héllo...")]
379 fn truncate_cases(#[case] s: &str, #[case] max: usize, #[case] expected: &str) -> Result<()> {
380 verify_that!(truncate(s, max), eq(expected))
381 }
382
383 #[gtest]
384 fn table_kv_render() -> Result<()> {
385 let rendered =
386 Table::kv(&[("Cores", "8".to_string()), ("Memory", "16 GiB".to_string())]).render();
387
388 verify_that!(rendered, contains_substring("Metric"))?;
389 verify_that!(rendered, contains_substring("Cores"))?;
390 verify_that!(rendered, contains_substring("16 GiB"))?;
391
392 Ok(())
393 }
394}