Skip to main content

deep_time/dt/
from_str.rs

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