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