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