Skip to main content

pretty_ms/
lib.rs

1//! # pretty-ms — human-readable milliseconds
2//!
3//! Convert a millisecond count into a compact, human-readable string. A faithful
4//! Rust port of the [`pretty-ms`](https://www.npmjs.com/package/pretty-ms) npm
5//! package, with the same options (compact, verbose, colon notation, decimal
6//! digits, sub-millisecond formatting, …). Zero dependencies.
7//!
8//! ```
9//! use pretty_ms::{pretty_ms, Options};
10//!
11//! assert_eq!(pretty_ms(1_337_000_000.0, &Options::default()), "15d 11h 23m 20s");
12//! assert_eq!(pretty_ms(1337.0, &Options::default()), "1.3s");
13//! assert_eq!(pretty_ms(1000.0, &Options::default().verbose(true)), "1 second");
14//! assert_eq!(pretty_ms(95_500.0, &Options::default().colon_notation(true)), "1:35.5");
15//! ```
16//!
17//! A [`Duration`] convenience wrapper is also provided:
18//!
19//! ```
20//! use std::time::Duration;
21//! use pretty_ms::{pretty_duration, Options};
22//! assert_eq!(pretty_duration(Duration::from_secs(90), &Options::default()), "1m 30s");
23//! ```
24
25#![doc(html_root_url = "https://docs.rs/pretty-ms/0.1.0")]
26// Every numeric cast here is on an already-bounded value (truncation mirrors
27// JavaScript's integer semantics; the exponent is always <= MAX_SCALE_DIGITS).
28#![allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
29
30use std::time::Duration;
31
32// Compile-test the README's examples as part of `cargo test`.
33#[cfg(doctest)]
34#[doc = include_str!("../README.md")]
35struct ReadmeDoctests;
36
37const SECOND_ROUNDING_EPSILON: f64 = 0.000_000_1;
38
39/// Formatting options, mirroring the `pretty-ms` npm package. Construct with
40/// [`Options::default`] and the builder methods:
41///
42/// ```
43/// use pretty_ms::Options;
44/// let opts = Options::default().compact(true);
45/// ```
46#[derive(Debug, Clone, Default)]
47#[non_exhaustive]
48#[allow(clippy::struct_excessive_bools)] // mirrors the pretty-ms options object
49pub struct Options {
50    /// Use colon notation: `5h 1m 45s` becomes `5:01:45`.
51    pub colon_notation: bool,
52    /// Only show the first unit: `1h 10m` becomes `1h`. Implies `unit_count = 1`
53    /// and no decimals.
54    pub compact: bool,
55    /// Use full-length unit names: `5h 1m 45s` becomes `5 hours 1 minute 45 seconds`.
56    pub verbose: bool,
57    /// Show milliseconds separately instead of as part of the seconds.
58    pub separate_milliseconds: bool,
59    /// Show sub-millisecond units (microseconds, nanoseconds).
60    pub format_sub_milliseconds: bool,
61    /// Show sub-second values as a decimal fraction of seconds even below 1 second.
62    pub sub_seconds_as_decimals: bool,
63    /// Do not show the year unit (days are not divided into years).
64    pub hide_year: bool,
65    /// Do not show year or day units (hours accumulate instead).
66    pub hide_year_and_days: bool,
67    /// Do not show the seconds unit.
68    pub hide_seconds: bool,
69    /// Keep the decimals on whole seconds: `3s` becomes `3.0s`.
70    pub keep_decimals_on_whole_seconds: bool,
71    /// Limit the number of units shown.
72    pub unit_count: Option<usize>,
73    /// Number of digits after the decimal point for seconds (default `1`). Beyond
74    /// 15 digits the extra places are zeros (the precision limit of `f64`), and
75    /// counts above 100 are clamped to 100.
76    pub seconds_decimal_digits: Option<usize>,
77    /// Number of digits after the decimal point for milliseconds (default `0`).
78    pub milliseconds_decimal_digits: Option<usize>,
79}
80
81impl Options {
82    /// See [`Options::colon_notation`].
83    #[must_use]
84    pub fn colon_notation(mut self, value: bool) -> Self {
85        self.colon_notation = value;
86        self
87    }
88    /// See [`Options::compact`].
89    #[must_use]
90    pub fn compact(mut self, value: bool) -> Self {
91        self.compact = value;
92        self
93    }
94    /// See [`Options::verbose`].
95    #[must_use]
96    pub fn verbose(mut self, value: bool) -> Self {
97        self.verbose = value;
98        self
99    }
100    /// See [`Options::separate_milliseconds`].
101    #[must_use]
102    pub fn separate_milliseconds(mut self, value: bool) -> Self {
103        self.separate_milliseconds = value;
104        self
105    }
106    /// See [`Options::format_sub_milliseconds`].
107    #[must_use]
108    pub fn format_sub_milliseconds(mut self, value: bool) -> Self {
109        self.format_sub_milliseconds = value;
110        self
111    }
112    /// See [`Options::sub_seconds_as_decimals`].
113    #[must_use]
114    pub fn sub_seconds_as_decimals(mut self, value: bool) -> Self {
115        self.sub_seconds_as_decimals = value;
116        self
117    }
118    /// See [`Options::hide_year`].
119    #[must_use]
120    pub fn hide_year(mut self, value: bool) -> Self {
121        self.hide_year = value;
122        self
123    }
124    /// See [`Options::hide_year_and_days`].
125    #[must_use]
126    pub fn hide_year_and_days(mut self, value: bool) -> Self {
127        self.hide_year_and_days = value;
128        self
129    }
130    /// See [`Options::hide_seconds`].
131    #[must_use]
132    pub fn hide_seconds(mut self, value: bool) -> Self {
133        self.hide_seconds = value;
134        self
135    }
136    /// See [`Options::keep_decimals_on_whole_seconds`].
137    #[must_use]
138    pub fn keep_decimals_on_whole_seconds(mut self, value: bool) -> Self {
139        self.keep_decimals_on_whole_seconds = value;
140        self
141    }
142    /// See [`Options::unit_count`].
143    #[must_use]
144    pub fn unit_count(mut self, value: usize) -> Self {
145        self.unit_count = Some(value);
146        self
147    }
148    /// See [`Options::seconds_decimal_digits`].
149    #[must_use]
150    pub fn seconds_decimal_digits(mut self, value: usize) -> Self {
151        self.seconds_decimal_digits = Some(value);
152        self
153    }
154    /// See [`Options::milliseconds_decimal_digits`].
155    #[must_use]
156    pub fn milliseconds_decimal_digits(mut self, value: usize) -> Self {
157        self.milliseconds_decimal_digits = Some(value);
158        self
159    }
160}
161
162/// Format a [`Duration`] as a human-readable string. See [`pretty_ms`].
163///
164/// ```
165/// use std::time::Duration;
166/// use pretty_ms::{pretty_duration, Options};
167/// assert_eq!(pretty_duration(Duration::from_millis(1337), &Options::default()), "1.3s");
168/// ```
169#[must_use]
170pub fn pretty_duration(duration: Duration, options: &Options) -> String {
171    pretty_ms(duration.as_secs_f64() * 1000.0, options)
172}
173
174/// Convert `milliseconds` into a human-readable string.
175///
176/// A non-finite input is treated as `0`.
177///
178/// ```
179/// use pretty_ms::{pretty_ms, Options};
180/// assert_eq!(pretty_ms(1500.0, &Options::default()), "1.5s");
181/// assert_eq!(pretty_ms(133.0, &Options::default()), "133ms");
182/// ```
183#[must_use]
184#[allow(clippy::too_many_lines)]
185pub fn pretty_ms(milliseconds: f64, options: &Options) -> String {
186    let value = if milliseconds.is_finite() {
187        milliseconds
188    } else {
189        0.0
190    };
191
192    let mut o = options.clone();
193    let sign = if value < 0.0 { "-" } else { "" };
194    let ms = value.abs();
195
196    if o.colon_notation {
197        o.compact = false;
198        o.format_sub_milliseconds = false;
199        o.separate_milliseconds = false;
200        o.verbose = false;
201    }
202    if o.compact {
203        o.unit_count = Some(1);
204        o.seconds_decimal_digits = Some(0);
205        o.milliseconds_decimal_digits = Some(0);
206    }
207
208    let parsed = parse_ms(ms);
209    let days = parsed.days;
210    let mut result: Vec<String> = Vec::new();
211
212    if o.hide_year_and_days {
213        add(
214            &mut result,
215            &o,
216            days * 24.0 + parsed.hours,
217            "hour",
218            "h",
219            None,
220        );
221    } else {
222        if o.hide_year {
223            add(&mut result, &o, days, "day", "d", None);
224        } else {
225            add(&mut result, &o, (days / 365.0).trunc(), "year", "y", None);
226            add(&mut result, &o, days % 365.0, "day", "d", None);
227        }
228        add(&mut result, &o, parsed.hours, "hour", "h", None);
229    }
230
231    add(&mut result, &o, parsed.minutes, "minute", "m", None);
232
233    if !o.hide_seconds {
234        if o.separate_milliseconds
235            || o.format_sub_milliseconds
236            || (!o.colon_notation && ms < 1000.0 && !o.sub_seconds_as_decimals)
237        {
238            add(&mut result, &o, parsed.seconds, "second", "s", None);
239
240            if o.format_sub_milliseconds {
241                add(
242                    &mut result,
243                    &o,
244                    parsed.milliseconds,
245                    "millisecond",
246                    "ms",
247                    None,
248                );
249                add(
250                    &mut result,
251                    &o,
252                    parsed.microseconds,
253                    "microsecond",
254                    "µs",
255                    None,
256                );
257                add(
258                    &mut result,
259                    &o,
260                    parsed.nanoseconds,
261                    "nanosecond",
262                    "ns",
263                    None,
264                );
265            } else {
266                let ms_and_below =
267                    parsed.milliseconds + parsed.microseconds / 1000.0 + parsed.nanoseconds / 1e6;
268                let digits = o.milliseconds_decimal_digits.unwrap_or(0);
269                let rounded = if ms_and_below >= 1.0 {
270                    ms_and_below.round()
271                } else {
272                    ms_and_below.ceil()
273                };
274                let ms_string = if digits == 0 {
275                    format!("{}", rounded as i64)
276                } else {
277                    to_fixed(ms_and_below, digits)
278                };
279                let val = ms_string.parse::<f64>().unwrap_or(0.0);
280                add(&mut result, &o, val, "millisecond", "ms", Some(ms_string));
281            }
282        } else {
283            let seconds = (ms / 1000.0) % 60.0;
284            let digits = o.seconds_decimal_digits.unwrap_or(1);
285            let seconds_fixed = floor_decimals(seconds, digits);
286            let seconds_string = if o.keep_decimals_on_whole_seconds {
287                seconds_fixed
288            } else {
289                strip_trailing_zeros(&seconds_fixed)
290            };
291            let val = seconds_string.parse::<f64>().unwrap_or(0.0);
292            add(&mut result, &o, val, "second", "s", Some(seconds_string));
293        }
294    }
295
296    if result.is_empty() {
297        return format!("{sign}0{}", if o.verbose { " milliseconds" } else { "ms" });
298    }
299
300    if let Some(count) = o.unit_count {
301        result.truncate(count.max(1));
302    }
303
304    let separator = if o.colon_notation { ":" } else { " " };
305    format!("{sign}{}", result.join(separator))
306}
307
308struct Parsed {
309    days: f64,
310    hours: f64,
311    minutes: f64,
312    seconds: f64,
313    milliseconds: f64,
314    microseconds: f64,
315    nanoseconds: f64,
316}
317
318/// Decompose a finite, non-negative millisecond count into time components,
319/// mirroring the `parse-ms` npm package.
320fn parse_ms(ms: f64) -> Parsed {
321    let zero_if_infinite = |v: f64| if v.is_finite() { v } else { 0.0 };
322    Parsed {
323        days: (ms / 86_400_000.0).trunc(),
324        hours: (ms / 3_600_000.0 % 24.0).trunc(),
325        minutes: (ms / 60_000.0 % 60.0).trunc(),
326        seconds: (ms / 1000.0 % 60.0).trunc(),
327        milliseconds: (ms % 1000.0).trunc(),
328        microseconds: (zero_if_infinite(ms * 1000.0) % 1000.0).trunc(),
329        nanoseconds: (zero_if_infinite(ms * 1e6) % 1000.0).trunc(),
330    }
331}
332
333#[allow(clippy::float_cmp)] // mirrors JavaScript's `count === 1`
334fn pluralize(word: &str, count: f64) -> String {
335    if count == 1.0 {
336        word.to_string()
337    } else {
338        format!("{word}s")
339    }
340}
341
342fn add(
343    result: &mut Vec<String>,
344    o: &Options,
345    value: f64,
346    long: &str,
347    short: &str,
348    value_string: Option<String>,
349) {
350    if (result.is_empty() || !o.colon_notation)
351        && value == 0.0
352        && !(o.colon_notation && short == "m")
353    {
354        return;
355    }
356
357    let mut s = value_string.unwrap_or_else(|| format!("{}", value as i64));
358
359    if o.colon_notation {
360        let whole_digits = match s.split_once('.') {
361            Some((int, _)) => int.len(),
362            None => s.len(),
363        };
364        let min_length: usize = if result.is_empty() { 1 } else { 2 };
365        let pad = min_length.saturating_sub(whole_digits);
366        s = format!("{}{s}", "0".repeat(pad));
367    } else if o.verbose {
368        s = format!("{s} {}", pluralize(long, value));
369    } else {
370        s.push_str(short);
371    }
372
373    result.push(s);
374}
375
376/// Beyond this many fractional digits an `f64` carries no more information, so the
377/// flooring path stops scaling (which would overflow `i64`) and pads with zeros.
378const MAX_SCALE_DIGITS: usize = 15;
379
380/// The maximum number of fractional digits we emit, matching JavaScript's `toFixed`
381/// range (which rejects more than 100). Larger requests are clamped to this, which
382/// also bounds allocation.
383const MAX_OUTPUT_DIGITS: usize = 100;
384
385/// Format `value` with exactly `digits` decimals after flooring (with a small
386/// rounding epsilon), mirroring `pretty-ms`'s `floorDecimals`.
387fn floor_decimals(value: f64, digits: usize) -> String {
388    let digits = digits.min(MAX_OUTPUT_DIGITS);
389    let calc = digits.min(MAX_SCALE_DIGITS);
390    let scale = 10f64.powi(calc as i32);
391    let scaled = (value * scale + SECOND_ROUNDING_EPSILON).floor() as i64;
392    pad_fraction(format_scaled(scaled, calc), calc, digits)
393}
394
395/// Render `value` with `digits` decimals like JavaScript's `Number.toFixed`, which
396/// rounds the true IEEE value. (Rust's formatter matches it except on exact binary
397/// half-points, where `toFixed`'s round-half-up would round upward instead.)
398fn to_fixed(value: f64, digits: usize) -> String {
399    let digits = digits.min(MAX_OUTPUT_DIGITS);
400    format!("{value:.digits$}")
401}
402
403/// Render a fixed-point integer `scaled` (= real value × 10^`digits`) as a decimal
404/// string with exactly `digits` fractional digits (`digits <= MAX_SCALE_DIGITS`).
405/// `scaled` is assumed non-negative.
406fn format_scaled(scaled: i64, digits: usize) -> String {
407    if digits == 0 {
408        return format!("{scaled}");
409    }
410    let pow = 10i64.pow(digits as u32);
411    let int = scaled / pow;
412    let frac = (scaled % pow).abs();
413    format!("{int}.{frac:0digits$}")
414}
415
416/// Extend the fractional part of `s` from `have` digits to `want` digits with zeros.
417fn pad_fraction(s: String, have: usize, want: usize) -> String {
418    if want <= have {
419        return s;
420    }
421    if have == 0 {
422        format!("{s}.{}", "0".repeat(want))
423    } else {
424        format!("{s}{}", "0".repeat(want - have))
425    }
426}
427
428/// Remove a trailing decimal point followed only by zeros (`"1.0"` → `"1"`), like
429/// `pretty-ms`'s `.replace(/\.0+$/, '')`.
430fn strip_trailing_zeros(s: &str) -> String {
431    if let Some((int, frac)) = s.split_once('.') {
432        if !frac.is_empty() && frac.bytes().all(|b| b == b'0') {
433            return int.to_string();
434        }
435    }
436    s.to_string()
437}