Skip to main content

wowlab_common/
output.rs

1//! Shared CLI output formatting.
2
3const 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
20/// Bold cyan section header with a leading arrow.
21pub fn header(text: &str) {
22    eprintln!("{}", style(format!("▸ {text}")).bold().cyan());
23}
24
25/// Bold white text with underline.
26pub fn subheader(text: &str) {
27    eprintln!("{}", style(text).bold().white().underlined());
28}
29
30/// Key-value line: dim key, bold value.
31pub fn kv(key: &str, val: &str) {
32    eprintln!("  {} {}", style(format!("{key}:")).dim(), style(val).bold());
33}
34
35/// Key-value line with a `Display` value.
36pub fn kv_fmt(key: &str, val: impl Display) {
37    kv(key, &val.to_string());
38}
39
40/// Green checkmark prefix.
41pub fn success(msg: &str) {
42    eprintln!("{} {}", style("✓").green().bold(), msg);
43}
44
45/// Red X prefix.
46pub fn error(msg: &str) {
47    eprintln!("{} {}", style("✗").red().bold(), msg);
48}
49
50/// Yellow exclamation prefix.
51pub fn warning(msg: &str) {
52    eprintln!("{} {}", style("!").yellow().bold(), msg);
53}
54
55/// Dim horizontal rule.
56pub fn separator() {
57    eprintln!("{}", style("─".repeat(50)).dim());
58}
59
60/// Styled banner: "Name vX.Y.Z".
61pub fn banner(name: &str, version: &str) {
62    eprintln!(
63        "{} {}\n",
64        style(name).bold().cyan(),
65        style(format!("v{version}")).dim()
66    );
67}
68
69/// Plain detail line (indented, dim). For multi-line data under a header.
70pub fn detail(text: &str) {
71    eprintln!("  {}", style(text).dim());
72}
73
74/// Info line (no prefix, no styling). For free-form structured output.
75pub fn info(text: &str) {
76    eprintln!("{text}");
77}
78
79/// Pretty-print a serializable value as JSON to stdout.
80// #t(rust_pub_api_foreign_types) serialization is the explicit boundary of this output helper
81pub fn json(value: &impl serde::Serialize) {
82    println!(
83        "{}",
84        serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
85    );
86}
87
88/// Render a table to stderr.
89pub fn table<I, T>(data: I)
90where
91    I: IntoIterator<Item = T>,
92    T: tabled::Tabled,
93{
94    eprintln!("{}", Table::new(data));
95}
96
97/// Blank line to stderr.
98pub fn blank() {
99    eprintln!();
100}
101
102/// Thin wrapper around `tabled::Table` with consistent styling.
103#[derive(Debug)]
104pub struct Table {
105    inner: tabled::Table,
106}
107
108impl Table {
109    /// Build a table from any iterator of `Tabled` items.
110    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    /// Build a two-column key-value table.
125    #[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    /// Render the table to a string.
139    #[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/// Styled progress bar wrapping `indicatif::ProgressBar`.
160#[derive(Debug)]
161pub struct ProgressBar {
162    inner: indicatif::ProgressBar,
163}
164
165impl ProgressBar {
166    /// Create a progress bar with the given total and initial message.
167    ///
168    /// # Panics
169    ///
170    /// Panics if the compile-time progress-bar template is invalid.
171    #[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    /// Update position.
188    pub fn tick(&self, current: u64) {
189        self.inner.set_position(current);
190    }
191
192    /// Update position with live stats in the message.
193    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    /// Finish and clear the bar, printing a final message.
203    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/// Format a float with thousands separators: `1234.56 -> "1,234.56"`.
213#[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/// Format an unsigned integer with thousands separators: `1234 -> "1,234"`.
230#[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/// Format a ratio as percentage: `0.2567 -> "25.7%"`.
247#[must_use]
248pub fn fmt_pct(n: f64) -> String {
249    format!("{:.1}%", n * 100.0)
250}
251
252/// Format seconds as human-readable duration: `125.3 -> "2m 5.3s"`.
253#[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/// Truncate a string to `max` characters, appending `...` if needed.
273#[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    // `trunc(-0.5) == -0 -> 0`, so the sign is lost: quirk pinned so a "fix" is caught.
342    #[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}