Skip to main content

deep_time/dt/
from_str.rs

1use crate::{
2    Dt, DtErr, DtErrKind, SEC_PER_DAY, SEC_PER_MONTH, SEC_PER_WEEK, SEC_PER_YEAR, Scale,
3    StrPTimeFmt, TimeParts, an_err,
4};
5use core::str::FromStr;
6
7#[cfg(feature = "parse")]
8impl FromStr for Dt {
9    type Err = DtErr;
10
11    #[inline]
12    fn from_str(s: &str) -> Result<Self, DtErr> {
13        Dt::from_str_parse(s, &None)
14    }
15}
16
17#[cfg(not(feature = "parse"))]
18impl FromStr for Dt {
19    type Err = DtErr;
20
21    #[inline]
22    fn from_str(s: &str) -> Result<Self, DtErr> {
23        Self::from_str_ccsds(s)
24    }
25}
26
27struct ParsedComponent {
28    unit: u8,
29    signed_int: i64,
30    frac_digits: usize,
31    frac_num: i64,
32}
33
34impl Dt {
35    /// Parses a date/time string.
36    ///
37    /// - When the `parse` feature is enabled: uses the smart auto-parser.
38    /// - When the `parse` feature is disabled: falls back to CCSDS format.
39    ///
40    /// ## Examples
41    ///
42    /// ```
43    /// use deep_time::{Dt, Scale};
44    ///
45    /// // uses impl FromStr but Dt::parse provides the same functionality
46    /// let x: Dt = "2000-01-01 12:00:00".parse().unwrap();
47    ///
48    /// let ymd = x.to_ymdhms(Scale::TAI);
49    /// assert_eq!(ymd.yr(), 2000);
50    /// assert_eq!(ymd.mo(), 1);
51    /// assert_eq!(ymd.day(), 1);
52    /// assert_eq!(ymd.hr(), 12);
53    /// assert_eq!(ymd.min(), 0);
54    /// assert_eq!(ymd.sec(), 0);
55    /// assert_eq!(ymd.attos(), 0);
56    /// ```
57    ///
58    /// ## See also
59    ///
60    /// - [`Dt::from_str_parse`](../struct.Dt.html#method.from_str_parse)
61    /// - [`Dt::from_str_ccsds`](../struct.Dt.html#method.from_str_ccsds)
62    #[inline]
63    pub fn parse(s: &str) -> Result<Self, DtErr> {
64        #[cfg(feature = "parse")]
65        {
66            Self::from_str_parse(s, &None)
67        }
68        #[cfg(not(feature = "parse"))]
69        {
70            Self::from_str_ccsds(s)
71        }
72    }
73
74    /// High-level parser equivalent to C `strptime` (and Python `strptime`).
75    ///
76    /// Parses the input string `s` according to the supplied format string `fmt`
77    /// and returns a [`Dt`] directly. This is a convenience wrapper around
78    /// [`TimeParts::from_str`](../struct.TimeParts.html#method.from_str)
79    /// followed by [`TimeParts::to_dt`](../struct.TimeParts.html#method.to_dt).
80    ///
81    /// It supports the same set of `%` directives as the low-level parser, pretty
82    /// much the same as jiff.
83    ///
84    /// ## Parameters
85    ///
86    /// - `s`: The date/time string to parse.
87    /// - `fmt`: The format string containing `%` directives (must be valid ASCII).
88    /// - `inp_can_end_before_fmt`: If `true`, the input may end before the format
89    ///   string is fully consumed (extra format specifiers are ignored).
90    /// - `fmt_can_end_before_inp`: If `true`, the format may end before the input
91    ///   is fully consumed (trailing characters in the input are allowed).
92    /// - `allow_partial_date`: If `true`, a missing month/day will be defaulted
93    ///   to `1` instead of returning an [`Incomplete`] error.
94    ///
95    /// ## Errors
96    ///
97    /// Returns [`DtErr`] for:
98    /// - Parse failures (`InvalidFormat`, `OutOfRange`, `UnknownDirective`, etc.)
99    /// - Incomplete data when `allow_partial_date` is `false`
100    /// - Trailing characters (when `fmt_can_end_before_inp` is `false`)
101    ///
102    /// See [`TimeParts::from_str`] for the complete list of supported directives
103    /// and detailed parsing semantics.
104    #[inline]
105    pub fn from_str(
106        s: &str,
107        fmt: &str,
108        inp_can_end_before_fmt: bool,
109        fmt_can_end_before_inp: bool,
110        allow_partial_date: bool,
111    ) -> Result<Dt, DtErr> {
112        TimeParts::from_str(
113            fmt,
114            s,
115            inp_can_end_before_fmt,
116            fmt_can_end_before_inp,
117            allow_partial_date,
118        )?
119        .to_dt()
120    }
121
122    /// Parses and validates a `strptime`-style format string into a reusable [`StrPTimeFmt`].
123    ///
124    /// The format is checked once for syntax errors and unsupported directives,
125    /// then stored in a compact fixed-size buffer. The resulting `StrPTimeFmt` is
126    /// `Copy`, cheap to clone, and can be used repeatedly with [`StrPTimeFmt::to_dt`]
127    /// and [`StrPTimeFmt::to_str`] without re-validating.
128    ///
129    /// Only ASCII formats up to 256 bytes are accepted.
130    ///
131    /// ## Parameters
132    ///
133    /// - `strptime_fmt`: The format string using `%` directives (e.g. `"%Y-%m-%d %H:%M:%S"`,
134    ///   `"%F %T"`, `"%Y-%m-%dT%H:%M:%S%.3fZ"`).
135    ///
136    /// ## Errors
137    ///
138    /// Returns [`DtErr`] if the format is:
139    /// - Longer than 256 bytes
140    /// - Not valid ASCII
141    /// - Contains unknown, unsupported, or malformed directives
142    #[inline]
143    pub fn parse_fmt(strptime_fmt: &str) -> Result<StrPTimeFmt, DtErr> {
144        StrPTimeFmt::new(strptime_fmt)
145    }
146
147    /// Parses an ISO 8601 duration string into a [`Dt`] representing a pure time interval.
148    ///
149    /// Supports the full `PnYnMnDTnHnMnS` format (case-insensitive), including:
150    /// - Optional leading `+` or `-` sign
151    /// - `P` / `p` prefix (required)
152    /// - Optional `T` / `t` separator between date and time parts
153    /// - Weeks (`W` / `w`)
154    /// - Fractional seconds with up to 18 digits of precision (attosecond resolution)
155    ///
156    /// The returned [`Dt`] is a **duration** (signed interval) on the TAI scale.
157    /// It can be added to/subtracted from other `Dt` values, multiplied/divided,
158    /// rounded, etc.
159    ///
160    /// ## Not Reference-Time Aware
161    ///
162    /// This parser is **not reference-time aware**. Calendar units (`Y`, `M`) are
163    /// converted to a fixed number of seconds using standard average lengths
164    /// rather than being resolved against a specific date. This makes parsing
165    /// fast and allocation-free, but `P1M` always represents exactly the same
166    /// duration regardless of context.
167    ///
168    /// ## Parameters
169    ///
170    /// - `s`: The ISO 8601 duration string (e.g. `"P1Y2M3DT4H5M6.123456789012345678S"`,
171    ///   `"-PT30M"`, `"P7W"`, `"+P1DT12H"`).
172    ///
173    /// ## Errors
174    ///
175    /// Returns [`DtErr`] for:
176    /// - Empty string
177    /// - Missing `P` prefix
178    /// - Invalid syntax (`T` with no time part, multiple `T`s, etc.)
179    /// - Unknown unit designators
180    /// - Numeric values that are out of range or cause overflow
181    pub fn from_iso_duration(s: &str) -> Result<Dt, DtErr> {
182        let len = s.len();
183        if len == 0 {
184            return Err(an_err!(DtErrKind::Incomplete, "empty"));
185        }
186
187        let b = s.as_bytes();
188        let mut i = 0usize;
189
190        // Optional leading sign (+ or -)
191        let mut sign: i64 = 1;
192        if i < len && matches!(b[i], b'+' | b'-') {
193            if b[i] == b'-' {
194                sign = -1;
195            }
196            i += 1;
197        }
198
199        // Must start with P/p
200        if i >= len || !matches!(b[i], b'P' | b'p') {
201            return Err(an_err!(DtErrKind::MustStartWith, "P"));
202        }
203        i += 1;
204
205        // Find the (single) T/t separator
206        let t_pos = b[i..]
207            .iter()
208            .position(|&c| matches!(c, b'T' | b't'))
209            .map(|p| i + p);
210
211        let (date_part, time_part) = match t_pos {
212            Some(pos) => {
213                if pos == len - 1 {
214                    return Err(an_err!(DtErrKind::InvalidSyntax, "T with no time"));
215                }
216                if b[pos + 1..].iter().any(|&c| matches!(c, b'T' | b't')) {
217                    return Err(an_err!(DtErrKind::InvalidSyntax, "multiple T"));
218                }
219                (&b[i..pos], &b[pos + 1..])
220            }
221            None => (&b[i..], &[] as &[u8]),
222        };
223
224        let mut has_fraction = false;
225        let mut total_nanos: i128 = 0;
226
227        // Both date and time parts now use the same fixed-length logic
228        Self::parse_duration_part(date_part, &mut total_nanos, true, sign, &mut has_fraction)?;
229        Self::parse_duration_part(time_part, &mut total_nanos, false, sign, &mut has_fraction)?;
230
231        // Convert accumulated nanoseconds to attoseconds and build Dt
232        let total_attos = total_nanos * 1_000_000_000i128;
233        Ok(Dt::from_attos(total_attos, Scale::TAI))
234    }
235
236    /// Parses a single component (number + optional fraction + unit) from the slice,
237    /// advancing the index `i`. Returns `None` when the slice is exhausted.
238    fn parse_next_component(
239        chars: &[u8],
240        i: &mut usize,
241        sign: i64,
242        has_fraction: &mut bool,
243    ) -> Result<Option<ParsedComponent>, DtErr> {
244        if *i >= chars.len() {
245            return Ok(None);
246        }
247
248        if *has_fraction {
249            return Err(an_err!(DtErrKind::InvalidSyntax, "components after frac"));
250        }
251
252        // Parse integer part
253        let start = *i;
254        while *i < chars.len() && chars[*i].is_ascii_digit() {
255            *i += 1;
256        }
257        if start == *i {
258            return Err(an_err!(DtErrKind::ExpectedValue, "number"));
259        }
260
261        let int_str = core::str::from_utf8(&chars[start..*i])
262            .map_err(|_| an_err!(DtErrKind::InvalidNumber, "invalid utf8 in int"))?;
263        let int: i64 = int_str.parse().map_err(|e: core::num::ParseIntError| {
264            an_err!(DtErrKind::InvalidNumber, "{}: {}", int_str, e)
265        })?;
266
267        // Parse optional fraction
268        let mut frac_num: i64 = 0;
269        let mut frac_digits: usize = 0;
270        if *i < chars.len() && matches!(chars[*i], b'.' | b',') {
271            *i += 1;
272            let frac_start = *i;
273            while *i < chars.len() && chars[*i].is_ascii_digit() {
274                *i += 1;
275            }
276            frac_digits = *i - frac_start;
277            if frac_digits == 0 {
278                return Err(an_err!(DtErrKind::ExpectedValue, "empty frac after ."));
279            }
280            if frac_digits > 9 {
281                return Err(an_err!(DtErrKind::OutOfRange, "frac >9"));
282            }
283
284            let frac_str = core::str::from_utf8(&chars[frac_start..*i])
285                .map_err(|_| an_err!(DtErrKind::InvalidNumber, "invalid utf8 in frac"))?;
286            frac_num = frac_str.parse().map_err(|e: core::num::ParseIntError| {
287                an_err!(DtErrKind::InvalidNumber, "{}: {}", frac_str, e)
288            })?;
289        }
290
291        // Unit must follow
292        if *i >= chars.len() {
293            return Err(an_err!(
294                DtErrKind::InvalidSyntax,
295                "missing unit after number"
296            ));
297        }
298        let unit = chars[*i];
299        *i += 1;
300
301        // Only seconds support a fractional part
302        if frac_digits > 0 {
303            if !matches!(unit, b'S' | b's') {
304                return Err(an_err!(
305                    DtErrKind::InvalidSyntax,
306                    "frac only supported for seconds"
307                ));
308            }
309            *has_fraction = true;
310        }
311
312        let signed_int = (int as i128 * sign as i128) as i64;
313
314        Ok(Some(ParsedComponent {
315            unit,
316            signed_int,
317            frac_digits,
318            frac_num,
319        }))
320    }
321
322    /// Helper that parses **one section** of an ISO duration (date or time part)
323    /// and accumulates nanoseconds into `total_nanos`.
324    ///
325    /// Years, months, weeks, and days are converted using the fixed-length
326    /// constants (the only sensible semantics for a pure `Dt`).
327    fn parse_duration_part(
328        chars: &[u8],
329        total_nanos: &mut i128,
330        is_date: bool,
331        sign: i64,
332        has_fraction: &mut bool,
333    ) -> Result<(), DtErr> {
334        let mut i = 0;
335        while let Some(comp) = Self::parse_next_component(chars, &mut i, sign, has_fraction)? {
336            let contrib_nanos = match (is_date, comp.unit) {
337                (true, b'Y' | b'y') => {
338                    let total_secs = (comp.signed_int as i128)
339                        .checked_mul(SEC_PER_YEAR)
340                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "year"))?;
341                    total_secs * 1_000_000_000i128
342                }
343                (true, b'M' | b'm') => {
344                    let total_secs = (comp.signed_int as i128)
345                        .checked_mul(SEC_PER_MONTH)
346                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "month"))?;
347                    total_secs * 1_000_000_000i128
348                }
349                (true, b'W' | b'w') => {
350                    let total_secs = (comp.signed_int as i128)
351                        .checked_mul(SEC_PER_WEEK as i128)
352                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "week"))?;
353                    total_secs * 1_000_000_000i128
354                }
355                (true, b'D' | b'd') => {
356                    let total_secs = (comp.signed_int as i128)
357                        .checked_mul(SEC_PER_DAY)
358                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "day"))?;
359                    total_secs * 1_000_000_000i128
360                }
361                (false, b'H' | b'h') => (comp.signed_int as i128) * 3_600_000_000_000i128,
362                (false, b'M' | b'm') => (comp.signed_int as i128) * 60_000_000_000i128,
363                (false, b'S' | b's') => {
364                    let mut sec_nanos = (comp.signed_int as i128) * 1_000_000_000i128;
365                    if comp.frac_digits > 0 {
366                        let frac_ns = (comp.frac_num as i128 * sign as i128 * 1_000_000_000i128)
367                            / 10i128.pow(comp.frac_digits as u32);
368                        sec_nanos += frac_ns;
369                    }
370                    sec_nanos
371                }
372                _ => {
373                    return Err(an_err!(DtErrKind::InvalidItem, "{}", comp.unit as char));
374                }
375            };
376
377            *total_nanos = total_nanos.saturating_add(contrib_nanos);
378        }
379        Ok(())
380    }
381
382    /// Accepts: `P1Y`, `-P2W`, `PT1.5H`, `P1DT2H30M`, `+P3D`, `p1y`, `P1,5S`, `PT0S`, etc.
383    /// Rejects: anything with whitespace, lone "P"/"-P"/"PT", "P123", "Please wait 5m",
384    ///          "1.5h", "P1Yabc", "P1Y!", or **any string longer than 128 bytes**.
385    pub fn looks_like_iso(s: &str) -> bool {
386        let len = s.len();
387        if matches!(len, 0 | 1) {
388            return false;
389        }
390        let b = s.as_bytes();
391        let mut i = 0usize;
392        // Optional leading sign
393        if matches!(b[0], b'+' | b'-') {
394            i += 1;
395        }
396        // Must start with P/p after optional sign
397        if !matches!(b[i], b'P' | b'p') {
398            return false;
399        }
400        i += 1;
401        let mut has_digit = false;
402        let mut has_designator = false;
403        while i < len {
404            match b[i] {
405                b'0'..=b'9' => has_digit = true,
406                b'.' | b',' => {} // decimal separators allowed by ISO 8601
407                b'Y' | b'y' | b'M' | b'm' | b'W' | b'w' | b'D' | b'd' | b'T' | b't' | b'H'
408                | b'h' | b'S' | b's' => {
409                    has_designator = true;
410                }
411                _ => return false, // any other character = not ISO
412            }
413
414            i += 1;
415        }
416        // Must contain at least one digit *and* one designator after the initial P
417        has_digit && has_designator
418    }
419}