#![doc(html_root_url = "https://docs.rs/pretty-ms/0.1.0")]
#![allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
use std::time::Duration;
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
const SECOND_ROUNDING_EPSILON: f64 = 0.000_000_1;
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
#[allow(clippy::struct_excessive_bools)] pub struct Options {
pub colon_notation: bool,
pub compact: bool,
pub verbose: bool,
pub separate_milliseconds: bool,
pub format_sub_milliseconds: bool,
pub sub_seconds_as_decimals: bool,
pub hide_year: bool,
pub hide_year_and_days: bool,
pub hide_seconds: bool,
pub keep_decimals_on_whole_seconds: bool,
pub unit_count: Option<usize>,
pub seconds_decimal_digits: Option<usize>,
pub milliseconds_decimal_digits: Option<usize>,
}
impl Options {
#[must_use]
pub fn colon_notation(mut self, value: bool) -> Self {
self.colon_notation = value;
self
}
#[must_use]
pub fn compact(mut self, value: bool) -> Self {
self.compact = value;
self
}
#[must_use]
pub fn verbose(mut self, value: bool) -> Self {
self.verbose = value;
self
}
#[must_use]
pub fn separate_milliseconds(mut self, value: bool) -> Self {
self.separate_milliseconds = value;
self
}
#[must_use]
pub fn format_sub_milliseconds(mut self, value: bool) -> Self {
self.format_sub_milliseconds = value;
self
}
#[must_use]
pub fn sub_seconds_as_decimals(mut self, value: bool) -> Self {
self.sub_seconds_as_decimals = value;
self
}
#[must_use]
pub fn hide_year(mut self, value: bool) -> Self {
self.hide_year = value;
self
}
#[must_use]
pub fn hide_year_and_days(mut self, value: bool) -> Self {
self.hide_year_and_days = value;
self
}
#[must_use]
pub fn hide_seconds(mut self, value: bool) -> Self {
self.hide_seconds = value;
self
}
#[must_use]
pub fn keep_decimals_on_whole_seconds(mut self, value: bool) -> Self {
self.keep_decimals_on_whole_seconds = value;
self
}
#[must_use]
pub fn unit_count(mut self, value: usize) -> Self {
self.unit_count = Some(value);
self
}
#[must_use]
pub fn seconds_decimal_digits(mut self, value: usize) -> Self {
self.seconds_decimal_digits = Some(value);
self
}
#[must_use]
pub fn milliseconds_decimal_digits(mut self, value: usize) -> Self {
self.milliseconds_decimal_digits = Some(value);
self
}
}
#[must_use]
pub fn pretty_duration(duration: Duration, options: &Options) -> String {
pretty_ms(duration.as_secs_f64() * 1000.0, options)
}
#[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,
}
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)] 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);
}
const MAX_SCALE_DIGITS: usize = 15;
const MAX_OUTPUT_DIGITS: usize = 100;
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)
}
fn to_fixed(value: f64, digits: usize) -> String {
let digits = digits.min(MAX_OUTPUT_DIGITS);
format!("{value:.digits$}")
}
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$}")
}
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))
}
}
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()
}