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, TimeParts,
3    an_err, parser::Parser,
4};
5
6struct ParsedComponent {
7    unit: u8,
8    signed_int: i64,
9    frac_digits: usize,
10    frac_num: i64,
11}
12
13const MAX_FORMAT_LEN: usize = 256;
14
15/// A pre-validated, reusable date/time format string.
16///
17/// - Format is validated **once** at construction (`new` returns `Result`).
18/// - Format bytes are copied into an owned fixed-size buffer.
19/// - Only ASCII formats are accepted.
20#[derive(Debug, Clone, Copy)]
21pub struct StrPTimeFmt {
22    fmt: [u8; MAX_FORMAT_LEN],
23    len: usize,
24}
25
26impl StrPTimeFmt {
27    /// Creates a new validated format.
28    ///
29    /// - Validates syntax and supported directives.
30    /// - Requires the format to be valid ASCII and ≤ 256 bytes.
31    /// - Returns a proper `DtErr` on any failure.
32    pub fn new(fmt: &str) -> Result<Self, DtErr> {
33        if fmt.len() > MAX_FORMAT_LEN {
34            return Err(an_err!(
35                DtErrKind::UnexpectedEnd,
36                "format string too long (max {} bytes)",
37                MAX_FORMAT_LEN
38            ));
39        }
40        let fmt = fmt.as_bytes();
41        if !fmt.is_ascii() {
42            return Err(an_err!(
43                DtErrKind::UnexpectedEnd,
44                "format string must be ASCII"
45            ));
46        }
47
48        Self::validate_format(fmt)?;
49
50        let mut buffer = [0u8; MAX_FORMAT_LEN];
51        buffer[..fmt.len()].copy_from_slice(fmt);
52
53        Ok(Self {
54            fmt: buffer,
55            len: fmt.len(),
56        })
57    }
58
59    fn validate_format(mut fmt: &[u8]) -> Result<(), DtErr> {
60        while !fmt.is_empty() {
61            if fmt[0] != b'%' {
62                // literal character (including whitespace) — always valid
63                fmt = &fmt[1..];
64                continue;
65            }
66
67            // lone % at end of format
68            if fmt.len() == 1 {
69                return Err(an_err!(DtErrKind::UnexpectedEnd, "after %"));
70            }
71            fmt = &fmt[1..]; // eat %
72
73            // reuse existing helper for flags/width/colons
74            let (_, _, _, new_fmt) = Parser::parse_format_extensions(fmt, 0);
75            fmt = new_fmt;
76
77            if fmt.is_empty() {
78                return Err(an_err!(DtErrKind::UnexpectedEnd, "expected directive"));
79            }
80
81            let directive = fmt[0];
82
83            match directive {
84            // all currently supported directives (exact list from Parser::parse)
85            b'%' | b'A' | b'a' | b'B' | b'b' | b'h' | b'C' | b'd' | b'e' |
86            b'f' | b'N' | b'G' | b'g' | b'H' | b'k' | b'I' | b'l' | b'j' |
87            b'M' | b'm' | b'n' | b't' | b'P' | b'p' | b'Q' | b'S' | b's' |
88            b'U' | b'u' | b'V' | b'W' | b'w' | b'Y' | b'y' | b'z' |
89            // shortcuts
90            b'F' | b'D' | b'T' | b'R' |
91            // library directives
92            b'*' => {
93                fmt = &fmt[1..];
94            }
95
96            b'.' => {
97                // special case for %.f / %.3N etc.
98                fmt = &fmt[1..]; // eat the .
99
100                // optional width digits
101                while !fmt.is_empty() && fmt[0].is_ascii_digit() {
102                    fmt = &fmt[1..];
103                }
104
105                let next = fmt.get(0).copied().unwrap_or(0);
106                if !matches!(next, b'f' | b'N') {
107                    return Err(an_err!(DtErrKind::BadFractional, "{}", char::from(next)));
108                }
109                fmt = &fmt[1..];
110            }
111
112            // explicitly unsupported (same as Parser)
113            b'c' | b'r' | b'X' | b'x' | b'Z' => {
114                return Err(an_err!(
115                    DtErrKind::UnsupportedDirective,
116                    "{}",
117                    char::from(directive)
118                ));
119            }
120
121            _ => {
122                return Err(an_err!(DtErrKind::UnknownDirective));
123            }
124        }
125        }
126
127        Ok(())
128    }
129
130    #[inline]
131    fn as_bytes(&self) -> &[u8] {
132        &self.fmt[..self.len]
133    }
134
135    #[inline]
136    fn as_str(&self) -> Result<&str, DtErr> {
137        match core::str::from_utf8(self.as_bytes()) {
138            Ok(f) => Ok(f),
139            Err(e) => Err(an_err!(DtErrKind::InvalidBytes, "{}", e)),
140        }
141    }
142
143    /// Parse a date str using this pre-validated format.
144    pub fn to_dt(
145        &self,
146        s: &str,
147        inp_can_end_before_fmt: bool,
148        fmt_can_end_before_inp: bool,
149        allow_partial_date: bool,
150    ) -> Result<Dt, DtErr> {
151        TimeParts::from_str(
152            self.as_str()?,
153            s,
154            inp_can_end_before_fmt,
155            fmt_can_end_before_inp,
156            allow_partial_date,
157        )
158        .and_then(|p| p.to_time_point())
159    }
160
161    #[cfg(feature = "alloc")]
162    pub fn to_str(
163        &self,
164        current: Scale,
165        s: &str,
166        inp_can_end_before_fmt: bool,
167        fmt_can_end_before_inp: bool,
168        allow_partial_date: bool,
169    ) -> Result<alloc::string::String, DtErr> {
170        self.to_dt(
171            s,
172            inp_can_end_before_fmt,
173            fmt_can_end_before_inp,
174            allow_partial_date,
175        )?
176        .to_str(current, self.as_str()?)
177    }
178}
179
180impl Dt {
181    #[inline]
182    pub fn from_str(
183        s: &str,
184        fmt: &str,
185        inp_can_end_before_fmt: bool,
186        fmt_can_end_before_inp: bool,
187        allow_partial_date: bool,
188    ) -> Result<Dt, DtErr> {
189        Ok(TimeParts::from_str(
190            fmt,
191            s,
192            inp_can_end_before_fmt,
193            fmt_can_end_before_inp,
194            allow_partial_date,
195        )?
196        .to_time_point()?)
197    }
198
199    #[inline]
200    pub fn parse_fmt(strptime_fmt: &str) -> Result<StrPTimeFmt, DtErr> {
201        StrPTimeFmt::new(strptime_fmt)
202    }
203
204    pub fn from_iso(s: &str) -> Result<Dt, DtErr> {
205        let len = s.len();
206        if len == 0 {
207            return Err(an_err!(DtErrKind::Incomplete, "empty"));
208        }
209
210        let b = s.as_bytes();
211        let mut i = 0usize;
212
213        // Optional leading sign (+ or -)
214        let mut sign: i64 = 1;
215        if i < len && matches!(b[i], b'+' | b'-') {
216            if b[i] == b'-' {
217                sign = -1;
218            }
219            i += 1;
220        }
221
222        // Must start with P/p
223        if i >= len || !matches!(b[i], b'P' | b'p') {
224            return Err(an_err!(DtErrKind::MustStartWith, "P"));
225        }
226        i += 1;
227
228        // Find the (single) T/t separator
229        let t_pos = b[i..]
230            .iter()
231            .position(|&c| matches!(c, b'T' | b't'))
232            .map(|p| i + p);
233
234        let (date_part, time_part) = match t_pos {
235            Some(pos) => {
236                if pos == len - 1 {
237                    return Err(an_err!(DtErrKind::InvalidSyntax, "T with no time"));
238                }
239                if b[pos + 1..].iter().any(|&c| matches!(c, b'T' | b't')) {
240                    return Err(an_err!(DtErrKind::InvalidSyntax, "multiple T"));
241                }
242                (&b[i..pos], &b[pos + 1..])
243            }
244            None => (&b[i..], &[] as &[u8]),
245        };
246
247        let mut has_fraction = false;
248        let mut total_nanos: i128 = 0;
249
250        // Both date and time parts now use the same fixed-length logic
251        Self::parse_duration_part(date_part, &mut total_nanos, true, sign, &mut has_fraction)?;
252        Self::parse_duration_part(time_part, &mut total_nanos, false, sign, &mut has_fraction)?;
253
254        // Convert accumulated nanoseconds to attoseconds and build Dt
255        let total_attos = total_nanos * 1_000_000_000i128;
256        Ok(Dt::from_attos(total_attos, Scale::TAI))
257    }
258
259    /// Parses a single component (number + optional fraction + unit) from the slice,
260    /// advancing the index `i`. Returns `None` when the slice is exhausted.
261    fn parse_next_component(
262        chars: &[u8],
263        i: &mut usize,
264        sign: i64,
265        has_fraction: &mut bool,
266    ) -> Result<Option<ParsedComponent>, DtErr> {
267        if *i >= chars.len() {
268            return Ok(None);
269        }
270
271        if *has_fraction {
272            return Err(an_err!(DtErrKind::InvalidSyntax, "components after frac"));
273        }
274
275        // Parse integer part
276        let start = *i;
277        while *i < chars.len() && chars[*i].is_ascii_digit() {
278            *i += 1;
279        }
280        if start == *i {
281            return Err(an_err!(DtErrKind::ExpectedValue, "number"));
282        }
283
284        let int_str = core::str::from_utf8(&chars[start..*i])
285            .map_err(|_| an_err!(DtErrKind::InvalidNumber, "invalid utf8 in int"))?;
286        let int: i64 = int_str.parse().map_err(|e: core::num::ParseIntError| {
287            an_err!(DtErrKind::InvalidNumber, "{}: {}", int_str, e)
288        })?;
289
290        // Parse optional fraction
291        let mut frac_num: i64 = 0;
292        let mut frac_digits: usize = 0;
293        if *i < chars.len() && matches!(chars[*i], b'.' | b',') {
294            *i += 1;
295            let frac_start = *i;
296            while *i < chars.len() && chars[*i].is_ascii_digit() {
297                *i += 1;
298            }
299            frac_digits = *i - frac_start;
300            if frac_digits == 0 {
301                return Err(an_err!(DtErrKind::ExpectedValue, "empty frac after ."));
302            }
303            if frac_digits > 9 {
304                return Err(an_err!(DtErrKind::OutOfRange, "frac >9"));
305            }
306
307            let frac_str = core::str::from_utf8(&chars[frac_start..*i])
308                .map_err(|_| an_err!(DtErrKind::InvalidNumber, "invalid utf8 in frac"))?;
309            frac_num = frac_str.parse().map_err(|e: core::num::ParseIntError| {
310                an_err!(DtErrKind::InvalidNumber, "{}: {}", frac_str, e)
311            })?;
312        }
313
314        // Unit must follow
315        if *i >= chars.len() {
316            return Err(an_err!(
317                DtErrKind::InvalidSyntax,
318                "missing unit after number"
319            ));
320        }
321        let unit = chars[*i];
322        *i += 1;
323
324        // Only seconds support a fractional part
325        if frac_digits > 0 {
326            if !matches!(unit, b'S' | b's') {
327                return Err(an_err!(
328                    DtErrKind::InvalidSyntax,
329                    "frac only supported for seconds"
330                ));
331            }
332            *has_fraction = true;
333        }
334
335        let signed_int = (int as i128 * sign as i128) as i64;
336
337        Ok(Some(ParsedComponent {
338            unit,
339            signed_int,
340            frac_digits,
341            frac_num,
342        }))
343    }
344
345    /// Helper that parses **one section** of an ISO duration (date or time part)
346    /// and accumulates nanoseconds into `total_nanos`.
347    ///
348    /// Years, months, weeks, and days are converted using the fixed-length
349    /// constants (the only sensible semantics for a pure `Dt`).
350    fn parse_duration_part(
351        chars: &[u8],
352        total_nanos: &mut i128,
353        is_date: bool,
354        sign: i64,
355        has_fraction: &mut bool,
356    ) -> Result<(), DtErr> {
357        let mut i = 0;
358        while let Some(comp) = Self::parse_next_component(chars, &mut i, sign, has_fraction)? {
359            let contrib_nanos = match (is_date, comp.unit) {
360                (true, b'Y' | b'y') => {
361                    let total_secs = (comp.signed_int as i128)
362                        .checked_mul(SEC_PER_YEAR)
363                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "year"))?;
364                    total_secs * 1_000_000_000i128
365                }
366                (true, b'M' | b'm') => {
367                    let total_secs = (comp.signed_int as i128)
368                        .checked_mul(SEC_PER_MONTH)
369                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "month"))?;
370                    total_secs * 1_000_000_000i128
371                }
372                (true, b'W' | b'w') => {
373                    let total_secs = (comp.signed_int as i128)
374                        .checked_mul(SEC_PER_WEEK as i128)
375                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "week"))?;
376                    total_secs * 1_000_000_000i128
377                }
378                (true, b'D' | b'd') => {
379                    let total_secs = (comp.signed_int as i128)
380                        .checked_mul(SEC_PER_DAY)
381                        .ok_or_else(|| an_err!(DtErrKind::OutOfRange, "day"))?;
382                    total_secs * 1_000_000_000i128
383                }
384                (false, b'H' | b'h') => (comp.signed_int as i128) * 3_600_000_000_000i128,
385                (false, b'M' | b'm') => (comp.signed_int as i128) * 60_000_000_000i128,
386                (false, b'S' | b's') => {
387                    let mut sec_nanos = (comp.signed_int as i128) * 1_000_000_000i128;
388                    if comp.frac_digits > 0 {
389                        let frac_ns = (comp.frac_num as i128 * sign as i128 * 1_000_000_000i128)
390                            / 10i128.pow(comp.frac_digits as u32);
391                        sec_nanos += frac_ns;
392                    }
393                    sec_nanos
394                }
395                _ => {
396                    return Err(an_err!(DtErrKind::InvalidItem, "{}", comp.unit as char));
397                }
398            };
399
400            *total_nanos = total_nanos.saturating_add(contrib_nanos);
401        }
402        Ok(())
403    }
404
405    /// Accepts: `P1Y`, `-P2W`, `PT1.5H`, `P1DT2H30M`, `+P3D`, `p1y`, `P1,5S`, `PT0S`, etc.
406    /// Rejects: anything with whitespace, lone "P"/"-P"/"PT", "P123", "Please wait 5m",
407    ///          "1.5h", "P1Yabc", "P1Y!", or **any string longer than 128 bytes**.
408    pub fn looks_like_iso(s: &str) -> bool {
409        let len = s.len();
410        if matches!(len, 0 | 1) {
411            return false;
412        }
413        let b = s.as_bytes();
414        let mut i = 0usize;
415        // Optional leading sign
416        if matches!(b[0], b'+' | b'-') {
417            i += 1;
418        }
419        // Must start with P/p after optional sign
420        if !matches!(b[i], b'P' | b'p') {
421            return false;
422        }
423        i += 1;
424        let mut has_digit = false;
425        let mut has_designator = false;
426        while i < len {
427            match b[i] {
428                b'0'..=b'9' => has_digit = true,
429                b'.' | b',' => {} // decimal separators allowed by ISO 8601
430                b'Y' | b'y' | b'M' | b'm' | b'W' | b'w' | b'D' | b'd' | b'T' | b't' | b'H'
431                | b'h' | b'S' | b's' => {
432                    has_designator = true;
433                }
434                _ => return false, // any other character = not ISO
435            }
436
437            i += 1;
438        }
439        // Must contain at least one digit *and* one designator after the initial P
440        has_digit && has_designator
441    }
442}