Skip to main content

deep_time/dt/
from_str.rs

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