Skip to main content

deep_time/dt/
from_str.rs

1use crate::{
2    ATTOS_PER_SEC_I128, Dt, DtErr, DtErrKind, SEC_PER_DAY, SEC_PER_MONTH, SEC_PER_WEEK,
3    SEC_PER_YEAR, StrPTimeFmt, TimeParts, an_err,
4};
5use core::str::FromStr;
6
7#[cfg(feature = "parse")]
8impl FromStr for Dt {
9    type Err = DtErr;
10
11    #[inline]
12    fn from_str(s: &str) -> Result<Self, DtErr> {
13        Dt::from_str_parse(s, &None)
14    }
15}
16
17#[cfg(not(feature = "parse"))]
18impl FromStr for Dt {
19    type Err = DtErr;
20
21    #[inline]
22    fn from_str(s: &str) -> Result<Self, DtErr> {
23        Self::from_str_iso(s)
24    }
25}
26
27struct ParsedComponent {
28    unit: u8,
29    signed_int: i64,
30    frac_digits: usize,
31    frac_num: i64,
32}
33
34impl Dt {
35    /// Parses a date/time string.
36    ///
37    /// - When the `parse` feature is enabled: uses the smart auto-parser.
38    /// - When the `parse` feature is disabled: falls back to CCSDS format.
39    ///
40    /// ## Examples
41    ///
42    /// ```rust
43    /// use deep_time::{Dt, Scale};
44    ///
45    /// // uses impl FromStr but Dt::parse provides the same functionality
46    /// let x: Dt = "2000-01-01 12:00:00".parse().unwrap();
47    ///
48    /// let ymd = x.to_ymd();
49    /// assert_eq!(ymd.yr(), 2000);
50    /// assert_eq!(ymd.mo(), 1);
51    /// assert_eq!(ymd.day(), 1);
52    /// assert_eq!(ymd.hr(), 12);
53    /// assert_eq!(ymd.min(), 0);
54    /// assert_eq!(ymd.sec(), 0);
55    /// assert_eq!(ymd.attos(), 0);
56    /// ```
57    ///
58    /// ## See also
59    ///
60    /// - [`Dt::from_str_parse`](../struct.Dt.html#method.from_str_parse)
61    /// - [`Dt::from_str_iso`](../struct.Dt.html#method.from_str_iso)
62    #[inline(always)]
63    pub fn parse(s: &str) -> Result<Self, DtErr> {
64        #[cfg(feature = "parse")]
65        {
66            Self::from_str_parse(s, &None)
67        }
68        #[cfg(not(feature = "parse"))]
69        {
70            Self::from_str_iso(s)
71        }
72    }
73
74    /// Parser equivalent to `strptime` with a provided format string.
75    ///
76    /// The returned [`Dt`] will be on the `TAI` time scale, converted from whatever
77    /// optional time scale (`%L`) was provided in the input. If no time scale was
78    /// provided then it's converted from `UTC` -> `TAI`.
79    ///
80    /// The result is that the [`Dt`]'s `scale` field will be `TAI` and its `target`
81    /// field will be whatever time scale it was converted from (`UTC` if no time
82    /// scale was in the input).
83    ///
84    /// ## Parameters
85    ///
86    /// - `fmt`: The format string containing `%` directives.
87    /// - `input`: The string to parse.
88    /// - `inp_can_end_before_fmt`: If `true`, the input may end before the format
89    ///   string is fully consumed (extra format specifiers are ignored).
90    /// - `fmt_can_end_before_inp`: If `true`, the format may end before the input
91    ///   is fully consumed (trailing characters in the input are allowed).
92    /// - `allow_partial_date`: If `true`, a missing month/day will be defaulted
93    ///   to `1` instead of returning an [`Incomplete`] error.
94    ///
95    /// ## Supported Directives
96    ///
97    /// The format string supports literal characters and the following `%` directives.
98    /// Literal non-whitespace characters must match the input exactly.
99    /// Whitespace in the format matches (and consumes) any leading ASCII whitespace in the input.
100    ///
101    /// Many directives accept **format extensions** right after `%`:
102    /// - **Flags**: `-` (no pad), `_` (space pad), `0` (zero pad), `^`/`#` (treated as default)
103    /// - **Width**: 1–3 digits (affects numeric field width / padding expectations)
104    /// - **Colons** (only for `%z`): `:`, `::`, `:::` to control offset format
105    ///
106    /// ### Year / Century / Unbounded
107    /// - `%Y` — Four-digit year (e.g. `2024`). Supports sign, flags, and width.
108    /// - `%y` — Two-digit year (`00`–`99`; `00`–`68` → 2000+, `69`–`99` → 1900s).
109    /// - `%C` — Century (`00`–`99`).
110    /// - `%G` — Four-digit ISO week-based year.
111    /// - `%g` — Two-digit ISO week-based year (same century rule as `%y`).
112    /// - `%*` — **Unbounded year** (arbitrary length, supports negative years). *Library extension.*
113    ///
114    /// ### Month
115    /// - `%m` — Month number `01`–`12`.
116    /// - `%B` — Full English month name (e.g. `January`).
117    /// - `%b`, `%h` — Abbreviated English month name (3 letters, e.g. `Jan`).
118    ///
119    /// ### Day
120    /// - `%d`, `%e` — Day of month `01`–`31` (`%e` allows space padding).
121    /// - `%j` — Day of year `001`–`366`.
122    ///
123    /// ### Time of day
124    /// - `%H`, `%k` — Hour `00`–`23` (24-hour clock; `%k` allows space padding).
125    /// - `%I`, `%l` — Hour `01`–`12` (12-hour clock).
126    /// - `%M` — Minute `00`–`59`.
127    /// - `%S` — Second `00`–`60` (leap second allowed).
128    /// - `%f`, `%N` — Fractional seconds (up to 18 digits = attoseconds).
129    ///   Width controls precision (`%3f` = ms, `%6N` = µs, `%9f` = ns, etc.).
130    ///   Both accept an optional leading `.` in the input.
131    /// - `%.f`, `%.N`, `%.3f`, `%.6N`, ... — Same fractional parsing, but the
132    ///   dot before the fraction is **optional** in the input (consumes literal `.` if present).
133    /// - `%P`, `%p` — `AM`/`PM` indicator (case-insensitive).
134    ///
135    /// ### Weekday / Week number
136    /// - `%A` — Full English weekday name (e.g. `Monday`).
137    /// - `%a` — Abbreviated English weekday name (3 letters, e.g. `Mon`).
138    /// - `%u` — Weekday number Monday=`1` … Sunday=`7`.
139    /// - `%w` — Weekday number Sunday=`0` … Saturday=`6`.
140    /// - `%U` — Week number (Sunday-first week), `00`–`53`.
141    /// - `%W` — Week number (Monday-first week), `00`–`53`.
142    /// - `%V` — ISO 8601 week number `01`–`53`.
143    ///
144    /// ### Timezone, Offset & Scale
145    /// - `%z` — Timezone offset. Colon count selects format:
146    ///   - `%z`   → `±HH[MM[SS]]` (minutes/seconds optional)
147    ///   - `%:z`  → `±HH:MM` (minutes required)
148    ///   - `%::z` → `±HH:MM:SS` (seconds optional)
149    ///   - `%:::z` → `±HH:MM:SS` (more flexible)
150    /// - `%Q` — IANA timezone name (e.g. `America/New_York`) **or** numeric offset
151    ///   (if input starts with `+`/`-`). *Library extension.*
152    /// - `%L` — Time scale abbreviation (e.g. `TAI`, `UTC`, `GPS`). See [`Scale`].
153    ///   *Library extension.*
154    ///
155    /// ### Shortcuts (compound directives)
156    /// - `%F` — Equivalent to `%Y-%m-%d` (ISO date).
157    /// - `%D` — Equivalent to `%m/%d/%y` (US date).
158    /// - `%T` — Equivalent to `%H:%M:%S`.
159    /// - `%R` — Equivalent to `%H:%M`.
160    ///
161    /// ### Other
162    /// - `%%` — Literal `%` character.
163    /// - `%s` — Unix timestamp (seconds since epoch; up to 19 digits, can be negative).
164    /// - `%n`, `%t` — Any whitespace (consumes it from input).
165    ///
166    /// ### Unsupported / Unknown
167    /// - `%c`, `%r`, `%x`, `%X`, `%Z` → [`DtErrKind::UnsupportedItem`]
168    /// - Any other unknown directive character → [`DtErrKind::UnknownItem`]
169    ///
170    /// ## Errors
171    ///
172    /// Returns a [`DtErr`] if either the strptime-style parser or the subsequent
173    /// conversion from [`TimeParts`] to [`Dt`] fails.
174    ///
175    /// ### Format string errors
176    ///
177    /// - [`DtErrKind::TruncatedDirective`] — The format string ended immediately
178    ///   after a `%` or after a `.` in a fractional directive (e.g. `%.`).
179    /// - [`DtErrKind::UnknownItem`] — Unknown `%` directive character.
180    /// - [`DtErrKind::UnsupportedItem`] — Known but unsupported directive
181    ///   (e.g. `%c`, `%r`, `%x`, `%X`, `%Z`).
182    /// - [`DtErrKind::BadFractional`] — Malformed fractional directive
183    ///   (e.g. `%.x` where `x` is not `f` or `N`).
184    ///
185    /// ### Input parsing errors
186    ///
187    /// - [`DtErrKind::UnexpectedInputEnd`] — Input ended before a required value
188    ///   could be parsed.
189    /// - `Expected*` variants:
190    ///   - [`DtErrKind::ExpectedYear`]
191    ///   - [`DtErrKind::ExpectedMonth`]
192    ///   - [`DtErrKind::ExpectedDay`]
193    ///   - [`DtErrKind::ExpectedDayOfYear`]
194    ///   - [`DtErrKind::ExpectedHour`]
195    ///   - [`DtErrKind::ExpectedMinute`]
196    ///   - [`DtErrKind::ExpectedSecond`]
197    ///   - [`DtErrKind::ExpectedFractionalSeconds`]
198    ///   - [`DtErrKind::ExpectedTimestamp`]
199    ///   - [`DtErrKind::ExpectedWeekNumber`]
200    ///   - [`DtErrKind::ExpectedWeekdayNumber`]
201    /// - [`DtErrKind::MismatchedLiteral`] — A literal character from the format
202    ///   string did not match the input.
203    /// - [`DtErrKind::OutOfRange`] — A numeric value was parsed but is outside
204    ///   the valid range for that component (e.g. month 13, hour 25, day 32).
205    /// - [`DtErrKind::InvalidName`] — Unrecognized month name, weekday name,
206    ///   or `am`/`pm` value.
207    /// - [`DtErrKind::InvalidTimezoneOffset`] — Invalid or malformed timezone
208    ///   offset / IANA name.
209    /// - [`DtErrKind::MustStartWith`] — Timezone offset did not start with
210    ///   `+` or `-`.
211    ///
212    /// ### Post-processing / validation errors
213    ///
214    /// - [`DtErrKind::Incomplete`] — Required date components (month/day) were
215    ///   missing and `allow_partial_date` was `false`.
216    /// - [`DtErrKind::TrailingCharacters`] — The input contained trailing
217    ///   characters after parsing and `fmt_can_end_before_inp` was `false`.
218    ///
219    /// ### Conversion to [`Dt`] errors
220    ///
221    /// These errors can occur *after* successful parsing, inside
222    /// [`TimeParts::to_dt`], when constructing the final [`Dt`]:
223    ///
224    /// - [`DtErrKind::InvalidInput`] — Invalid YMD date, or unable to construct
225    ///   a Julian date from the parsed components (e.g. conflicting or
226    ///   insufficient fields).
227    /// - [`DtErrKind::OutOfRange`] — Day-of-year out of range for the year,
228    ///   ISO week 53 does not exist in the target year, week number > 53,
229    ///   or hour outside `1..=12` when an AM/PM indicator was also parsed.
230    /// - [`DtErrKind::InvalidItem`] — ISO week 53 was requested for a year that
231    ///   does not contain 53 ISO weeks.
232    /// - [`DtErrKind::Incomplete`] — No year (neither `%Y`/`%y` nor `%G`/`%g`)
233    ///   was present in the input at all.
234    /// - [`DtErrKind::InvalidTimezoneOffset`] — Invalid IANA timezone name
235    ///   (only possible when the `jiff-tz` feature is enabled).
236    /// - [`DtErrKind::InvalidNumber`] — Internal timestamp conversion error
237    ///   (rare; only occurs with the `jiff-tz` feature).
238    /// - [`DtErrKind::InvalidBytes`] — A non-UTC IANA timezone name was used
239    ///   but the `jiff-tz` feature is not enabled.
240    ///
241    /// Because [`DtErrKind`] is `#[non_exhaustive]`, additional variants may
242    /// appear in the future. You can match on the variants you care about and
243    /// use a wildcard arm for the rest.
244    ///
245    /// The concrete error kind is available via [`DtErr::kind()`] (or by
246    /// iterating [`DtErr::trace()`] if the error was chained with context
247    /// higher up the call stack).
248    #[inline(always)]
249    pub fn from_str(
250        s: &str,
251        fmt: &str,
252        inp_can_end_before_fmt: bool,
253        fmt_can_end_before_inp: bool,
254        allow_partial_date: bool,
255    ) -> Result<Dt, DtErr> {
256        TimeParts::from_str(
257            fmt,
258            s,
259            inp_can_end_before_fmt,
260            fmt_can_end_before_inp,
261            allow_partial_date,
262        )?
263        .to_dt()
264    }
265
266    /// Parses and validates a `strptime`-style format string into a reusable [`StrPTimeFmt`].
267    ///
268    /// The format is checked once for syntax errors and unsupported directives,
269    /// then stored in a compact fixed-size buffer. The resulting `StrPTimeFmt` is
270    /// `Copy`, cheap to clone, and can be used repeatedly with [`StrPTimeFmt::to_dt`]
271    /// and [`StrPTimeFmt::to_str`] without re-validating.
272    ///
273    /// Only ASCII formats up to 256 bytes are accepted.
274    ///
275    /// ## Parameters
276    ///
277    /// - `strptime_fmt`: The format string using `%` directives (e.g. `"%Y-%m-%d %H:%M:%S"`,
278    ///   `"%F %T"`, `"%Y-%m-%dT%H:%M:%S%.3fZ"`).
279    ///
280    /// ## Errors
281    ///
282    /// Returns [`DtErr`] if the format is:
283    /// - Longer than 256 bytes
284    /// - Not valid ASCII
285    /// - Contains unknown, unsupported, or malformed directives
286    #[inline(always)]
287    pub fn parse_fmt(strptime_fmt: &str) -> Result<StrPTimeFmt, DtErr> {
288        StrPTimeFmt::new(strptime_fmt)
289    }
290
291    /// Generalized ISO / CCSDS ASCII Time Code parser (A or B variant).
292    /// - Parses e.g. **`+2000-01-01T17:00:00 -0500 [America/New_York] TAI`**.
293    /// - Only supports ASCII characters.
294    /// - If a time is included then some kind of date-time separator e.g. `T` is
295    ///   required.
296    /// - Supports both calendar (`%Y-%m-%d`) and day-of-year (`%Y-%j`) formats.
297    /// - Treats years digits literally as shown, for example `99-01-01` would be
298    ///   the year 99 AD not 1999.
299    /// - Supported **optional** components:
300    ///     - Time components after a date e.g. `T12:00:00`.
301    ///     - Offset after time components or directly after the date e.g. `+0200` or
302    ///       `2023-01-01+05:00`.
303    ///     - Timezone name, **requires square brackets** and requires `jiff-tz` feature,
304    ///       after time or offset e.g. `T12:00:00 [America/New_York]`.
305    ///     - Library time scale right on the end of the input, e.g. `TAI`.
306    /// - This function is considerably faster than all other string parsing methods if
307    ///   your date-time string is in the supported formats.
308    #[inline(always)]
309    pub fn from_str_iso(input: &str) -> Result<Self, DtErr> {
310        let mut tp = TimeParts::from_str_iso(input)?;
311        tp.finish(true)?;
312        tp.to_dt()
313    }
314
315    /// Parses an ISO 8601 duration string into a [`Dt`] representing a pure time interval.
316    ///
317    /// Supports the full `PnYnMnDTnHnMnS` format (case-insensitive), including:
318    /// - Optional leading `+` or `-` sign
319    /// - `P` / `p` prefix (required)
320    /// - Optional `T` / `t` separator between date and time parts
321    /// - Weeks (`W` / `w`)
322    /// - Fractional seconds with up to 18 digits of precision (attosecond resolution)
323    ///
324    /// The returned [`Dt`] is a **duration** (signed interval) on the TAI scale.
325    /// It can be added to/subtracted from other `Dt` values, multiplied/divided,
326    /// rounded, etc.
327    ///
328    /// ## Not Reference-Time Aware
329    ///
330    /// This parser is **not reference-time aware**. Calendar units (`Y`, `M`) are
331    /// converted to a fixed number of seconds using standard average lengths
332    /// rather than being resolved against a specific date. This makes parsing
333    /// fast and allocation-free, but `P1M` always represents exactly the same
334    /// duration regardless of context.
335    ///
336    /// ## Parameters
337    ///
338    /// - `s`: The ISO 8601 duration string (e.g. `"P1Y2M3DT4H5M6.123456789012345678S"`,
339    ///   `"-PT30M"`, `"P7W"`, `"+P1DT12H"`).
340    ///
341    /// ## Errors
342    ///
343    /// Returns [`DtErr`] for:
344    /// - Empty string
345    /// - Missing `P` prefix
346    /// - Invalid syntax (`T` with no time part, multiple `T`s, etc.)
347    /// - Unknown unit designators
348    /// - Numeric values that are out of range or cause overflow
349    pub fn from_iso_duration(s: &str) -> Result<Dt, DtErr> {
350        let len = s.len();
351        if len == 0 {
352            return Err(an_err!(DtErrKind::Incomplete, "empty"));
353        }
354
355        let b = s.as_bytes();
356        let mut i = 0usize;
357
358        // Optional leading sign (+ or -)
359        let mut sign: i64 = 1;
360        if i < len && matches!(b[i], b'+' | b'-') {
361            if b[i] == b'-' {
362                sign = -1;
363            }
364            i += 1;
365        }
366
367        // Must start with P/p
368        if i >= len || !matches!(b[i], b'P' | b'p') {
369            return Err(an_err!(DtErrKind::MustStartWith, "P"));
370        }
371        i += 1;
372
373        // Find the (single) T/t separator
374        let t_pos = b[i..]
375            .iter()
376            .position(|&c| matches!(c, b'T' | b't'))
377            .map(|p| i + p);
378
379        let (date_part, time_part) = match t_pos {
380            Some(pos) => {
381                if pos == len - 1 {
382                    return Err(an_err!(DtErrKind::InvalidSyntax, "T with no time"));
383                }
384                if b[pos + 1..].iter().any(|&c| matches!(c, b'T' | b't')) {
385                    return Err(an_err!(DtErrKind::InvalidSyntax, "multiple T"));
386                }
387                (&b[i..pos], &b[pos + 1..])
388            }
389            None => (&b[i..], &[] as &[u8]),
390        };
391
392        let mut has_fraction = false;
393        let mut total_nanos: i128 = 0;
394
395        // Both date and time parts now use the same fixed-length logic
396        Self::parse_duration_part(date_part, &mut total_nanos, true, sign, &mut has_fraction)?;
397        Self::parse_duration_part(time_part, &mut total_nanos, false, sign, &mut has_fraction)?;
398
399        // Convert accumulated nanoseconds to attoseconds and build Dt
400        let total_attos = total_nanos * 1_000_000_000i128;
401        Ok(Dt::span(total_attos))
402    }
403
404    /// Parses a single component (number + optional fraction + unit) from the slice,
405    /// advancing the index `i`. Returns `None` when the slice is exhausted.
406    fn parse_next_component(
407        chars: &[u8],
408        i: &mut usize,
409        sign: i64,
410        has_fraction: &mut bool,
411    ) -> Result<Option<ParsedComponent>, DtErr> {
412        if *i >= chars.len() {
413            return Ok(None);
414        }
415
416        if *has_fraction {
417            return Err(an_err!(DtErrKind::InvalidSyntax, "components after frac"));
418        }
419
420        // Parse integer part
421        let start = *i;
422        while *i < chars.len() && chars[*i].is_ascii_digit() {
423            *i += 1;
424        }
425        if start == *i {
426            return Err(an_err!(DtErrKind::ExpectedValue, "number"));
427        }
428
429        let int_str = core::str::from_utf8(&chars[start..*i])
430            .map_err(|_| an_err!(DtErrKind::InvalidNumber, "invalid utf8 in int"))?;
431        let int: i64 = int_str.parse().map_err(|e: core::num::ParseIntError| {
432            an_err!(DtErrKind::InvalidNumber, "{}: {}", int_str, e)
433        })?;
434
435        // Parse optional fraction
436        let mut frac_num: i64 = 0;
437        let mut frac_digits: usize = 0;
438        if *i < chars.len() && matches!(chars[*i], b'.' | b',') {
439            *i += 1;
440            let frac_start = *i;
441            while *i < chars.len() && chars[*i].is_ascii_digit() {
442                *i += 1;
443            }
444            frac_digits = *i - frac_start;
445            if frac_digits == 0 {
446                return Err(an_err!(DtErrKind::ExpectedValue, "empty frac after ."));
447            }
448            if frac_digits > 9 {
449                return Err(an_err!(DtErrKind::OutOfRange, "frac >9"));
450            }
451
452            let frac_str = core::str::from_utf8(&chars[frac_start..*i])
453                .map_err(|_| an_err!(DtErrKind::InvalidNumber, "invalid utf8 in frac"))?;
454            frac_num = frac_str.parse().map_err(|e: core::num::ParseIntError| {
455                an_err!(DtErrKind::InvalidNumber, "{}: {}", frac_str, e)
456            })?;
457        }
458
459        // Unit must follow
460        if *i >= chars.len() {
461            return Err(an_err!(
462                DtErrKind::InvalidSyntax,
463                "missing unit after number"
464            ));
465        }
466        let unit = chars[*i];
467        *i += 1;
468
469        // Only seconds support a fractional part
470        if frac_digits > 0 {
471            if !matches!(unit, b'S' | b's') {
472                return Err(an_err!(
473                    DtErrKind::InvalidSyntax,
474                    "frac only supported for seconds"
475                ));
476            }
477            *has_fraction = true;
478        }
479
480        let signed_int = (int as i128 * sign as i128) as i64;
481
482        Ok(Some(ParsedComponent {
483            unit,
484            signed_int,
485            frac_digits,
486            frac_num,
487        }))
488    }
489
490    /// Helper that parses **one section** of an ISO duration (date or time part)
491    /// and accumulates nanoseconds into `total_nanos`.
492    ///
493    /// Years, months, weeks, and days are converted using the fixed-length
494    /// constants (the only sensible semantics for a pure `Dt`).
495    fn parse_duration_part(
496        chars: &[u8],
497        total_nanos: &mut i128,
498        is_date: bool,
499        sign: i64,
500        has_fraction: &mut bool,
501    ) -> Result<(), DtErr> {
502        let mut i = 0;
503        while let Some(comp) = Self::parse_next_component(chars, &mut i, sign, has_fraction)? {
504            let contrib_nanos = match (is_date, comp.unit) {
505                (true, b'Y' | b'y') => {
506                    let total_secs = (comp.signed_int as i128)
507                        .checked_mul(SEC_PER_YEAR)
508                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "year"))?;
509                    total_secs * 1_000_000_000i128
510                }
511                (true, b'M' | b'm') => {
512                    let total_secs = (comp.signed_int as i128)
513                        .checked_mul(SEC_PER_MONTH)
514                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "month"))?;
515                    total_secs * 1_000_000_000i128
516                }
517                (true, b'W' | b'w') => {
518                    let total_secs = (comp.signed_int as i128)
519                        .checked_mul(SEC_PER_WEEK as i128)
520                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "week"))?;
521                    total_secs * 1_000_000_000i128
522                }
523                (true, b'D' | b'd') => {
524                    let total_secs = (comp.signed_int as i128)
525                        .checked_mul(SEC_PER_DAY)
526                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "day"))?;
527                    total_secs * 1_000_000_000i128
528                }
529                (false, b'H' | b'h') => (comp.signed_int as i128) * 3_600_000_000_000i128,
530                (false, b'M' | b'm') => (comp.signed_int as i128) * 60_000_000_000i128,
531                (false, b'S' | b's') => {
532                    let mut sec_nanos = (comp.signed_int as i128) * 1_000_000_000i128;
533                    if comp.frac_digits > 0 {
534                        let frac_ns = (comp.frac_num as i128 * sign as i128 * 1_000_000_000i128)
535                            / 10i128.pow(comp.frac_digits as u32);
536                        sec_nanos += frac_ns;
537                    }
538                    sec_nanos
539                }
540                _ => {
541                    return Err(an_err!(DtErrKind::InvalidItem, "{}", comp.unit as char));
542                }
543            };
544
545            *total_nanos = total_nanos.saturating_add(contrib_nanos);
546        }
547        Ok(())
548    }
549
550    /// Parses a media-style duration string.
551    ///
552    /// Accepts formats like:
553    /// - `"0:45"`, `"9:41"`
554    /// - `"1:23:45"`
555    /// - `"1:07:54:30"`
556    /// - `"-1:23:45"`
557    ///
558    /// ## See also
559    ///
560    /// - [`Dt::to_str_media_duration`]
561    /// - [`Dt::to_str_lite_media_duration`]
562    pub fn from_str_media_duration(input: &str) -> Result<Dt, DtErr> {
563        let bytes = input.as_bytes();
564        let len = bytes.len();
565        let mut pos: usize = 0;
566
567        // Skip leading whitespace
568        while pos < len && bytes[pos].is_ascii_whitespace() {
569            pos += 1;
570        }
571
572        if pos == len {
573            return Err(an_err!(
574                DtErrKind::InvalidBytes,
575                "empty media duration string"
576            ));
577        }
578
579        // Optional single leading minus
580        let negative = if bytes[pos] == b'-' {
581            pos += 1;
582            if pos == len {
583                return Err(an_err!(DtErrKind::InvalidBytes, "invalid media duration"));
584            }
585            true
586        } else {
587            false
588        };
589
590        // Parse up to 4 numeric components separated by ':'
591        let mut components: [i128; 4] = [0; 4];
592        let mut count: usize = 0;
593
594        loop {
595            if count >= 4 {
596                break;
597            }
598
599            // Parse one number
600            if pos >= len || !bytes[pos].is_ascii_digit() {
601                return Err(an_err!(
602                    DtErrKind::InvalidNumber,
603                    "expected digit in media duration component"
604                ));
605            }
606
607            let mut value: i128 = 0;
608            while pos < len && bytes[pos].is_ascii_digit() {
609                value = value
610                    .saturating_mul(10)
611                    .saturating_add((bytes[pos] - b'0') as i128);
612                pos += 1;
613            }
614
615            components[count] = value;
616            count += 1;
617
618            // Check for more components
619            if pos >= len || bytes[pos] != b':' {
620                break;
621            }
622
623            pos += 1; // consume ':'
624
625            // Reject trailing ':' with no number after it
626            if pos >= len || !bytes[pos].is_ascii_digit() {
627                return Err(an_err!(
628                    DtErrKind::InvalidBytes,
629                    "expected number after ':' in media duration"
630                ));
631            }
632        }
633
634        if count < 2 || count > 4 {
635            return Err(an_err!(
636                DtErrKind::InvalidBytes,
637                "media duration must contain 2 to 4 colon-separated components"
638            ));
639        }
640
641        // Skip trailing whitespace
642        while pos < len && bytes[pos].is_ascii_whitespace() {
643            pos += 1;
644        }
645
646        if pos != len {
647            return Err(an_err!(
648                DtErrKind::InvalidBytes,
649                "trailing characters in media duration string"
650            ));
651        }
652
653        // Convert to total seconds
654        let total_secs: i128 = match count {
655            2 => components[0] * 60 + components[1], // M:SS
656            3 => components[0] * 3600 + components[1] * 60 + components[2], // H:MM:SS
657            4 => components[0] * 86400 + components[1] * 3600 + components[2] * 60 + components[3], // D:H:MM:SS
658            _ => unreachable!(),
659        };
660
661        let total_secs = if negative { -total_secs } else { total_secs };
662        let attos = total_secs.saturating_mul(ATTOS_PER_SEC_I128);
663
664        Ok(Dt::span(attos))
665    }
666}