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}