pretty-ms 0.1.1

Convert milliseconds to a human-readable string: 1337000000 -> "15d 11h 23m 20s". A faithful port of the pretty-ms npm package. Zero dependencies.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! # pretty-ms — human-readable milliseconds
//!
//! Convert a millisecond count into a compact, human-readable string. A faithful
//! Rust port of the [`pretty-ms`](https://www.npmjs.com/package/pretty-ms) npm
//! package, with the same options (compact, verbose, colon notation, decimal
//! digits, sub-millisecond formatting, …). Zero dependencies.
//!
//! ```
//! use pretty_ms::{pretty_ms, Options};
//!
//! assert_eq!(pretty_ms(1_337_000_000.0, &Options::default()), "15d 11h 23m 20s");
//! assert_eq!(pretty_ms(1337.0, &Options::default()), "1.3s");
//! assert_eq!(pretty_ms(1000.0, &Options::default().verbose(true)), "1 second");
//! assert_eq!(pretty_ms(95_500.0, &Options::default().colon_notation(true)), "1:35.5");
//! ```
//!
//! A [`Duration`] convenience wrapper is also provided:
//!
//! ```
//! use std::time::Duration;
//! use pretty_ms::{pretty_duration, Options};
//! assert_eq!(pretty_duration(Duration::from_secs(90), &Options::default()), "1m 30s");
//! ```

#![doc(html_root_url = "https://docs.rs/pretty-ms/0.1.0")]
// Every numeric cast here is on an already-bounded value (truncation mirrors
// JavaScript's integer semantics; the exponent is always <= MAX_SCALE_DIGITS).
#![allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]

use std::time::Duration;

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

const SECOND_ROUNDING_EPSILON: f64 = 0.000_000_1;

/// Formatting options, mirroring the `pretty-ms` npm package. Construct with
/// [`Options::default`] and the builder methods:
///
/// ```
/// use pretty_ms::Options;
/// let opts = Options::default().compact(true);
/// ```
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
#[allow(clippy::struct_excessive_bools)] // mirrors the pretty-ms options object
pub struct Options {
    /// Use colon notation: `5h 1m 45s` becomes `5:01:45`.
    pub colon_notation: bool,
    /// Only show the first unit: `1h 10m` becomes `1h`. Implies `unit_count = 1`
    /// and no decimals.
    pub compact: bool,
    /// Use full-length unit names: `5h 1m 45s` becomes `5 hours 1 minute 45 seconds`.
    pub verbose: bool,
    /// Show milliseconds separately instead of as part of the seconds.
    pub separate_milliseconds: bool,
    /// Show sub-millisecond units (microseconds, nanoseconds).
    pub format_sub_milliseconds: bool,
    /// Show sub-second values as a decimal fraction of seconds even below 1 second.
    pub sub_seconds_as_decimals: bool,
    /// Do not show the year unit (days are not divided into years).
    pub hide_year: bool,
    /// Do not show year or day units (hours accumulate instead).
    pub hide_year_and_days: bool,
    /// Do not show the seconds unit.
    pub hide_seconds: bool,
    /// Keep the decimals on whole seconds: `3s` becomes `3.0s`.
    pub keep_decimals_on_whole_seconds: bool,
    /// Limit the number of units shown.
    pub unit_count: Option<usize>,
    /// Number of digits after the decimal point for seconds (default `1`). Beyond
    /// 15 digits the extra places are zeros (the precision limit of `f64`), and
    /// counts above 100 are clamped to 100.
    pub seconds_decimal_digits: Option<usize>,
    /// Number of digits after the decimal point for milliseconds (default `0`).
    pub milliseconds_decimal_digits: Option<usize>,
}

