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