1#![doc(html_root_url = "https://docs.rs/pretty-ms/0.1.0")]
26#![allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
29
30use std::time::Duration;
31
32#[cfg(doctest)]
34#[doc = include_str!("../README.md")]
35struct ReadmeDoctests;
36
37const SECOND_ROUNDING_EPSILON: f64 = 0.000_000_1;
38
39#[derive(Debug, Clone, Default)]
47#[non_exhaustive]
48#[allow(clippy::struct_excessive_bools)] pub struct Options {
50 pub colon_notation: bool,
52 pub compact: bool,
55 pub verbose: bool,
57 pub separate_milliseconds: bool,
59 pub format_sub_milliseconds: bool,
61 pub sub_seconds_as_decimals: bool,
63 pub hide_year: bool,
65 pub hide_year_and_days: bool,
67 pub hide_seconds: bool,
69 pub keep_decimals_on_whole_seconds: bool,
71 pub unit_count: Option<usize>,
73 pub seconds_decimal_digits: Option<usize>,
77 pub milliseconds_decimal_digits: Option<usize>,
79}
80
81impl Options {
82 #[must_use]
84 pub fn colon_notation(mut self, value: bool) -> Self {
85 self.colon_notation = value;
86 self
87 }
88 #[must_use]
90 pub fn compact(mut self, value: bool) -> Self {
91 self.compact = value;
92 self
93 }
94 #[must_use]
96 pub fn verbose(mut self, value: bool) -> Self {
97 self.verbose = value;
98 self
99 }
100 #[must_use]
102 pub fn separate_milliseconds(mut self, value: bool) -> Self {
103 self.separate_milliseconds = value;
104 self
105 }
106 #[must_use]
108 pub fn format_sub_milliseconds(mut self, value: bool) -> Self {
109 self.format_sub_milliseconds = value;
110 self
111 }
112 #[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 #[must_use]
120 pub fn hide_year(mut self, value: bool) -> Self {
121 self.hide_year = value;
122 self
123 }
124 #[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 #[must_use]
132 pub fn hide_seconds(mut self, value: bool) -> Self {
133 self.hide_seconds = value;
134 self
135 }
136 #[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 #[must_use]
144 pub fn unit_count(mut self, value: usize) -> Self {
145 self.unit_count = Some(value);
146 self
147 }
148 #[must_use]
150 pub fn seconds_decimal_digits(mut self, value: usize) -> Self {
151 self.seconds_decimal_digits = Some(value);
152 self
153 }
154 #[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#[must_use]
170pub fn pretty_duration(duration: Duration, options: &Options) -> String {
171 pretty_ms(duration.as_secs_f64() * 1000.0, options)
172}
173
174#[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
318fn 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)] fn 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
376const MAX_SCALE_DIGITS: usize = 15;
379
380const MAX_OUTPUT_DIGITS: usize = 100;
384
385fn 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
395fn to_fixed(value: f64, digits: usize) -> String {
399 let digits = digits.min(MAX_OUTPUT_DIGITS);
400 format!("{value:.digits$}")
401}
402
403fn 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
416fn 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
428fn 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}