impl Options {
    /// See [`Options::colon_notation`].
    #[must_use]
    pub fn colon_notation(mut self, value: bool) -> Self {
        self.colon_notation = value;
        self
    }
    /// See [`Options::compact`].
    #[must_use]
    pub fn compact(mut self, value: bool) -> Self {
        self.compact = value;
        self
    }
    /// See [`Options::verbose`].
    #[must_use]
    pub fn verbose(mut self, value: bool) -> Self {
        self.verbose = value;
        self
    }
    /// See [`Options::separate_milliseconds`].
    #[must_use]
    pub fn separate_milliseconds(mut self, value: bool) -> Self {
        self.separate_milliseconds = value;
        self
    }
    /// See [`Options::format_sub_milliseconds`].
    #[must_use]
    pub fn format_sub_milliseconds(mut self, value: bool) -> Self {
        self.format_sub_milliseconds = value;
        self
    }
    /// See [`Options::sub_seconds_as_decimals`].
    #[must_use]
    pub fn sub_seconds_as_decimals(mut self, value: bool) -> Self {
        self.sub_seconds_as_decimals = value;
        self
    }
    /// See [`Options::hide_year`].
    #[must_use]
    pub fn hide_year(mut self, value: bool) -> Self {
        self.hide_year = value;
        self
    }
    /// See [`Options::hide_year_and_days`].
    #[must_use]
    pub fn hide_year_and_days(mut self, value: bool) -> Self {
        self.hide_year_and_days = value;
        self
    }
    /// See [`Options::hide_seconds`].
    #[must_use]
    pub fn hide_seconds(mut self, value: bool) -> Self {
        self.hide_seconds = value;
        self
    }
    /// See [`Options::keep_decimals_on_whole_seconds`].
    #[must_use]
    pub fn keep_decimals_on_whole_seconds(mut self, value: bool) -> Self {
        self.keep_decimals_on_whole_seconds = value;
        self
    }
    /// See [`Options::unit_count`].
    #[must_use]
    pub fn unit_count(mut self, value: usize) -> Self {
        self.unit_count = Some(value);
        self
    }
    /// See [`Options::seconds_decimal_digits`].
    #[must_use]
    pub fn seconds_decimal_digits(mut self, value: usize) -> Self {
        self.seconds_decimal_digits = Some(value);
        self
    }
    /// See [`Options::milliseconds_decimal_digits`].
    #[must_use]
    pub fn milliseconds_decimal_digits(mut self, value: usize) -> Self {
        self.milliseconds_decimal_digits = Some(value);
        self
    }
}

/// Format a [`Duration`] as a human-readable string. See [`pretty_ms`].
///
/// ```
/// use std::time::Duration;
/// use pretty_ms::{pretty_duration, Options};
/// assert_eq!(pretty_duration(Duration::from_millis(1337), &Options::default()), "1.3s");
/// ```
#[must_use]
pub fn pretty_duration(duration: Duration, options: &Options) -> String {
    pretty_ms(duration.as_secs_f64() * 1000.0, options)
}

