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}