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}