/// Convert `milliseconds` into a human-readable string.
///
/// A non-finite input is treated as `0`.
///
/// ```
/// use pretty_ms::{pretty_ms, Options};
/// assert_eq!(pretty_ms(1500.0, &Options::default()), "1.5s");
/// assert_eq!(pretty_ms(133.0, &Options::default()), "133ms");
/// ```
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn pretty_ms(milliseconds: f64, options: &Options) -> String {
    let value = if milliseconds.is_finite() {
        milliseconds
    } else {
        0.0
    };

    let mut o = options.clone();
    let sign = if value < 0.0 { "-" } else { "" };
    let ms = value.abs();

    if o.colon_notation {
        o.compact = false;
        o.format_sub_milliseconds = false;
        o.separate_milliseconds = false;
        o.verbose = false;
    }
    if o.compact {
        o.unit_count = Some(1);
        o.seconds_decimal_digits = Some(0);
        o.milliseconds_decimal_digits = Some(0);
    }

    let parsed = parse_ms(ms);
    let days = parsed.days;
    let mut result: Vec<String> = Vec::new();

    if o.hide_year_and_days {
        add(
            &mut result,
            &o,
            days * 24.0 + parsed.hours,
            "hour",
            "h",
            None,
        );
    } else {
        if o.hide_year {
            add(&mut result, &o, days, "day", "d", None);
        } else {
            add(&mut result, &o, (days / 365.0).trunc(), "year", "y", None);
            add(&mut result, &o, days % 365.0, "day", "d", None);
        }
        add(&mut result, &o, parsed.hours, "hour", "h", None);
    }

    add(&mut result, &o, parsed.minutes, "minute", "m", None);

    if !o.hide_seconds {
        if o.separate_milliseconds
            || o.format_sub_milliseconds
            || (!o.colon_notation && ms < 1000.0 && !o.sub_seconds_as_decimals)
        {
            add(&mut result, &o, parsed.seconds, "second", "s", None);

            if o.format_sub_milliseconds {
                add(
                    &mut result,
                    &o,
                    parsed.milliseconds,
                    "millisecond",
                    "ms",
                    None,
                );
                add(
                    &mut result,
                    &o,
                    parsed.microseconds,
                    "microsecond",
                    "µs",
                    None,
                );
                add(
                    &mut result,
                    &o,
                    parsed.nanoseconds,
                    "nanosecond",
                    "ns",
                    None,
                );
            } else {
                let ms_and_below =
                    parsed.milliseconds + parsed.microseconds / 1000.0 + parsed.nanoseconds / 1e6;
                let digits = o.milliseconds_decimal_digits.unwrap_or(0);
                let rounded = if ms_and_below >= 1.0 {
                    ms_and_below.round()
                } else {
                    ms_and_below.ceil()
                };
                let ms_string = if digits == 0 {
                    format!("{}", rounded as i64)
                } else {
                    to_fixed(ms_and_below, digits)
                };
                let val = ms_string.parse::<f64>().unwrap_or(0.0);
                add(&mut result, &o, val, "millisecond", "ms", Some(ms_string));
            }
        } else {
            let seconds = (ms / 1000.0) % 60.0;
            let digits = o.seconds_decimal_digits.unwrap_or(1);
            let seconds_fixed = floor_decimals(seconds, digits);
            let seconds_string = if o.keep_decimals_on_whole_seconds {
                seconds_fixed
            } else {
                strip_trailing_zeros(&seconds_fixed)
            };
            let val = seconds_string.parse::<f64>().unwrap_or(0.0);
            add(&mut result, &o, val, "second", "s", Some(seconds_string));
        }
    }

    if result.is_empty() {
        return format!("{sign}0{}", if o.verbose { " milliseconds" } else { "ms" });
    }

    if let Some(count) = o.unit_count {
        result.truncate(count.max(1));
    }

    let separator = if o.colon_notation { ":" } else { " " };
    format!("{sign}{}", result.join(separator))
}

struct Parsed {
    days: f64,
    hours: f64,
    minutes: f64,
    seconds: f64,
    milliseconds: f64,
    microseconds: f64,
    nanoseconds: f64,
}

/// Decompose a finite, non-negative millisecond count into time components,
/// mirroring the `parse-ms` npm package.
fn parse_ms(ms: f64) -> Parsed {
    let zero_if_infinite = |v: f64| if v.is_finite() { v } else { 0.0 };
    Parsed {
        days: (ms / 86_400_000.0).trunc(),
        hours: (ms / 3_600_000.0 % 24.0).trunc(),
        minutes: (ms / 60_000.0 % 60.0).trunc(),
        seconds: (ms / 1000.0 % 60.0).trunc(),
        milliseconds: (ms % 1000.0).trunc(),
        microseconds: (zero_if_infinite(ms * 1000.0) % 1000.0).trunc(),
        nanoseconds: (zero_if_infinite(ms * 1e6) % 1000.0).trunc(),
    }
}

