fmt_dur/
lib.rs

1//! fmt_dur - strict Duration parsing/formatting.
2//!
3//! # Grammar (strict by default)
4//!
5//! ```text
6//! Input := Segment { Segment }
7//! Segment := Number Unit
8//! Number := DIGIT+ [ "." DIGIT{1,9} ]   // decimal allowed at most once, and only in the last Segment
9//! Unit   := "d" | "h" | "m" | "s" | "ms" | "us" | "ns"
10//! ```
11//!
12//! ## Rules
13//!
14//! - Units must appear in strictly descending order: d > h > m > s > ms > us > ns
15//! - No duplicate units
16//! - No spaces/underscores; lowercase only (enable "loose" feature to allow spaces/underscores and case-insensitive)
17//! - At least one segment must be present (e.g., "0s" is valid)
18//! - Up to 9 fractional digits (nanosecond precision). Fraction may appear only on the last segment.
19//!
20//! # Examples
21//!
22//! Valid duration strings:
23//! - `"2d3h4m"`
24//! - `"90s"`
25//! - `"1.5h"`
26//! - `"250ms"`
27//! - `"1m30s"`
28//! - `"1m30.5s"`
29//! - `"750us"`
30//! - `"10ns"`
31//!
32//! # Usage
33//!
34//! ```rust
35//! use std::time::Duration;
36//! # use fmt_dur::{parse, parse_with, format, format_with, ParseOptions, FormatOptions};
37//!
38//! // Parse a duration string
39//! let duration = parse("1.5h").unwrap();
40//! assert_eq!(duration, Duration::from_secs(5400));
41//!
42//! // Parse with custom options (saturating on overflow)
43//! let duration = parse_with("1.5h", &ParseOptions::strict().saturating()).unwrap();
44//!
45//! // Format a duration (default: mixed-units; decimals only on the last unit)
46//! let formatted = format(duration);
47//! assert_eq!(formatted, "1h30m");
48//!
49//! // Format with custom options (largest unit with decimal)
50//! let formatted = format_with(duration, &FormatOptions::largest_unit_decimal());
51//! assert_eq!(formatted, "1.5h");
52//! ```
53//!
54//! # Features
55//!
56//! - **`loose`**: Allows spaces and underscores between segments and case-insensitive units (ordering still enforced).
57//! - **`serde`**: Enables `serde::{Serialize, Deserialize}` for [`DurationStr`] using this format.
58
59#![forbid(unsafe_code)]
60// #![deny(missing_docs)]
61use std::fmt;
62use std::time::Duration;
63
64/// Behavior when a parsed value exceeds `Duration`'s maximum.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum OverflowBehavior {
67    /// Return an error on overflow (default).
68    Error,
69    /// Saturate to `Duration::MAX` on overflow.
70    Saturate,
71}
72
73/// Options controlling parsing behavior.
74#[derive(Clone, Copy, Debug)]
75pub struct ParseOptions {
76    overflow: OverflowBehavior,
77}
78
79impl ParseOptions {
80    /// Strict defaults: overflow errors.
81    pub fn strict() -> Self {
82        Self {
83            overflow: OverflowBehavior::Error,
84        }
85    }
86    /// Change overflow behavior to saturate.
87    pub fn saturating(mut self) -> Self {
88        self.overflow = OverflowBehavior::Saturate;
89        self
90    }
91}
92
93/// Parse a strict human duration using default options.
94///
95/// # Examples
96///
97/// ```
98/// use std::time::Duration;
99/// use fmt_dur::parse;
100///
101/// assert_eq!(parse("90s").unwrap(), Duration::from_secs(90));
102/// assert_eq!(parse("1.5h").unwrap(), Duration::from_secs(5400));
103/// assert_eq!(parse("2d3h4m").unwrap(), Duration::from_secs(2 * 86_400 + 3 * 3600 + 4 * 60));
104/// ```
105pub fn parse(input: &str) -> Result<Duration, ParseError> {
106    parse_with(input, &ParseOptions::strict())
107}
108
109/// Parse with explicit options.
110///
111/// # Examples
112///
113/// ```
114/// use fmt_dur::{parse_with, ParseOptions};
115///
116/// let result = parse_with("999999999d", &ParseOptions::strict().saturating());
117/// assert!(result.is_ok());
118/// ```
119pub fn parse_with(input: &str, opts: &ParseOptions) -> Result<Duration, ParseError> {
120    let s = normalize_input(input)?;
121    Parser::new(&s, *opts).parse()
122}
123
124/// Format a Duration using mixed-units style (default).
125///
126/// Examples: `"2d3h4m5.25s"`, `"250ms"`, `"0s"`.
127///
128/// # Examples
129///
130/// ```
131/// use std::time::Duration;
132/// use fmt_dur::format;
133///
134/// assert_eq!(format(Duration::from_secs(90)), "1m30s");
135/// assert_eq!(format(Duration::from_millis(250)), "250ms");
136/// ```
137pub fn format(d: Duration) -> String {
138    format_with(d, &FormatOptions::mixed())
139}
140
141/// Formatting style.
142#[derive(Clone, Copy, Debug)]
143pub enum FormatStyle {
144    /// Mixed units, descending, with decimals only on the last (seconds) component.
145    /// Sub-second durations use ms/us/ns.
146    Mixed,
147    /// Single largest unit with a decimal fraction if needed, e.g., `"1.5h"`, `"90s"`, `"0.123s"`.
148    /// This style cannot always be finite-decimal exact (e.g., 30s in hours),
149    /// but it still round-trips because the parser accepts up to 9 fractional digits.
150    LargestUnitDecimal,
151}
152
153/// Options controlling formatting.
154#[derive(Clone, Copy, Debug)]
155pub struct FormatOptions {
156    style: FormatStyle,
157    max_frac_digits: u8, // 0..=9
158}
159
160impl FormatOptions {
161    /// Mixed-units default. Fractions up to 9 digits when needed.
162    pub fn mixed() -> Self {
163        Self {
164            style: FormatStyle::Mixed,
165            max_frac_digits: 9,
166        }
167    }
168    /// Largest-unit decimal style.
169    pub fn largest_unit_decimal() -> Self {
170        Self {
171            style: FormatStyle::LargestUnitDecimal,
172            max_frac_digits: 9,
173        }
174    }
175    /// Limit fractional digits (0..=9).
176    pub fn with_max_frac_digits(mut self, digits: u8) -> Self {
177        self.max_frac_digits = digits.min(9);
178        self
179    }
180}
181
182/// Format with options.
183///
184/// # Examples
185///
186/// ```
187/// use std::time::Duration;
188/// use fmt_dur::{format_with, FormatOptions};
189///
190/// let d = Duration::from_secs(5400);
191/// let s = format_with(d, &FormatOptions::largest_unit_decimal());
192/// assert_eq!(s, "1.5h");
193/// ```
194pub fn format_with(d: Duration, opts: &FormatOptions) -> String {
195    match opts.style {
196        FormatStyle::Mixed => format_mixed(d, opts.max_frac_digits),
197        FormatStyle::LargestUnitDecimal => format_largest_unit_decimal(d, opts.max_frac_digits),
198    }
199}
200
201/// Error returned when parsing fails.
202#[derive(Clone, Debug, Eq, PartialEq)]
203pub enum ParseError {
204    /// Empty input string.
205    Empty,
206    /// Invalid character at byte index.
207    InvalidChar(usize),
208    /// Invalid or missing number at byte index.
209    InvalidNumber(usize),
210    /// Invalid unit at byte index.
211    InvalidUnit(usize),
212    /// Units must be strictly descending (e.g., h cannot follow s).
213    OutOfOrderUnit {
214        /// The previous unit that appeared earlier.
215        prev: Unit,
216        /// The next unit that violated ordering.
217        next: Unit,
218        /// Byte index where the violation occurred.
219        index: usize,
220    },
221    /// Unit appeared more than once.
222    DuplicateUnit {
223        /// The duplicated unit.
224        unit: Unit,
225        /// Byte index of the duplicate.
226        index: usize,
227    },
228    /// A decimal number was found before the last segment.
229    DecimalNotLast(usize),
230    /// Too many fractional digits (> 9).
231    TooPreciseFraction {
232        /// Number of digits found.
233        digits: usize,
234        /// Byte index of the fraction.
235        index: usize,
236    },
237    /// Overflow (value exceeds Duration::MAX) and behavior was set to Error.
238    Overflow,
239}
240
241impl fmt::Display for ParseError {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        use ParseError::*;
244        match self {
245            Empty => write!(f, "empty duration"),
246            InvalidChar(i) => write!(f, "invalid character at byte index {}", i),
247            InvalidNumber(i) => write!(f, "invalid number at byte index {}", i),
248            InvalidUnit(i) => write!(f, "invalid or missing unit at byte index {}", i),
249            OutOfOrderUnit { prev, next, index } => write!(
250                f,
251                "out-of-order unit '{}' followed by '{}' at byte index {}",
252                prev.as_str(),
253                next.as_str(),
254                index
255            ),
256            DuplicateUnit { unit, index } => write!(
257                f,
258                "duplicate unit '{}' at byte index {}",
259                unit.as_str(),
260                index
261            ),
262            DecimalNotLast(i) => write!(f, "decimal segment must be last (index {})", i),
263            TooPreciseFraction { digits, index } => write!(
264                f,
265                "fractional part has {} digits (max 9) at byte index {}",
266                digits, index
267            ),
268            Overflow => write!(f, "duration overflowed maximum representable span"),
269        }
270    }
271}
272
273impl std::error::Error for ParseError {}
274
275/// Time unit for duration parsing and formatting.
276#[derive(Clone, Copy, Debug, Eq, PartialEq)]
277pub enum Unit {
278    /// Days
279    D,
280    /// Hours
281    H,
282    /// Minutes
283    M,
284    /// Seconds
285    S,
286    /// Milliseconds
287    Ms,
288    /// Microseconds
289    Us,
290    /// Nanoseconds
291    Ns,
292}
293
294impl Unit {
295    fn as_str(self) -> &'static str {
296        match self {
297            Unit::D => "d",
298            Unit::H => "h",
299            Unit::M => "m",
300            Unit::S => "s",
301            Unit::Ms => "ms",
302            Unit::Us => "us",
303            Unit::Ns => "ns",
304        }
305    }
306    fn rank(self) -> u8 {
307        match self {
308            Unit::D => 6,
309            Unit::H => 5,
310            Unit::M => 4,
311            Unit::S => 3,
312            Unit::Ms => 2,
313            Unit::Us => 1,
314            Unit::Ns => 0,
315        }
316    }
317    fn nanos(self) -> u128 {
318        match self {
319            Unit::D => 86_400_000_000_000,
320            Unit::H => 3_600_000_000_000,
321            Unit::M => 60_000_000_000,
322            Unit::S => 1_000_000_000,
323            Unit::Ms => 1_000_000,
324            Unit::Us => 1_000,
325            Unit::Ns => 1,
326        }
327    }
328}
329
330struct Parser<'a> {
331    s: &'a str,
332    opts: ParseOptions,
333    i: usize,
334    len: usize,
335}
336
337impl<'a> Parser<'a> {
338    fn new(s: &'a str, opts: ParseOptions) -> Self {
339        Self {
340            s,
341            opts,
342            i: 0,
343            len: s.len(),
344        }
345    }
346
347    fn parse(&mut self) -> Result<Duration, ParseError> {
348        if self.s.is_empty() {
349            return Err(ParseError::Empty);
350        }
351
352        let mut total_nanos: u128 = 0;
353        let max_nanos: u128 =
354            (u128::from(u64::MAX) * 1_000_000_000u128) + (1_000_000_000u128 - 1u128);
355
356        let mut prev_rank: Option<u8> = None;
357        let mut seen_mask: u8 = 0;
358        let mut decimal_used = false;
359        let mut segments = 0usize;
360
361        while self.i < self.len {
362            let start_num = self.i;
363            // Parse number with optional decimal.
364            let (int_part, frac_part) = self.parse_number()?;
365            segments += 1;
366
367            // Decimal can only appear on the last segment.
368            if frac_part.is_some() && self.i < self.len {
369                // There are more characters; must be unit or next segment.
370                // Check after unit parse that we're not at end.
371                decimal_used = true;
372            }
373
374            let unit_start = self.i;
375            let unit = self
376                .parse_unit()
377                .map_err(|_| ParseError::InvalidUnit(unit_start))?;
378
379            // Enforce ordering
380            let rank = unit.rank();
381            if let Some(prev) = prev_rank {
382                if rank >= prev {
383                    return Err(ParseError::OutOfOrderUnit {
384                        prev: rank_to_unit(prev),
385                        next: unit,
386                        index: unit_start,
387                    });
388                }
389            }
390            prev_rank = Some(rank);
391
392            // Enforce no duplicates
393            let bit = 1u8 << rank;
394            if (seen_mask & bit) != 0 {
395                return Err(ParseError::DuplicateUnit {
396                    unit,
397                    index: unit_start,
398                });
399            }
400            seen_mask |= bit;
401
402            // Decimal must be on the last segment only.
403            if decimal_used && self.i < self.len {
404                return Err(ParseError::DecimalNotLast(start_num));
405            }
406
407            // Accumulate nanos, with overflow handling
408            let unit_nanos = unit.nanos();
409
410            // int_part
411            if int_part > 0 {
412                let add = (int_part as u128)
413                    .checked_mul(unit_nanos)
414                    .ok_or(ParseError::Overflow)?;
415                total_nanos = match total_nanos.checked_add(add) {
416                    Some(v) => v,
417                    None => {
418                        if self.opts.overflow == OverflowBehavior::Saturate {
419                            return Ok(duration_max());
420                        } else {
421                            return Err(ParseError::Overflow);
422                        }
423                    }
424                };
425                if total_nanos > max_nanos {
426                    if self.opts.overflow == OverflowBehavior::Saturate {
427                        return Ok(duration_max());
428                    } else {
429                        return Err(ParseError::Overflow);
430                    }
431                }
432            }
433
434            // frac_part
435            if let Some(frac) = frac_part {
436                let digits = frac.len();
437                if digits == 0 {
438                    return Err(ParseError::InvalidNumber(start_num));
439                }
440                if digits > 9 {
441                    return Err(ParseError::TooPreciseFraction {
442                        digits,
443                        index: start_num,
444                    });
445                }
446                let frac_value = frac
447                    .bytes()
448                    .try_fold(0u128, |acc, b: u8| {
449                        if b.is_ascii_digit() {
450                            Some(acc * 10 + u128::from(b - b'0'))
451                        } else {
452                            None
453                        }
454                    })
455                    .ok_or(ParseError::InvalidNumber(start_num))?;
456
457                // Compute fractional nanos: unit_nanos * frac / 10^digits
458                let denom = 10u128.pow(digits as u32);
459                let add = unit_nanos
460                    .checked_mul(frac_value)
461                    .ok_or(ParseError::Overflow)?
462                    / denom;
463
464                total_nanos = match total_nanos.checked_add(add) {
465                    Some(v) => v,
466                    None => {
467                        if self.opts.overflow == OverflowBehavior::Saturate {
468                            return Ok(duration_max());
469                        } else {
470                            return Err(ParseError::Overflow);
471                        }
472                    }
473                };
474                if total_nanos > max_nanos {
475                    if self.opts.overflow == OverflowBehavior::Saturate {
476                        return Ok(duration_max());
477                    } else {
478                        return Err(ParseError::Overflow);
479                    }
480                }
481            }
482        }
483
484        if segments == 0 {
485            return Err(ParseError::Empty);
486        }
487
488        Ok(nanos_to_duration(total_nanos))
489    }
490
491    fn parse_number(&mut self) -> Result<(u64, Option<&'a str>), ParseError> {
492        let start = self.i;
493        let bytes = self.s.as_bytes();
494
495        if start >= self.len {
496            return Err(ParseError::InvalidNumber(start));
497        }
498
499        let mut saw_digit = false;
500        let mut int_end = start;
501        while int_end < self.len {
502            let b = bytes[int_end];
503            if b.is_ascii_digit() {
504                saw_digit = true;
505                int_end += 1;
506            } else {
507                break;
508            }
509        }
510
511        if !saw_digit {
512            return Err(ParseError::InvalidNumber(start));
513        }
514
515        let mut frac: Option<&'a str> = None;
516        let mut pos = int_end;
517        if pos < self.len && bytes[pos] == b'.' {
518            // fractional part
519            pos += 1;
520            let frac_start = pos;
521            let mut frac_end = pos;
522            while frac_end < self.len {
523                let b = bytes[frac_end];
524                if b.is_ascii_digit() {
525                    frac_end += 1;
526                } else {
527                    break;
528                }
529            }
530            if frac_end == frac_start {
531                return Err(ParseError::InvalidNumber(start));
532            }
533            frac = Some(&self.s[frac_start..frac_end]);
534            pos = frac_end;
535        }
536
537        // Parse int part (fits u64)
538        let int_str = &self.s[start..int_end];
539        let int_val = int_str
540            .bytes()
541            .try_fold(0u64, |acc, b| {
542                acc.checked_mul(10)?.checked_add(u64::from(b - b'0'))
543            })
544            .ok_or(ParseError::InvalidNumber(start))?;
545
546        self.i = pos;
547        Ok((int_val, frac))
548    }
549
550    fn parse_unit(&mut self) -> Result<Unit, ()> {
551        // Match longest unit first: "ms", "us", "ns" before single letters.
552        let rest = &self.s[self.i..];
553
554        let try_take =
555            |s: &str, u: Unit| -> Option<Unit> { if rest.starts_with(s) { Some(u) } else { None } };
556
557        let unit = try_take("ms", Unit::Ms)
558            .or_else(|| try_take("us", Unit::Us))
559            .or_else(|| try_take("ns", Unit::Ns))
560            .or_else(|| try_take("d", Unit::D))
561            .or_else(|| try_take("h", Unit::H))
562            .or_else(|| try_take("m", Unit::M))
563            .or_else(|| try_take("s", Unit::S));
564
565        if let Some(u) = unit {
566            self.i += u.as_str().len();
567            Ok(u)
568        } else {
569            Err(())
570        }
571    }
572}
573
574// Helpers
575
576fn nanos_to_duration(nanos: u128) -> Duration {
577    let secs = (nanos / 1_000_000_000) as u64;
578    let sub = (nanos % 1_000_000_000) as u32;
579    Duration::new(secs, sub)
580}
581
582fn duration_max() -> Duration {
583    // Equivalent to Duration::MAX without relying on that constant.
584    nanos_to_duration((u128::from(u64::MAX) * 1_000_000_000u128) + 999_999_999u128)
585}
586
587fn rank_to_unit(rank: u8) -> Unit {
588    match rank {
589        6 => Unit::D,
590        5 => Unit::H,
591        4 => Unit::M,
592        3 => Unit::S,
593        2 => Unit::Ms,
594        1 => Unit::Us,
595        _ => Unit::Ns,
596    }
597}
598
599fn normalize_input(input: &str) -> Result<String, ParseError> {
600    #[cfg(feature = "loose")]
601    {
602        let mut s = String::with_capacity(input.len());
603        for (i, ch) in input.chars().enumerate() {
604            if ch == ' ' || ch == '_' {
605                continue;
606            }
607            if ch.is_ascii() {
608                s.push(ch.to_ascii_lowercase());
609            } else {
610                return Err(ParseError::InvalidChar(i));
611            }
612        }
613        if s.is_empty() {
614            return Err(ParseError::Empty);
615        }
616        Ok(s)
617    }
618    #[cfg(not(feature = "loose"))]
619    {
620        // Strict: must be ASCII and contain no spaces/underscores, lower-case only.
621        if input.is_empty() {
622            return Err(ParseError::Empty);
623        }
624        for (i, b) in input.bytes().enumerate() {
625            if !b.is_ascii() {
626                return Err(ParseError::InvalidChar(i));
627            }
628            if b == b' ' || b == b'_' || b.is_ascii_uppercase() {
629                return Err(ParseError::InvalidChar(i));
630            }
631        }
632        Ok(input.to_string())
633    }
634}
635
636// Formatting
637
638fn format_mixed(d: Duration, max_frac_digits: u8) -> String {
639    let mut rem_secs = d.as_secs();
640    let rem_nanos = d.subsec_nanos();
641
642    let mut out = String::new();
643
644    let days = rem_secs / 86_400;
645    if days > 0 {
646        out.push_str(&format!("{}d", days));
647        rem_secs %= 86_400;
648    }
649    let hours = rem_secs / 3_600;
650    if hours > 0 {
651        out.push_str(&format!("{}h", hours));
652        rem_secs %= 3_600;
653    }
654    let mins = rem_secs / 60;
655    if mins > 0 {
656        out.push_str(&format!("{}m", mins));
657        rem_secs %= 60;
658    }
659
660    // Always render seconds if we have any seconds or nanos left.
661    if rem_secs > 0 || rem_nanos > 0 {
662        if rem_nanos > 0 {
663            let s = format_fraction(rem_secs, rem_nanos, max_frac_digits);
664            out.push_str(&format!("{}s", s));
665        } else {
666            out.push_str(&format!("{}s", rem_secs));
667        }
668    }
669
670    // If nothing at all was emitted, render 0s.
671    if out.is_empty() {
672        out.push_str("0s");
673    }
674    out
675}
676
677fn format_largest_unit_decimal(d: Duration, max_frac_digits: u8) -> String {
678    let total_nanos = (d.as_secs() as u128) * 1_000_000_000u128 + (d.subsec_nanos() as u128);
679
680    if total_nanos == 0 {
681        return "0s".to_string();
682    }
683
684    let candidates = [
685        Unit::D,
686        Unit::H,
687        Unit::M,
688        Unit::S,
689        Unit::Ms,
690        Unit::Us,
691        Unit::Ns,
692    ];
693
694    for &u in &candidates {
695        let u_nanos = u.nanos();
696        if total_nanos >= u_nanos {
697            // integer part
698            let whole = total_nanos / u_nanos;
699            let rem = total_nanos % u_nanos;
700            if rem == 0 {
701                return format!("{}{}", whole, u.as_str());
702            } else {
703                // fraction up to max_frac_digits
704                let frac = rem * 10u128.pow(max_frac_digits as u32) / u_nanos;
705                // Trim trailing zeros, but keep at least one digit.
706                let mut frac_str = format!("{:0width$}", frac, width = max_frac_digits as usize);
707                while frac_str.ends_with('0') && frac_str.len() > 1 {
708                    frac_str.pop();
709                }
710                return format!("{}.{}{}", whole, frac_str, u.as_str());
711            }
712        }
713    }
714    // Fallback, should not happen.
715    "0s".to_string()
716}
717
718fn format_fraction(secs: u64, nanos: u32, max_frac_digits: u8) -> String {
719    if nanos == 0 || max_frac_digits == 0 {
720        return format!("{}.", secs).trim_end_matches('.').to_string();
721    }
722    // Scale nanos (0..1_000_000_000) to fractional digits.
723    let scale = 10u32.pow(max_frac_digits as u32);
724    let frac = (nanos as u128 * scale as u128) / 1_000_000_000u128;
725    let mut frac_str = format!("{:0width$}", frac, width = max_frac_digits as usize);
726    // Trim trailing zeros.
727    while frac_str.ends_with('0') && frac_str.len() > 1 {
728        frac_str.pop();
729    }
730    format!("{}.{}", secs, frac_str)
731}
732
733/// A serde wrapper that (de)serializes as a strict human duration string.
734///
735/// Enable the "serde" feature to use this type.
736#[cfg(feature = "serde")]
737#[derive(Clone, Copy, Debug, Eq, PartialEq)]
738pub struct DurationStr(pub Duration);
739
740#[cfg(feature = "serde")]
741impl serde::Serialize for DurationStr {
742    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
743    where
744        S: serde::Serializer,
745    {
746        let s = format(self.0);
747        serializer.serialize_str(&s)
748    }
749}
750
751#[cfg(feature = "serde")]
752impl<'de> serde::Deserialize<'de> for DurationStr {
753    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
754    where
755        D: serde::Deserializer<'de>,
756    {
757        struct V;
758        impl<'de> serde::de::Visitor<'de> for V {
759            type Value = DurationStr;
760            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
761                f.write_str("a strict human duration string (e.g., \"2d3h4m\", \"90s\", \"1.5h\")")
762            }
763            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
764            where
765                E: serde::de::Error,
766            {
767                parse(v)
768                    .map(DurationStr)
769                    .map_err(|e| E::custom(format!("invalid duration: {}", e)))
770            }
771        }
772        deserializer.deserialize_str(V)
773    }
774}
775
776// Tests
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    #[test]
783    fn basic_parse() {
784        assert_eq!(parse("90s").unwrap(), Duration::from_secs(90));
785        assert_eq!(parse("1.5h").unwrap(), Duration::from_secs(5400));
786        assert_eq!(
787            parse("2d3h4m").unwrap(),
788            Duration::from_secs(2 * 86_400 + 3 * 3600 + 4 * 60)
789        );
790        assert_eq!(parse("250ms").unwrap(), Duration::from_millis(250));
791        assert_eq!(parse("750us").unwrap(), Duration::from_micros(750));
792        assert_eq!(parse("10ns").unwrap(), Duration::new(0, 10));
793        assert_eq!(parse("1m30s").unwrap(), Duration::from_secs(90));
794        assert_eq!(
795            parse("1m30.5s").unwrap(),
796            Duration::from_secs(90) + Duration::from_millis(500)
797        );
798    }
799
800    #[test]
801    fn ordering_and_duplicates() {
802        assert!(parse("h1m").is_err());
803        assert!(parse("1m1m").is_err());
804        assert!(parse("1s2m").is_err());
805        assert!(parse("1ms2s").is_err());
806    }
807
808    #[test]
809    fn decimal_rules() {
810        assert!(parse("1.5h10m").is_err()); // decimal not last
811        assert!(parse("1.1234567890s").is_err()); // too precise (>9)
812        assert!(parse("1.s").is_err());
813        assert!(parse(".5h").is_err());
814    }
815
816    #[test]
817    fn zero_and_format() {
818        assert_eq!(parse("0s").unwrap(), Duration::from_secs(0));
819        assert_eq!(format(Duration::from_secs(0)), "0s");
820
821        let d = Duration::from_secs(2 * 86_400 + 3 * 3600 + 4 * 60) + Duration::from_millis(250);
822        let s = format(d);
823        assert_eq!(s, "2d3h4m0.25s");
824    }
825
826    #[test]
827    fn roundtrip_mixed() {
828        let cases = [
829            "2d3h4m",
830            "90s",
831            "1.5h",
832            "250ms",
833            "1m30s",
834            "1m30.5s",
835            "999ms",
836            "1001ms",
837            "3h15m45.123456789s",
838        ];
839        for &c in &cases {
840            let d = parse(c).unwrap();
841            let s = format(d);
842            let d2 = parse(&s).unwrap();
843            assert_eq!(d, d2, "roundtrip failed for {}", c);
844        }
845    }
846
847    #[test]
848    fn largest_unit_decimal_format() {
849        let d = Duration::from_secs(5400);
850        let s = format_with(
851            d,
852            &FormatOptions::largest_unit_decimal().with_max_frac_digits(3),
853        );
854        // 5400 seconds = 1.5h exactly
855        assert_eq!(s, "1.5h");
856    }
857
858    #[test]
859    fn overflow_behavior() {
860        // Construct a huge input to overflow
861        let huge = format!("{}d", u64::MAX);
862        let err = parse_with(&huge, &ParseOptions::strict()).unwrap_err();
863        assert!(matches!(err, ParseError::Overflow));
864
865        let saturated = parse_with(&huge, &ParseOptions::strict().saturating()).unwrap();
866        assert_eq!(saturated, super::duration_max());
867    }
868
869    #[cfg(feature = "loose")]
870    #[test]
871    fn loose_mode() {
872        assert_eq!(
873            super::parse("1H 30M").unwrap(),
874            std::time::Duration::from_secs(5400)
875        );
876        assert_eq!(
877            super::parse("1h_250ms").unwrap(),
878            std::time::Duration::from_millis(3_600_250)
879        );
880    }
881}