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, dt,
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(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 the fast ISO 8601 parser
42    ///   ([`Dt::from_str`](../struct.Dt.html#method.from_str)).
43    ///
44    /// ## Examples
45    ///
46    /// ```rust
47    /// use deep_time::{Dt, Scale};
48    ///
49    /// // uses impl FromStr but Dt::parse provides the same functionality
50    /// let x: Dt = "2000-01-01 12:00:00".parse().unwrap();
51    ///
52    /// let ymd = x.to_ymd();
53    /// assert_eq!(ymd.yr(), 2000);
54    /// assert_eq!(ymd.mo(), 1);
55    /// assert_eq!(ymd.day(), 1);
56    /// assert_eq!(ymd.hr(), 12);
57    /// assert_eq!(ymd.min(), 0);
58    /// assert_eq!(ymd.sec(), 0);
59    /// assert_eq!(ymd.attos(), 0);
60    /// ```
61    ///
62    /// ## See also
63    ///
64    /// - [`Dt::from_str_parse`](../struct.Dt.html#method.from_str_parse)
65    /// - [`Dt::from_str`](../struct.Dt.html#method.from_str)
66    #[inline(always)]
67    pub fn parse(s: &str) -> Result<Self, DtErr> {
68        #[cfg(feature = "parse")]
69        {
70            Self::from_str_parse(s, &ParseCfg::DEFAULT)
71        }
72        #[cfg(not(feature = "parse"))]
73        {
74            Self::from_str(s)
75        }
76    }
77
78    /// Parser equivalent to `strptime` with a provided format string.
79    ///
80    /// The returned [`Dt`] will be on the `TAI` time scale, converted from whatever
81    /// optional time scale (`%L`) was provided in the input. If no time scale was
82    /// provided then it's converted from `UTC` -> `TAI`.
83    ///
84    /// The result is that the [`Dt`]'s `scale` field will be `TAI` and its `target`
85    /// field will be whatever time scale it was converted from (`UTC` if no time
86    /// scale was in the input).
87    ///
88    /// ## Parameters
89    ///
90    /// - `fmt`: The format string containing `%` directives.
91    /// - `input`: The string to parse.
92    /// - `inp_can_end_before_fmt`: If `true`, the input may end before the format
93    ///   string is fully consumed (extra format specifiers are ignored).
94    /// - `fmt_can_end_before_inp`: If `true`, the format may end before the input
95    ///   is fully consumed (trailing characters in the input are allowed).
96    /// - `allow_partial_date`: If `true`, a missing month/day will be defaulted
97    ///   to `1` instead of returning a [`DtErrKind::Incomplete`](../error/enum.DtErrKind.html#variant.Incomplete) error.
98    ///
99    /// ## Supported Directives
100    ///
101    /// The format string supports literal characters and the following `%` directives.
102    /// Literal non-whitespace characters must match the input exactly.
103    /// Whitespace in the format matches (and consumes) any leading ASCII whitespace in the input.
104    ///
105    /// Many directives accept **format extensions** right after `%`:
106    /// - **Flags**: `-` (no pad), `_` (space pad), `0` (zero pad), `^`/`#` (treated as default)
107    /// - **Width**: 1–3 digits (affects numeric field width / padding expectations)
108    /// - **Colons** (only for `%z`): `:`, `::`, `:::` to control offset format
109    ///
110    /// ### Year / Century / Unbounded
111    /// - `%Y` — Four-digit year (e.g. `2024`). Supports sign, flags, and width.
112    /// - `%y` — Two-digit year (`00`–`99`; `00`–`68` → 2000+, `69`–`99` → 1900s).
113    /// - `%C` — Century (`00`–`99`).
114    /// - `%G` — Four-digit ISO week-based year.
115    /// - `%g` — Two-digit ISO week-based year (same century rule as `%y`).
116    /// - `%*` — **Unbounded year** (arbitrary length, supports negative years). *Library extension.*
117    ///
118    /// ### Month
119    /// - `%m` — Month number `01`–`12`.
120    /// - `%B` — Full English month name (e.g. `January`).
121    /// - `%b`, `%h` — Abbreviated English month name (3 letters, e.g. `Jan`).
122    ///
123    /// ### Day
124    /// - `%d`, `%e` — Day of month `01`–`31` (`%e` allows space padding).
125    /// - `%j` — Day of year `001`–`366`.
126    ///
127    /// ### Time of day
128    /// - `%H`, `%k` — Hour `00`–`23` (24-hour clock; `%k` allows space padding).
129    /// - `%I`, `%l` — Hour `01`–`12` (12-hour clock).
130    /// - `%M` — Minute `00`–`59`.
131    /// - `%S` — Second `00`–`60` (leap second allowed).
132    /// - `%f`, `%N` — Fractional seconds (up to 18 digits = attoseconds).
133    ///   Width controls precision (`%3f` = ms, `%6N` = µs, `%9f` = ns, etc.).
134    ///   Both accept an optional leading `.` in the input.
135    /// - `%.f`, `%.N`, `%.3f`, `%.6N`, ... — Same fractional parsing, but the
136    ///   dot before the fraction is **optional** in the input (consumes literal `.` if present).
137    /// - `%P`, `%p` — `AM`/`PM` indicator (case-insensitive).
138    ///
139    /// ### Weekday / Week number
140    /// - `%A` — Full English weekday name (e.g. `Monday`).
141    /// - `%a` — Abbreviated English weekday name (3 letters, e.g. `Mon`).
142    /// - `%u` — Weekday number Monday=`1` … Sunday=`7`.
143    /// - `%w` — Weekday number Sunday=`0` … Saturday=`6`.
144    /// - `%U` — Week number (Sunday-first week), `00`–`53`.
145    /// - `%W` — Week number (Monday-first week), `00`–`53`.
146    /// - `%V` — ISO 8601 week number `01`–`53`.
147    ///
148    /// ### Timezone, Offset & Scale
149    /// - `%z` — Timezone offset. Colon count selects format:
150    ///   - `%z`   → `±HH[MM[SS]]` (minutes/seconds optional)
151    ///   - `%:z`  → `±HH:MM` (minutes required)
152    ///   - `%::z` → `±HH:MM:SS` (seconds optional)
153    ///   - `%:::z` → `±HH:MM:SS` (more flexible)
154    /// - `%Q` — IANA timezone name (e.g. `America/New_York`) **or** numeric offset
155    ///   (if input starts with `+`/`-`). *Library extension.*
156    /// - `%L` — Time scale abbreviation (e.g. `TAI`, `UTC`, `GPS`). See [`Scale`].
157    ///   *Library extension.*
158    ///
159    /// ### Shortcuts (compound directives)
160    /// - `%F` — Equivalent to `%Y-%m-%d` (ISO date).
161    /// - `%D` — Equivalent to `%m/%d/%y` (US date).
162    /// - `%T` — Equivalent to `%H:%M:%S`.
163    /// - `%R` — Equivalent to `%H:%M`.
164    ///
165    /// ### Other
166    /// - `%%` — Literal `%` character.
167    /// - `%s` — Unix timestamp (seconds since 1970-01-01 00:00 UTC, can be negative).
168    ///   This directive greedily consumes any fractional seconds.
169    /// - `%J` — Seconds since 2000-01-01 12:00 TAI (2000-01-01 noon epoch), can be
170    ///   negative.
171    ///   This directive greedily consumes any fractional seconds.
172    /// - `%n`, `%t` — Any whitespace (consumes it from input).
173    ///
174    /// ### Unsupported / Unknown
175    /// - `%c`, `%r`, `%x`, `%X`, `%Z` → [`DtErrKind::UnsupportedItem`]
176    /// - Any other unknown directive character → [`DtErrKind::UnknownItem`]
177    ///
178    /// ## Errors
179    ///
180    /// Returns a [`DtErr`] if either the strptime-style parser or the subsequent
181    /// conversion from [`Parts`] to [`Dt`] fails.
182    ///
183    /// ### Format string errors
184    ///
185    /// - [`DtErrKind::TruncatedDirective`] — A `%` appeared at the end of the format
186    ///   string, or after flags/width/colons with no directive character following it.
187    /// - [`DtErrKind::UnexpectedEnd`] — A `%` was followed only by extensions with no
188    ///   directive character.
189    /// - [`DtErrKind::InvalidFractional`] — A `%.` fractional directive was followed by
190    ///   an invalid character (not `f` or `N`).
191    /// - [`DtErrKind::ExpectedFractional`] — A `%.` fractional directive was started
192    ///   but no directive character followed the dot.
193    /// - [`DtErrKind::UnsupportedItem`] — The format contains `%c`, `%r`, `%x`, `%X`,
194    ///   or `%Z`.
195    /// - [`DtErrKind::UnknownItem`] — The format contains an unrecognized `%` directive.
196    ///
197    /// ### Input parsing errors
198    ///
199    /// - [`DtErrKind::UnexpectedEnd`] — The input ended before a required value could
200    ///   be parsed.
201    /// - `Expected*` variants:
202    ///   - [`DtErrKind::ExpectedYear`], [`DtErrKind::ExpectedCentury`],
203    ///     [`DtErrKind::ExpectedMonth`], [`DtErrKind::ExpectedDay`],
204    ///     [`DtErrKind::ExpectedDayOfYear`], [`DtErrKind::ExpectedHour`],
205    ///     [`DtErrKind::ExpectedMinute`], [`DtErrKind::ExpectedSecond`],
206    ///     [`DtErrKind::ExpectedFractional`], [`DtErrKind::ExpectedTimestamp`],
207    ///     [`DtErrKind::ExpectedWeekNumber`], [`DtErrKind::ExpectedMonWeekday`],
208    ///     [`DtErrKind::ExpectedSunWeekday`], [`DtErrKind::ExpectedMonWeek`],
209    ///     [`DtErrKind::ExpectedSunWeek`]
210    /// - Out-of-range errors:
211    ///   - [`DtErrKind::MonthOutOfRange`], [`DtErrKind::DayOutOfRange`],
212    ///     [`DtErrKind::DayOfYearOutOfRange`], [`DtErrKind::HourOutOfRange`],
213    ///     [`DtErrKind::MinuteOutOfRange`], [`DtErrKind::SecondOutOfRange`],
214    ///     [`DtErrKind::IsoWeekOutOfRange`], [`DtErrKind::MonWeekdayOutOfRange`],
215    ///     [`DtErrKind::SunWeekdayOutOfRange`]
216    /// - [`DtErrKind::MismatchedLiteral`] — A literal character in the format string
217    ///   did not match the input.
218    /// - Name errors: [`DtErrKind::InvalidMonthName`], [`DtErrKind::InvalidWeekdayName`],
219    ///   [`DtErrKind::InvalidMeridiem`].
220    ///
221    /// ### Timezone and Offset errors
222    ///
223    /// - [`DtErrKind::OffsetMissingSign`] — A timezone offset (`%z` / `%Q`) did not
224    ///   start with `+` or `-`.
225    /// - [`DtErrKind::InvalidOffsetHour`] — Invalid hour value in a timezone offset.
226    /// - [`DtErrKind::InvalidOffsetMinute`] — Invalid minute value in a timezone offset.
227    /// - [`DtErrKind::InvalidOffsetSecond`] — Invalid second value in a timezone offset.
228    /// - [`DtErrKind::InvalidOffsetColons`] — Incorrect number of colons or missing
229    ///   required colon in a timezone offset.
230    /// - [`DtErrKind::InvalidOffset`] — General failure while parsing a numeric
231    ///   timezone offset.
232    /// - [`DtErrKind::InvalidTimeZone`] — Invalid or unparseable IANA timezone name
233    ///   (used by the `%Q` directive).
234    ///
235    /// ### Post-processing / validation errors
236    ///
237    /// - [`DtErrKind::TrailingCharacters`] — The input contained trailing characters
238    ///   after parsing and `fmt_can_end_before_inp` was `false`.
239    /// - [`DtErrKind::Incomplete`] — Required date components (month or day) were
240    ///   missing and `allow_partial_date` was `false`.
241    ///
242    /// ### Conversion to [`Dt`] errors
243    ///
244    /// These errors can occur *after* successful parsing, inside [`Parts::to_dt`]:
245    ///
246    /// - [`DtErrKind::InvalidDate`] or [`DtErrKind::InvalidInput`] — Unable to
247    ///   construct a valid date from the parsed components.
248    /// - Out-of-range or conflicting field errors (e.g. [`DtErrKind::DayOfYearOutOfRange`],
249    ///   [`DtErrKind::IsoWeekOutOfRange`], [`DtErrKind::WeekOutOfRange`], etc.).
250    /// - [`DtErrKind::InvalidItem`] — ISO week 53 requested for a year that does not
251    ///   contain 53 ISO weeks.
252    /// - Feature-dependent errors (when `jiff-tz` is involved):
253    ///   - [`DtErrKind::InvalidTimeZone`], [`DtErrKind::InvalidNumber`],
254    ///     [`DtErrKind::InvalidBytes`].
255    ///
256    /// The error kind is available via [`DtErr::kind()`].
257    #[inline(always)]
258    pub fn from_strptime(
259        s: &str,
260        fmt: &str,
261        inp_can_end_before_fmt: bool,
262        fmt_can_end_before_inp: bool,
263        allow_partial_date: bool,
264    ) -> Result<Dt, DtErr> {
265        Parts::from_strptime(
266            fmt,
267            s,
268            inp_can_end_before_fmt,
269            fmt_can_end_before_inp,
270            allow_partial_date,
271        )?
272        .to_dt()
273    }
274
275    /// Parses and validates a `strptime`-style format string into a reusable [`StrPTimeFmt`].
276    ///
277    /// The format is checked once for syntax errors and unsupported directives,
278    /// then stored in a compact fixed-size buffer. The resulting `StrPTimeFmt` is
279    /// can be used repeatedly with
280    /// [`StrPTimeFmt::to_dt`](../struct.StrPTimeFmt.html#method.to_dt)
281    /// and
282    /// [`StrPTimeFmt::to_str`](../struct.StrPTimeFmt.html#method.to_str)
283    /// without re-validating.
284    ///
285    /// - This unfortunately doesn't improve parsing performance.
286    /// - Only ASCII formats up to
287    ///   [`StrPTimeFmt::MAX_FMT_LEN`](../struct.StrPTimeFmt.html#associatedconstant.MAX_FMT_LEN)
288    ///   bytes are accepted.
289    ///
290    /// ## Parameters
291    ///
292    /// - `strptime_fmt`: The format string using `%` directives (e.g. `"%Y-%m-%d %H:%M:%S"`,
293    ///   `"%F %T"`, `"%Y-%m-%dT%H:%M:%S%.3fZ"`).
294    ///
295    /// ## Errors
296    ///
297    /// Returns [`DtErr`] if the format is:
298    /// - Longer than
299    ///   [`StrPTimeFmt::MAX_FMT_LEN`](../struct.StrPTimeFmt.html#associatedconstant.MAX_FMT_LEN)
300    ///   bytes.
301    /// - Not valid ASCII.
302    /// - Contains unknown, unsupported, or malformed directives.
303    #[inline(always)]
304    pub fn parse_fmt(strptime_fmt: &str) -> Result<StrPTimeFmt, DtErr> {
305        StrPTimeFmt::new(strptime_fmt)
306    }
307
308    /// Fast, no-alloc parser for common ISO-like and epoch-style date-time strings.
309    ///
310    /// Equivalent to [`Parts::from_str`](../civil_parts/struct.Parts.html#method.from_str)
311    /// followed by [`Parts::to_dt`](../civil_parts/struct.Parts.html#method.to_dt). The
312    /// formats and lenience rules below are those of the `Parts` parser; this method
313    /// then resolves components to a single instant.
314    ///
315    /// - Only **ASCII** input is supported.
316    /// - Inputs longer than [`STRTIME_SIZE`](../consts/constant.STRTIME_SIZE.html) are
317    ///   rejected with [`DtErrKind::InvalidLen`](../error/enum.DtErrKind.html#variant.InvalidLen).
318    /// - Leading non-date junk is skipped until a year-like start (`digit` or `±`digit),
319    ///   a recognized alphabetic prefix (`JD` / `MJD` / `SEC`, case-insensitive), or an
320    ///   English weekday name / abbrev (day-month-year order).
321    /// - Trailing characters after a successful parse are generally ignored (lenient).
322    /// - Considerably faster than format-string / smart parsers when the input is one
323    ///   of the shapes below.
324    /// - Timezones beyond UTC aliases require the `jiff-tz` or `jiff-tz-bundle` feature
325    ///   (both require `alloc`).
326    ///
327    /// ## Returns
328    ///
329    /// Always a [`Dt`] on the **`TAI`** time scale (after any conversion).
330    ///
331    /// - No trailing scale + civil ISO-like input (calendar / DOY / ISO week) → interpret
332    ///   as **UTC**, then convert **UTC → TAI** (leap seconds applied).
333    /// - No trailing scale + `SEC` / `JD` / `MJD` → interpret as **TAI** (no conversion).
334    /// - Trailing scale present (e.g. `TDB`, `GPS`) → convert **that scale → TAI**
335    ///   (no-op if the scale is already `TAI`).
336    ///
337    /// The returned value’s `target` reflects the scale assumed before conversion to TAI.
338    ///
339    /// ## Supported formats
340    ///
341    /// An **optional** library time scale at the end of the input (e.g. `TAI`) is
342    /// supported for all of the formats below.
343    ///
344    /// ### ISO-like civil date-times
345    ///
346    /// #### Format examples
347    ///
348    /// - **`+2000-01-01T17:00:00 -0500 [America/New_York] TAI`**
349    /// - **`2024 Apr 18, 14:30:25 [America/New_York]`** — month abbrev or full English name
350    /// - **`Sat, 07 Feb 2015 11:22:33`**, **`Sat,07Feb2015T11:22:33`** — weekday first
351    ///   (day-month-year; `Sun`…`Sat` or full English name)
352    /// - **`2024-109 14:30:25`** — day of year (`%Y-%j`)
353    /// - **`2024-W11`**, **`2024W11`**, **`2024-W11-4`** — ISO week date (`%G-W%V`, optional
354    ///   weekday `%u` with Monday=`1` … Sunday=`7`)
355    /// - **`2024`**, **`2024-03`**, **`2024 Mar`** — partial dates (missing month/day → `1`)
356    /// - **`2024-04-18T9:3:5.5`**, **`2024-04-18T143025`** — flexible / compact time
357    ///
358    /// #### Date forms
359    ///
360    /// Year digits are taken **literally** (no century window): `99-01-01` is year 99 AD.
361    /// Year overflow during accumulation yields
362    /// [`DtErrKind::YearOutOfRange`](../error/enum.DtErrKind.html#variant.YearOutOfRange).
363    ///
364    /// **Weekday first** → day, month, year (required). Hyphen before the year is a
365    /// separator (`07-Feb-2015`); a signed year needs space or a doubled sign
366    /// (`07 Feb -4714`, `07-Feb--4714`, `+2015`).
367    ///
368    /// **Otherwise year first.** After an optional sign and year digits, exactly one of:
369    ///
370    /// 1. **ISO week** — `W`/`w` immediately after the year (or after a non-letter
371    ///    separator such as `-`): e.g. `2024-W11`, `2024W114`.
372    ///    - Week number required right after `W` (1–2 digits); `0` becomes week `1`.
373    ///    - Optional weekday: `-4` or basic trailing digit `1..=7` (Monday=`1` … Sunday=`7`);
374    ///      if omitted, Monday is used when resolving the instant.
375    ///    - This is **not** strftime `%W` (Monday week-of-year on a calendar year).
376    /// 2. **Day of year** — three slots `[digit|space][digit|space][digit]` not followed
377    ///    by another digit (so `2024-0401` is calendar `04-01`, not DOY). Space padding
378    ///    is allowed (`2024-  9`).
379    /// 3. **Calendar month/day** — numeric month (1–2 digits) or English month name
380    ///    (abbrev or full; matched from the first three letters), then day (1–2 digits).
381    ///
382    /// **Partial calendar dates** (year-first only) default missing fields to `1`
383    /// (January / day 1):
384    ///
385    /// - Year only: `2024`, `2024-` → 1 January.
386    /// - Year-month: `2024-03`, `2024 Mar` → day 1.
387    /// - Explicit zero month/day digits are treated like omitted (`2024-00`, `2024-03-0` → 1).
388    /// - Year or year-month may be followed by time: `2024T12:00`, `2024-03T12:00`.
389    ///
390    /// #### Time (optional)
391    ///
392    /// - Usually introduced by `T`/`t`, space, or another non-digit separator; compact
393    ///   glued times after a full day are also accepted (e.g. `…18143025`).
394    /// - Hour, minute, and second are **1 or 2** digits when the field ends at `:` /
395    ///   space (or, for seconds, `.` before a fraction).
396    /// - Compact digit runs without separators are supported (e.g. `T143025`).
397    /// - Fractional seconds: `.` then digits (up to 18 kept as attoseconds; extra digits
398    ///   ignored).
399    /// - Optional trailing `Z`/`z` is consumed.
400    /// - Minutes must be `≤ 59`; seconds must be `≤ 60` (leap second `60` allowed; resolved
401    ///   by [`Parts::to_dt`](../civil_parts/struct.Parts.html#method.to_dt)).
402    ///
403    /// #### Optional trailing components
404    ///
405    /// - **Offset** — `+`/`-` then hours (and optional minutes), with or without `:`:
406    ///   `+02:00`, `-0530`, also allowed directly after the date.
407    /// - **IANA name** — must be in square brackets, e.g. `[America/New_York]`.
408    ///   Resolving non-UTC aliases requires the `jiff-tz` or `jiff-tz-bundle` feature
409    ///   (both require `alloc`).
410    /// - **Scale** — library abbreviation, e.g. `TAI`, `UTC`, `TDB`, `GPS`.
411    ///
412    /// ### Seconds since 2000-01-01 noon (library epoch)
413    ///
414    /// #### Format examples
415    ///
416    /// - **`SEC 1234.567 TDB`**
417    /// - **`sec1234.5 TAI`**
418    ///
419    /// #### Notes
420    ///
421    /// - `sec` prefix is required (case-insensitive).
422    /// - Fractional seconds optional.
423    ///
424    /// ### Julian Date
425    ///
426    /// #### Format examples
427    ///
428    /// - **`JD 2451545.0 TAI`**
429    /// - **`JD2451545.25 TT`**
430    ///
431    /// #### Notes
432    ///
433    /// - `jd` prefix is required (case-insensitive).
434    /// - Fractional days optional.
435    ///
436    /// ### Modified Julian Date
437    ///
438    /// #### Format examples
439    ///
440    /// - **`MJD 51544.5 TT`**
441    /// - **`mjd 51544.25`**
442    ///
443    /// #### Notes
444    ///
445    /// - `mjd` prefix is required (case-insensitive).
446    /// - Fractional days optional.
447    ///
448    /// ## See also
449    ///
450    /// - [`Parts::from_str`](../civil_parts/struct.Parts.html#method.from_str) —
451    ///   same string parse without resolving to a [`Dt`].
452    #[allow(clippy::should_implement_trait)]
453    #[inline(always)]
454    pub fn from_str(s: &str) -> Result<Self, DtErr> {
455        Parts::from_str(s)?.to_dt()
456    }
457
458    /// Parses a decimal seconds string (with optional fractional part) as seconds
459    /// since
460    /// [`Dt::ZERO`](../struct.Dt.html#associatedconstant.ZERO)
461    /// on the chosen time scale.
462    ///
463    /// The returned [`Dt`] is on the `TAI` time [`Scale`], having been converted
464    /// to `TAI` from whatever the **trailing** scale is, or if no scale is provided
465    /// then no conversion takes place.
466    ///
467    /// Leading non-numeric characters are skipped until a number start is found
468    /// (`+`, `-`, `.`, or digit).
469    ///
470    /// - Fractional seconds are limited to the first 18 digits (attosecond
471    ///   precision); extra digits are truncated.
472    /// - Oversized integer parts saturate instead of failing.
473    /// - Inputs longer than [`STRTIME_SIZE`](../consts/constant.STRTIME_SIZE.html) are rejected.
474    /// - Returns `None` only for completely unparseable input (empty, sign/dot
475    ///   only, no digits after skipping, etc.).
476    ///
477    /// ## Examples
478    ///
479    /// ```rust
480    /// use deep_time::{Dt, Scale};
481    ///
482    /// let d = Dt::from_str_sec_f("1700000000.123456789012345678", Some(Scale::TAI)).unwrap();
483    /// assert_eq!(d.to_sec64_floor(), 1700000000);
484    ///
485    /// // Leading junk is skipped
486    /// let d = Dt::from_str_sec_f("ts= -0.00123 suffix", Some(Scale::TAI)).unwrap();
487    /// assert!(d.to_attos() < 0);
488    ///
489    /// // Pure negative fraction
490    /// let d = Dt::from_str_sec_f("-.5", Some(Scale::TT)).unwrap();
491    /// assert!(d.to_attos() < 0);
492    ///
493    /// // Scale parsed from trailing abbreviation when passing None
494    /// let d = Dt::from_str_sec_f("42.75 GPS", None).unwrap();
495    /// assert_eq!(d.target, Scale::GPS);
496    ///
497    /// // 1 attosecond
498    /// let d = Dt::from_str_sec_f("0.000000000000000001", Some(Scale::TAI)).unwrap();
499    /// assert_eq!(d.to_attos() % 1_000_000_000_000_000_000, 1);
500    /// ```
501    #[inline]
502    pub fn from_str_sec_f(s: &str, scale: Option<Scale>) -> Option<Dt> {
503        Parts::from_str_sec_f(s, scale).and_then(|p| p.to_dt().ok())
504    }
505
506    /// Parses a decimal Julian Date string (with optional fractional part).
507    ///
508    /// The returned [`Dt`] is on the `TAI` time [`Scale`], having been converted
509    /// to `TAI` from whatever the **trailing** scale is, or if no scale is provided
510    /// then no conversion takes place.
511    ///
512    /// Leading junk is skipped the same way as [`Dt::from_str_sec_f`].
513    /// Fractional day precision up to 18 digits.
514    ///
515    /// Returns `None` for unparseable input.
516    ///
517    /// JD 2451545.0 is the library epoch (2000-01-01 noon).
518    ///
519    /// ## Examples
520    ///
521    /// ```rust
522    /// use deep_time::{Dt, Scale};
523    ///
524    /// let d = Dt::from_str_jd_f("2451545.0", Some(Scale::TAI)).unwrap();
525    /// assert_eq!(d.to_jd(), (2_451_545, 0));
526    ///
527    /// let d = Dt::from_str_jd_f("2451545.25 TT", None).unwrap();
528    /// assert_eq!(d.target, Scale::TT);
529    ///
530    /// let d = Dt::from_str_jd_f("2451544.5", Some(Scale::TAI)).unwrap();
531    /// assert!(d.to_attos() < 0);
532    /// ```
533    #[inline]
534    pub fn from_str_jd_f(s: &str, scale: Option<Scale>) -> Option<Dt> {
535        Parts::from_str_jd_f(s, scale).and_then(|p| p.to_dt().ok())
536    }
537
538    /// Parses a decimal Modified Julian Date string (with optional fractional part).
539    ///
540    /// The returned [`Dt`] is on the `TAI` time [`Scale`], having been converted
541    /// to `TAI` from whatever the **trailing** scale is, or if no scale is provided
542    /// then no conversion takes place.
543    ///
544    /// Leading junk is skipped the same way as [`Dt::from_str_sec_f`].
545    /// Fractional day precision up to 18 digits.
546    ///
547    /// Returns `None` for unparseable input.
548    ///
549    /// MJD 51544.5 is the library epoch (2000-01-01 noon).
550    ///
551    /// ## Examples
552    ///
553    /// ```rust
554    /// use deep_time::{Dt, Scale};
555    ///
556    /// let d = Dt::from_str_mjd_f("51544.5", Some(Scale::TAI)).unwrap();
557    /// assert_eq!(d.to_jd(), (2_451_545, 0));
558    ///
559    /// let d = Dt::from_str_mjd_f("51544.25 TT", None).unwrap();
560    /// assert_eq!(d.target, Scale::TT);
561    ///
562    /// let d = Dt::from_str_mjd_f("51543.5", Some(Scale::TAI)).unwrap();
563    /// assert!(d.to_attos() < 0);
564    /// ```
565    #[inline]
566    pub fn from_str_mjd_f(s: &str, scale: Option<Scale>) -> Option<Dt> {
567        Parts::from_str_mjd_f(s, scale).and_then(|p| p.to_dt().ok())
568    }
569
570    /// Parses an ISO 8601 duration string into a [`Dt`] representing a pure time interval.
571    ///
572    /// Supports the full `PnYnMnDTnHnMnS` format (case-insensitive), including:
573    /// - Optional leading `+` or `-` sign
574    /// - `P` / `p` prefix (required)
575    /// - Optional `T` / `t` separator between date and time parts
576    /// - Weeks (`W` / `w`)
577    /// - Fractional seconds with up to 9 digits of precision (nanosecond resolution;
578    ///   the parsed value is scaled to attosecond resolution in the resulting [`Dt`]).
579    ///
580    /// The returned [`Dt`] is a **duration** (signed interval) on the TAI scale.
581    /// It can be added to/subtracted from other `Dt` values, multiplied/divided,
582    /// rounded, etc.
583    ///
584    /// ## Not Reference-Time Aware
585    ///
586    /// This parser is **not reference-time aware**. Calendar units (`Y`, `M`) are
587    /// converted to a fixed number of seconds using standard average lengths
588    /// rather than being resolved against a specific date. This makes parsing
589    /// fast and allocation-free, but `P1M` always represents exactly the same
590    /// duration regardless of context.
591    ///
592    /// ## Parameters
593    ///
594    /// - `s`: The ISO 8601 duration string (e.g. `"P1Y2M3DT4H5M6.123456789012345678S"`,
595    ///   `"-PT30M"`, `"P7W"`, `"+P1DT12H"`).
596    ///
597    /// ## Errors
598    ///
599    /// Returns a [`DtErr`] if parsing fails. The error kind is available via
600    /// [`DtErr::kind()`].
601    ///
602    /// ### Input / structure errors
603    ///
604    /// - [`DtErrKind::Empty`] — The input string is empty.
605    /// - [`DtErrKind::MustStartWith`] — Missing `P` / `p` prefix (after optional leading sign).
606    /// - [`DtErrKind::InvalidSyntax`] — Invalid syntax, e.g. `T` with no following time part,
607    ///   or more than one `T`/`t` separator.
608    /// - [`DtErrKind::TrailingCharacters`] — Additional components appear after a fractional
609    ///   seconds value (only the final `S` component may carry a fraction).
610    ///
611    /// ### Component parsing errors
612    ///
613    /// - [`DtErrKind::ExpectedValue`] — Expected a numeric value for a component but found none.
614    /// - [`DtErrKind::ExpectedFractional`] — A `.` or `,` was present for a fractional part
615    ///   but no digits followed.
616    /// - [`DtErrKind::ExpectedUnit`] — A number was parsed but no unit designator
617    ///   (`Y`/`M`/`W`/`D`/`H`/`S` etc.) followed it.
618    /// - [`DtErrKind::InvalidNumber`] — A numeric component could not be parsed as an `i64`
619    ///   (typically too large).
620    /// - [`DtErrKind::InvalidBytes`] — Internal UTF-8 conversion failure while reading a number
621    ///   (should not occur for valid ASCII input).
622    /// - [`DtErrKind::InvalidFractional`] — The fractional part digits could not be parsed as an integer.
623    /// - [`DtErrKind::FracOutOfRange`] — More than 9 digits were supplied for fractional seconds.
624    /// - [`DtErrKind::InvalidItem`] — A fractional part was supplied on a unit other than seconds.
625    ///
626    /// ### Unit and range errors
627    ///
628    /// - [`DtErrKind::UnknownItem`] — An unknown unit designator character was used.
629    /// - [`DtErrKind::YearOutOfRange`], [`DtErrKind::MonthOutOfRange`],
630    ///   [`DtErrKind::WeekOutOfRange`], [`DtErrKind::DayOutOfRange`] — The component value
631    ///   (after sign) overflows when multiplied by the corresponding fixed-length constant
632    ///   (checked arithmetic).
633    pub fn from_iso_duration(s: &str) -> Result<Dt, DtErr> {
634        let len = s.len();
635        if len == 0 {
636            return Err(an_err!(DtErrKind::Empty));
637        }
638
639        let b = s.as_bytes();
640        let mut i = 0usize;
641
642        // Optional leading sign (+ or -)
643        let mut sign: i64 = 1;
644        if i < len && matches!(b[i], b'+' | b'-') {
645            if b[i] == b'-' {
646                sign = -1;
647            }
648            i += 1;
649        }
650
651        // Must start with P/p
652        if i >= len || !matches!(b[i], b'P' | b'p') {
653            return Err(an_err!(DtErrKind::MustStartWith));
654        }
655        i += 1;
656
657        // Find the (single) T/t separator
658        let t_pos = b[i..]
659            .iter()
660            .position(|&c| matches!(c, b'T' | b't'))
661            .map(|p| i + p);
662
663        let (date_part, time_part) = match t_pos {
664            Some(pos) => {
665                if pos == len - 1 {
666                    return Err(an_err!(DtErrKind::InvalidSyntax));
667                }
668                if b[pos + 1..].iter().any(|&c| matches!(c, b'T' | b't')) {
669                    return Err(an_err!(DtErrKind::InvalidSyntax));
670                }
671                (&b[i..pos], &b[pos + 1..])
672            }
673            None => (&b[i..], &[] as &[u8]),
674        };
675
676        let mut has_fraction = false;
677        let mut total_nanos: i128 = 0;
678
679        // Both date and time parts now use the same fixed-length logic
680        Self::parse_duration_part(date_part, &mut total_nanos, true, sign, &mut has_fraction)?;
681        Self::parse_duration_part(time_part, &mut total_nanos, false, sign, &mut has_fraction)?;
682
683        // Convert accumulated nanoseconds to attoseconds and build Dt
684        let total_attos = total_nanos * 1_000_000_000i128;
685        Ok(dt!(total_attos))
686    }
687
688    /// Parses a single component (number + optional fraction + unit) from the slice,
689    /// advancing the index `i`. Returns `None` when the slice is exhausted.
690    fn parse_next_component(
691        chars: &[u8],
692        i: &mut usize,
693        sign: i64,
694        has_fraction: &mut bool,
695    ) -> Result<Option<ParsedComponent>, DtErr> {
696        if *i >= chars.len() {
697            return Ok(None);
698        }
699
700        if *has_fraction {
701            return Err(an_err!(DtErrKind::TrailingCharacters));
702        }
703
704        // Parse integer part
705        let start = *i;
706        while *i < chars.len() && chars[*i].is_ascii_digit() {
707            *i += 1;
708        }
709        if start == *i {
710            return Err(an_err!(DtErrKind::ExpectedValue));
711        }
712
713        let int_str = core::str::from_utf8(&chars[start..*i])
714            .map_err(|e| an_err!(DtErrKind::InvalidBytes, "{}", e))?;
715        let int: i64 = int_str.parse().map_err(|e: core::num::ParseIntError| {
716            an_err!(DtErrKind::InvalidNumber, "{}: {}", int_str, e)
717        })?;
718
719        // Parse optional fraction
720        let mut frac_num: i64 = 0;
721        let mut frac_digits: usize = 0;
722        if *i < chars.len() && matches!(chars[*i], b'.' | b',') {
723            *i += 1;
724            let frac_start = *i;
725            while *i < chars.len() && chars[*i].is_ascii_digit() {
726                *i += 1;
727            }
728            frac_digits = *i - frac_start;
729            if frac_digits == 0 {
730                return Err(an_err!(DtErrKind::ExpectedFractional));
731            }
732            if frac_digits > 9 {
733                return Err(an_err!(DtErrKind::FracOutOfRange));
734            }
735
736            let frac_str = core::str::from_utf8(&chars[frac_start..*i])
737                .map_err(|e| an_err!(DtErrKind::InvalidBytes, "{}", e))?;
738            frac_num = frac_str.parse().map_err(|e: core::num::ParseIntError| {
739                an_err!(DtErrKind::InvalidFractional, "{}: {}", frac_str, e)
740            })?;
741        }
742
743        // Unit must follow
744        if *i >= chars.len() {
745            return Err(an_err!(DtErrKind::ExpectedUnit));
746        }
747        let unit = chars[*i];
748        *i += 1;
749
750        // Only seconds support a fractional part
751        if frac_digits > 0 {
752            if !matches!(unit, b'S' | b's') {
753                return Err(an_err!(DtErrKind::InvalidItem));
754            }
755            *has_fraction = true;
756        }
757
758        let signed_int = (int as i128 * sign as i128) as i64;
759
760        Ok(Some(ParsedComponent {
761            unit,
762            signed_int,
763            frac_digits,
764            frac_num,
765        }))
766    }
767
768    /// Helper that parses **one section** of an ISO duration (date or time part)
769    /// and accumulates nanoseconds into `total_nanos`.
770    ///
771    /// Years, months, weeks, and days are converted using the fixed-length
772    /// constants (the only sensible semantics for a pure `Dt`).
773    fn parse_duration_part(
774        chars: &[u8],
775        total_nanos: &mut i128,
776        is_date: bool,
777        sign: i64,
778        has_fraction: &mut bool,
779    ) -> Result<(), DtErr> {
780        let mut i = 0;
781        while let Some(comp) = Self::parse_next_component(chars, &mut i, sign, has_fraction)? {
782            let contrib_nanos = match (is_date, comp.unit) {
783                (true, b'Y' | b'y') => {
784                    let total_sec = (comp.signed_int as i128)
785                        .checked_mul(SEC_PER_YEAR)
786                        .ok_or_else(|| an_err!(DtErrKind::YearOutOfRange))?;
787                    total_sec * 1_000_000_000i128
788                }
789                (true, b'M' | b'm') => {
790                    let total_sec = (comp.signed_int as i128)
791                        .checked_mul(SEC_PER_MONTH)
792                        .ok_or_else(|| an_err!(DtErrKind::MonthOutOfRange))?;
793                    total_sec * 1_000_000_000i128
794                }
795                (true, b'W' | b'w') => {
796                    let total_sec = (comp.signed_int as i128)
797                        .checked_mul(SEC_PER_WEEK as i128)
798                        .ok_or_else(|| an_err!(DtErrKind::WeekOutOfRange))?;
799                    total_sec * 1_000_000_000i128
800                }
801                (true, b'D' | b'd') => {
802                    let total_sec = (comp.signed_int as i128)
803                        .checked_mul(SEC_PER_DAY)
804                        .ok_or_else(|| an_err!(DtErrKind::DayOutOfRange))?;
805                    total_sec * 1_000_000_000i128
806                }
807                (false, b'H' | b'h') => (comp.signed_int as i128) * 3_600_000_000_000i128,
808                (false, b'M' | b'm') => (comp.signed_int as i128) * 60_000_000_000i128,
809                (false, b'S' | b's') => {
810                    let mut sec_nanos = (comp.signed_int as i128) * 1_000_000_000i128;
811                    if comp.frac_digits > 0 {
812                        let frac_ns = (comp.frac_num as i128 * sign as i128 * 1_000_000_000i128)
813                            / 10i128.pow(comp.frac_digits as u32);
814                        sec_nanos += frac_ns;
815                    }
816                    sec_nanos
817                }
818                _ => {
819                    return Err(an_err!(DtErrKind::UnknownItem, "{}", comp.unit as char));
820                }
821            };
822
823            *total_nanos = total_nanos.saturating_add(contrib_nanos);
824        }
825        Ok(())
826    }
827
828    /// Parses a media-style duration string.
829    ///
830    /// Accepts formats like:
831    /// - `"0:45"`, `"9:41"`
832    /// - `"1:23:45"`
833    /// - `"1:07:54:30"`
834    /// - `"-1:23:45"`
835    ///
836    /// ## Errors
837    ///
838    /// Returns a [`DtErr`] if the input cannot be parsed as a valid media-style
839    /// duration. The error kind is available via [`DtErr::kind`].
840    ///
841    /// This function uses saturating arithmetic, so it never returns range or
842    /// overflow errors.
843    ///
844    /// ### Input / structure errors
845    ///
846    /// - [`DtErrKind::Empty`] — The string is empty or contains only ASCII whitespace.
847    /// - [`DtErrKind::InvalidInput`] — A single minus sign with nothing after it.
848    /// - [`DtErrKind::InvalidSyntax`] — The input does not contain exactly 2, 3, or 4
849    ///   colon-separated numeric components.
850    /// - [`DtErrKind::TrailingCharacters`] — Non-whitespace characters remain after
851    ///   the final numeric component.
852    ///
853    /// ### Parsing errors
854    ///
855    /// - [`DtErrKind::ExpectedValue`] — A component was expected to begin with a digit
856    ///   (either at the start of the string or immediately after a `:`) but did not.
857    ///
858    /// ## See also
859    ///
860    /// - [`Dt::to_str_media_duration`](../struct.Dt.html#method.to_str_media_duration)
861    /// - [`Dt::to_str_b_media_duration`](../struct.Dt.html#method.to_str_b_media_duration)
862    pub fn from_str_media_duration(input: &str) -> Result<Dt, DtErr> {
863        let bytes = input.as_bytes();
864        let len = bytes.len();
865        let mut pos: usize = 0;
866
867        // Skip leading whitespace
868        while pos < len && bytes[pos].is_ascii_whitespace() {
869            pos += 1;
870        }
871
872        if pos == len {
873            return Err(an_err!(DtErrKind::Empty));
874        }
875
876        // Optional single leading minus
877        let negative = if bytes[pos] == b'-' {
878            pos += 1;
879            if pos == len {
880                return Err(an_err!(DtErrKind::InvalidInput));
881            }
882            true
883        } else {
884            false
885        };
886
887        // Parse up to 4 numeric components separated by ':'
888        let mut components: [i128; 4] = [0; 4];
889        let mut count: usize = 0;
890
891        loop {
892            if count >= 4 {
893                break;
894            }
895
896            // Parse one number
897            if pos >= len || !bytes[pos].is_ascii_digit() {
898                return Err(an_err!(DtErrKind::ExpectedValue));
899            }
900
901            let mut value: i128 = 0;
902            while pos < len && bytes[pos].is_ascii_digit() {
903                value = value
904                    .saturating_mul(10)
905                    .saturating_add((bytes[pos] - b'0') as i128);
906                pos += 1;
907            }
908
909            components[count] = value;
910            count += 1;
911
912            // Check for more components
913            if pos >= len || bytes[pos] != b':' {
914                break;
915            }
916
917            pos += 1; // consume ':'
918
919            // Reject trailing ':' with no number after it
920            if pos >= len || !bytes[pos].is_ascii_digit() {
921                return Err(an_err!(DtErrKind::ExpectedValue));
922            }
923        }
924
925        if !(2..=4).contains(&count) {
926            return Err(an_err!(DtErrKind::InvalidSyntax));
927        }
928
929        // Skip trailing whitespace
930        while pos < len && bytes[pos].is_ascii_whitespace() {
931            pos += 1;
932        }
933
934        if pos != len {
935            return Err(an_err!(DtErrKind::TrailingCharacters));
936        }
937
938        // Convert to total seconds
939        let total_sec: i128 = match count {
940            2 => components[0] * 60 + components[1], // M:SS
941            3 => components[0] * 3600 + components[1] * 60 + components[2], // H:MM:SS
942            4 => components[0] * 86400 + components[1] * 3600 + components[2] * 60 + components[3], // D:H:MM:SS
943            _ => unreachable!(),
944        };
945
946        let total_sec = if negative { -total_sec } else { total_sec };
947        let attos = total_sec.saturating_mul(ATTOS_PER_SEC_I128);
948
949        Ok(dt!(attos))
950    }
951
952    /// Hours:Minutes elapsed time: `H:MM` or `H:MM:SS[.frac]` (hours unbounded).
953    ///
954    /// Minutes and seconds must be `0..=59`. Fractional seconds (optional) may
955    /// follow only the seconds field, up to 18 digits. Requires at least one
956    /// `:`. Optional leading `-` and ASCII whitespace. Returns a pure duration
957    /// on `Scale::TAI`.
958    ///
959    /// Not media duration: two fields are **hours:minutes**, not minutes:seconds.
960    #[inline]
961    pub fn from_str_h_mm_duration(input: &str) -> Result<Dt, DtErr> {
962        Ok(dt!(Self::h_mm_duration_attos(input)?))
963    }
964
965    /// Shared by [`from_str_h_mm_duration`] and relative natural parse.
966    pub(crate) fn h_mm_duration_attos(input: &str) -> Result<i128, DtErr> {
967        let bytes = input.as_bytes();
968        let len = bytes.len();
969        let mut pos = 0usize;
970
971        while pos < len && bytes[pos].is_ascii_whitespace() {
972            pos += 1;
973        }
974        if pos == len {
975            return Err(an_err!(DtErrKind::Empty));
976        }
977
978        let neg = if bytes[pos] == b'-' {
979            pos += 1;
980            if pos == len {
981                return Err(an_err!(DtErrKind::InvalidInput));
982            }
983            true
984        } else {
985            false
986        };
987
988        let hours = parse_uint(bytes, &mut pos, len)?;
989        // At least one colon is required (H:MM or H:MM:SS).
990        if pos >= len || bytes[pos] != b':' {
991            return Err(an_err!(DtErrKind::InvalidSyntax));
992        }
993        pos += 1;
994
995        let minutes = parse_uint(bytes, &mut pos, len)?;
996        if minutes > 59 {
997            return Err(an_err!(DtErrKind::InvalidInput));
998        }
999
1000        let mut seconds: i128 = 0;
1001        let mut frac_attos: i128 = 0;
1002
1003        if pos < len && bytes[pos] == b':' {
1004            pos += 1;
1005            seconds = parse_uint(bytes, &mut pos, len)?;
1006            if seconds > 59 {
1007                return Err(an_err!(DtErrKind::InvalidInput));
1008            }
1009            if pos < len && bytes[pos] == b'.' {
1010                pos += 1;
1011                if pos >= len || !bytes[pos].is_ascii_digit() {
1012                    return Err(an_err!(DtErrKind::ExpectedValue));
1013                }
1014                let mut digits = 0u32;
1015                let mut frac = 0i128;
1016                while pos < len && bytes[pos].is_ascii_digit() {
1017                    if digits < 18 {
1018                        frac = frac
1019                            .saturating_mul(10)
1020                            .saturating_add((bytes[pos] - b'0') as i128);
1021                        digits += 1;
1022                    }
1023                    pos += 1;
1024                }
1025                frac_attos = frac.saturating_mul(10i128.pow(18 - digits));
1026            }
1027        } else if pos < len && bytes[pos] == b'.' {
1028            // No fractional minutes.
1029            return Err(an_err!(DtErrKind::InvalidSyntax));
1030        }
1031
1032        while pos < len && bytes[pos].is_ascii_whitespace() {
1033            pos += 1;
1034        }
1035        if pos != len {
1036            return Err(an_err!(DtErrKind::TrailingCharacters));
1037        }
1038
1039        let mut attos = hours
1040            .saturating_mul(3600)
1041            .saturating_add(minutes.saturating_mul(60))
1042            .saturating_add(seconds)
1043            .saturating_mul(ATTOS_PER_SEC_I128)
1044            .saturating_add(frac_attos);
1045        if neg {
1046            attos = -attos;
1047        }
1048        Ok(attos)
1049    }
1050}
1051
1052fn parse_uint(bytes: &[u8], pos: &mut usize, len: usize) -> Result<i128, DtErr> {
1053    if *pos >= len || !bytes[*pos].is_ascii_digit() {
1054        return Err(an_err!(DtErrKind::ExpectedValue));
1055    }
1056    let mut v: i128 = 0;
1057    while *pos < len && bytes[*pos].is_ascii_digit() {
1058        v = v
1059            .saturating_mul(10)
1060            .saturating_add((bytes[*pos] - b'0') as i128);
1061        *pos += 1;
1062    }
1063    Ok(v)
1064}