#[allow(clippy::float_cmp)] // mirrors JavaScript's `count === 1`
fn pluralize(word: &str, count: f64) -> String {
    if count == 1.0 {
        word.to_string()
    } else {
        format!("{word}s")
    }
}

fn add(
    result: &mut Vec<String>,
    o: &Options,
    value: f64,
    long: &str,
    short: &str,
    value_string: Option<String>,
) {
    if (result.is_empty() || !o.colon_notation)
        && value == 0.0
        && !(o.colon_notation && short == "m")
    {
        return;
    }

    let mut s = value_string.unwrap_or_else(|| format!("{}", value as i64));

    if o.colon_notation {
        let whole_digits = match s.split_once('.') {
            Some((int, _)) => int.len(),
            None => s.len(),
        };
        let min_length: usize = if result.is_empty() { 1 } else { 2 };
        let pad = min_length.saturating_sub(whole_digits);
        s = format!("{}{s}", "0".repeat(pad));
    } else if o.verbose {
        s = format!("{s} {}", pluralize(long, value));
    } else {
        s.push_str(short);
    }

    result.push(s);
}

/// Beyond this many fractional digits an `f64` carries no more information, so the
/// flooring path stops scaling (which would overflow `i64`) and pads with zeros.
const MAX_SCALE_DIGITS: usize = 15;

/// The maximum number of fractional digits we emit, matching JavaScript's `toFixed`
/// range (which rejects more than 100). Larger requests are clamped to this, which
/// also bounds allocation.
const MAX_OUTPUT_DIGITS: usize = 100;

/// Format `value` with exactly `digits` decimals after flooring (with a small
/// rounding epsilon), mirroring `pretty-ms`'s `floorDecimals`.
fn floor_decimals(value: f64, digits: usize) -> String {
    let digits = digits.min(MAX_OUTPUT_DIGITS);
    let calc = digits.min(MAX_SCALE_DIGITS);
    let scale = 10f64.powi(calc as i32);
    let scaled = (value * scale + SECOND_ROUNDING_EPSILON).floor() as i64;
    pad_fraction(format_scaled(scaled, calc), calc, digits)
}

/// Render `value` with `digits` decimals like JavaScript's `Number.toFixed`, which
/// rounds the true IEEE value. (Rust's formatter matches it except on exact binary
/// half-points, where `toFixed`'s round-half-up would round upward instead.)
fn to_fixed(value: f64, digits: usize) -> String {
    let digits = digits.min(MAX_OUTPUT_DIGITS);
    format!("{value:.digits$}")
}

/// Render a fixed-point integer `scaled` (= real value × 10^`digits`) as a decimal
/// string with exactly `digits` fractional digits (`digits <= MAX_SCALE_DIGITS`).
/// `scaled` is assumed non-negative.
fn format_scaled(scaled: i64, digits: usize) -> String {
    if digits == 0 {
        return format!("{scaled}");
    }
    let pow = 10i64.pow(digits as u32);
    let int = scaled / pow;
    let frac = (scaled % pow).abs();
    format!("{int}.{frac:0digits$}")
}

/// Extend the fractional part of `s` from `have` digits to `want` digits with zeros.
fn pad_fraction(s: String, have: usize, want: usize) -> String {
    if want <= have {
        return s;
    }
    if have == 0 {
        format!("{s}.{}", "0".repeat(want))
    } else {
        format!("{s}{}", "0".repeat(want - have))
    }
}

/// Remove a trailing decimal point followed only by zeros (`"1.0"` → `"1"`), like
/// `pretty-ms`'s `.replace(/\.0+$/, '')`.
fn strip_trailing_zeros(s: &str) -> String {
    if let Some((int, frac)) = s.split_once('.') {
        if !frac.is_empty() && frac.bytes().all(|b| b == b'0') {
            return int.to_string();
        }
    }
    s.to_string()
}