deep_time/strtime/mod.rs
1pub mod parser;
2pub mod printer;
3
4use crate::error::{DtErr, DtErrKind};
5use crate::{BufStr, Dt, Lang, Parts, STRTIME_SIZE, an_err};
6use core::result::Result;
7use core::str;
8
9pub(crate) use parser::*;
10pub(crate) use printer::*;
11
12/// Optional `%` directive extensions: flag, width, and colon count.
13#[derive(Clone, Copy, Debug, Default)]
14pub(crate) struct FmtExtensions {
15 pub(crate) flag: FmtFlag,
16 pub(crate) width: Option<u8>,
17 pub(crate) colons: u8,
18}
19
20/// Flags that may appear immediately after `%` and before the directive.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub(crate) enum FmtFlag {
23 #[default]
24 None,
25 PadSpace,
26 PadZero,
27 NoPad,
28 Uppercase,
29 Swapcase,
30}
31
32impl FmtFlag {
33 #[inline(always)]
34 pub(crate) fn from_byte(byte: u8) -> Self {
35 match byte {
36 b'_' => Self::PadSpace,
37 b'0' => Self::PadZero,
38 b'-' => Self::NoPad,
39 b'^' => Self::Uppercase,
40 b'#' => Self::Swapcase,
41 _ => Self::None,
42 }
43 }
44
45 /// Resolve the padding flag for numeric parsing.
46 ///
47 /// `None`, `Uppercase`, and `Swapcase` defer to the directive default;
48 /// the three pad flags override it.
49 #[inline(always)]
50 pub(crate) fn resolve(self, default: FmtFlag) -> FmtFlag {
51 match self {
52 Self::None | Self::Uppercase | Self::Swapcase => default,
53 pad => pad,
54 }
55 }
56}
57
58/// A pre-validated, reusable **parse** format for `strptime`-style directives.
59///
60/// Construct once with [`StrPTimeFmt::new`](../struct.StrPTimeFmt.html#method.new)
61/// or [`Dt::parse_fmt`](../struct.Dt.html#method.parse_fmt). On success the format
62/// is known to be syntactically valid for this crate’s parser, so later use will
63/// not fail because the format itself is unusable.
64///
65/// ## What this type is for
66///
67/// - **Fail-fast format checking** — catch unknown, unsupported, or truncated
68/// `%` directives at construction time (e.g. config load), not on every parse.
69/// - **Reuse** — store the validated format and call
70/// [`to_dt`](../struct.StrPTimeFmt.html#method.to_dt) (or reformat helpers)
71/// many times without re-checking directive syntax.
72///
73/// ## Guarantee
74///
75/// After `new` succeeds, parsing with this value
76/// ([`to_dt`](../struct.StrPTimeFmt.html#method.to_dt), and the parse step of
77/// [`to_str`](../struct.StrPTimeFmt.html#method.to_str) /
78/// [`to_str_b`](../struct.StrPTimeFmt.html#method.to_str_b)) will **not** fail
79/// with format-structure errors such as [`DtErrKind::UnknownItem`],
80/// [`DtErrKind::UnsupportedItem`], [`DtErrKind::TruncatedDirective`], or
81/// [`DtErrKind::InvalidFractional`].
82///
83/// Parsing can still fail for **input** reasons: mismatched literals, missing
84/// fields, out-of-range values, incomplete dates, trailing characters, bad
85/// offsets, and so on. See
86/// [`Dt::from_strptime`](../struct.Dt.html#method.from_strptime).
87///
88/// ## What this type is *not*
89///
90/// - **Not a speed optimization.** Validation does not make strptime/strftime
91/// faster; it only checks the format once and stores a copy.
92/// - **Not a print-format type.** The stored string is the **input** (parse)
93/// pattern. Methods that take an `output_fmt` argument do **not** pre-validate
94/// that second format — only the format inside this struct is guaranteed.
95/// - **Not the free-form auto-parser.** For guessing formats from text, use
96/// [`Dt::from_str`](../struct.Dt.html#method.from_str) / the `parse` feature,
97/// not this type.
98///
99/// ## Constraints
100///
101/// - Format must be **ASCII** only.
102/// - Format length must be ≤
103/// [`MAX_FMT_LEN`](../struct.StrPTimeFmt.html#associatedconstant.MAX_FMT_LEN)
104/// (256 bytes).
105/// - Bytes are copied into an owned fixed-size buffer (no heap allocation).
106/// - Directives match the parse grammar documented on
107/// [`Dt::from_strptime`](../struct.Dt.html#method.from_strptime) (including
108/// library extensions such as `%L`, `%*`, `%J`, `%Q`). Printer-only spellings
109/// (e.g. trim flag `~` in `%.~f`) are **not** accepted here.
110///
111/// ## Examples
112///
113/// ```rust
114/// use deep_time::{Dt, StrPTimeFmt};
115///
116/// // Prefer validating once (e.g. at startup) then reusing.
117/// let fmt = StrPTimeFmt::new("%Y-%m-%d %H:%M:%S").unwrap();
118/// // equivalent: Dt::parse_fmt("%Y-%m-%d %H:%M:%S").unwrap();
119///
120/// let dt = fmt.to_dt("2025-05-23 14:30:00", false, false, false).unwrap();
121/// assert_eq!(dt.to_ymd().yr(), 2025);
122///
123/// // Construction fails on a broken format — before any input is parsed.
124/// assert!(StrPTimeFmt::new("%Y-%m-%").is_err());
125/// assert!(StrPTimeFmt::new("%c").is_err()); // unsupported locale directive
126/// ```
127///
128/// ## See also
129///
130/// - [`StrPTimeFmt::new`](../struct.StrPTimeFmt.html#method.new)
131/// - [`StrPTimeFmt::to_dt`](../struct.StrPTimeFmt.html#method.to_dt)
132/// - [`StrPTimeFmt::to_str_b`](../struct.StrPTimeFmt.html#method.to_str_b)
133/// - [`StrPTimeFmt::to_str`](../struct.StrPTimeFmt.html#method.to_str)
134/// - [`Dt::parse_fmt`](../struct.Dt.html#method.parse_fmt)
135#[derive(Debug, Clone)]
136pub struct StrPTimeFmt {
137 fmt: [u8; Self::MAX_FMT_LEN],
138 len: usize,
139}
140
141impl StrPTimeFmt {
142 /// Maximum length of a format string in bytes (not characters).
143 ///
144 /// Longer strings are rejected with [`DtErrKind::InvalidLen`] at construction.
145 pub const MAX_FMT_LEN: usize = 256;
146
147 /// Validates a `strptime`-style format and stores it for reuse.
148 ///
149 /// On success, the returned value carries the guarantee described on
150 /// [`StrPTimeFmt`](../struct.StrPTimeFmt.html): the format will not fail later
151 /// for structural reasons when used to **parse**.
152 ///
153 /// Accepts the same `%` directives and extensions as
154 /// [`Dt::from_strptime`](../struct.Dt.html#method.from_strptime) (one optional
155 /// flag `-`/`_`/`0`/`^`/`#`, optional width digits, up to three colons for
156 /// offsets, and `%.f` / `%.N` fractional forms).
157 ///
158 /// ## Errors
159 ///
160 /// - [`DtErrKind::InvalidLen`] — longer than
161 /// [`MAX_FMT_LEN`](../struct.StrPTimeFmt.html#associatedconstant.MAX_FMT_LEN).
162 /// - [`DtErrKind::InvalidInput`] — not valid ASCII.
163 /// - [`DtErrKind::TruncatedDirective`] — a bare `%` at end of format (no
164 /// directive letter).
165 /// - [`DtErrKind::UnexpectedEnd`] — `%` followed only by flags, width, and/or
166 /// colons, with no directive letter.
167 /// - [`DtErrKind::ExpectedFractional`] — a `%.` sequence with nothing after
168 /// optional digits (incomplete fractional directive).
169 /// - [`DtErrKind::InvalidFractional`] — a `%.` sequence followed by a character
170 /// other than `f` or `N` (also rejects printer-only forms such as `%.~f`).
171 /// - [`DtErrKind::UnsupportedItem`] — `%c`, `%r`, `%x`, `%X`, or `%Z`.
172 /// - [`DtErrKind::UnknownItem`] — any other unrecognized `%` directive, or
173 /// more than three colons before a directive (e.g. `%::::z`).
174 ///
175 /// ## Examples
176 ///
177 /// ```rust
178 /// use deep_time::StrPTimeFmt;
179 ///
180 /// let fmt = StrPTimeFmt::new("%F %T").unwrap();
181 /// let dt = fmt.to_dt("2025-05-23 14:30:00", false, false, false).unwrap();
182 /// assert_eq!(dt.to_ymd().day(), 23);
183 /// ```
184 pub fn new(fmt: &str) -> Result<Self, DtErr> {
185 if fmt.len() > Self::MAX_FMT_LEN {
186 return Err(an_err!(DtErrKind::InvalidLen));
187 }
188 let fmt = fmt.as_bytes();
189 if !fmt.is_ascii() {
190 return Err(an_err!(DtErrKind::InvalidInput, "must be ascii"));
191 }
192
193 Self::validate_format(fmt)?;
194
195 let mut buffer = [0u8; Self::MAX_FMT_LEN];
196 buffer[..fmt.len()].copy_from_slice(fmt);
197
198 Ok(Self {
199 fmt: buffer,
200 len: fmt.len(),
201 })
202 }
203
204 /// Parses `s` with this format and converts the result to a [`Dt`].
205 ///
206 /// Equivalent to
207 /// [`Dt::from_strptime`](../struct.Dt.html#method.from_strptime)`(s, fmt, …)`
208 /// where `fmt` is the string validated at construction — except the format
209 /// has already been checked, so failures should only reflect the **input**
210 /// (or post-parse conversion), not broken directive syntax.
211 ///
212 /// ## Parameters
213 ///
214 /// - `s`: The input string to parse.
215 /// - `inp_can_end_before_fmt`: Allow input to end before the format is fully
216 /// consumed (parse what is present, ignore remaining directives).
217 /// - `fmt_can_end_before_inp`: Allow the format to end before the input is
218 /// fully consumed (trailing characters in `s` are OK).
219 /// - `allow_partial_date`: Default missing month/day to `1` instead of
220 /// returning [`DtErrKind::Incomplete`].
221 ///
222 /// Full directive list and flag semantics:
223 /// [`Dt::from_strptime`](../struct.Dt.html#method.from_strptime).
224 ///
225 /// ## Errors
226 ///
227 /// Same **input** and conversion errors as
228 /// [`Dt::from_strptime`](../struct.Dt.html#method.from_strptime) (mismatch,
229 /// expected fields, out of range, trailing characters, incomplete date,
230 /// timezone issues, etc.). Format-structure errors for the stored pattern
231 /// are not expected after a successful
232 /// [`new`](../struct.StrPTimeFmt.html#method.new).
233 ///
234 /// ## Examples
235 ///
236 /// ```rust
237 /// use deep_time::StrPTimeFmt;
238 ///
239 /// let fmt = StrPTimeFmt::new("%F %T").unwrap();
240 /// let dt = fmt.to_dt("2025-05-23 14:30:00", false, false, false).unwrap();
241 /// assert_eq!(dt.to_ymd().hr(), 14);
242 /// ```
243 pub fn to_dt(
244 &self,
245 s: &str,
246 inp_can_end_before_fmt: bool,
247 fmt_can_end_before_inp: bool,
248 allow_partial_date: bool,
249 ) -> Result<Dt, DtErr> {
250 Parts::from_strptime(
251 self.as_str()?,
252 s,
253 inp_can_end_before_fmt,
254 fmt_can_end_before_inp,
255 allow_partial_date,
256 )
257 .and_then(|p| p.to_dt())
258 }
259
260 /// Parse `s` with this format, then format the resulting [`Dt`] with
261 /// `output_fmt` into an owned [`String`](`alloc::string::String`).
262 ///
263 /// Requires the `alloc` feature. Prefer
264 /// [`to_str_b`](../struct.StrPTimeFmt.html#method.to_str_b) when you want a
265 /// fixed stack buffer and no allocator.
266 ///
267 /// **Important:** only the format stored in `self` is pre-validated.
268 /// `output_fmt` is passed straight to formatting and may fail with
269 /// format-structure errors (truncated `%`, unknown directives, etc.) if it
270 /// is invalid. Validate it separately with
271 /// [`StrPTimeFmt::new`](../struct.StrPTimeFmt.html#method.new) if you need
272 /// the same guarantee for a parse pattern (some print-only spellings are
273 /// still rejected by `new`).
274 ///
275 /// ## Parameters
276 ///
277 /// - `s`: Input datetime string, parsed with `self`.
278 /// - `output_fmt`: Format string for the **output** (not pre-validated).
279 /// - `inp_can_end_before_fmt`, `fmt_can_end_before_inp`, `allow_partial_date`:
280 /// passed through to [`to_dt`](../struct.StrPTimeFmt.html#method.to_dt).
281 /// - `lang`: Language for weekday/month names in the output.
282 ///
283 /// ## Examples
284 ///
285 /// ```rust
286 /// # #[cfg(feature = "alloc")]
287 /// # {
288 /// use deep_time::{Lang, StrPTimeFmt};
289 ///
290 /// let fmt = StrPTimeFmt::new("%Y-%m-%dT%H:%M:%S").unwrap();
291 /// let s = fmt
292 /// .to_str("2000-01-01T12:00:00", "%d %m %Y %H:%M:%S", false, false, false, Lang::En)
293 /// .unwrap();
294 /// assert_eq!(s, "01 01 2000 12:00:00");
295 /// # }
296 /// ```
297 #[cfg(feature = "alloc")]
298 pub fn to_str(
299 &self,
300 s: &str,
301 output_fmt: &str,
302 inp_can_end_before_fmt: bool,
303 fmt_can_end_before_inp: bool,
304 allow_partial_date: bool,
305 lang: Lang,
306 ) -> Result<alloc::string::String, DtErr> {
307 Parts::from_strptime(
308 self.as_str()?,
309 s,
310 inp_can_end_before_fmt,
311 fmt_can_end_before_inp,
312 allow_partial_date,
313 )?
314 .to_dt()?
315 .to_str(output_fmt, lang)
316 }
317
318 /// Parse `s` with this format, then format the resulting [`Dt`] with
319 /// `output_fmt` into a stack [`BufStr`].
320 ///
321 /// Same pipeline as [`to_str`](../struct.StrPTimeFmt.html#method.to_str),
322 /// without requiring `alloc`.
323 ///
324 /// **Important:** only the format stored in `self` is pre-validated.
325 /// `output_fmt` is not checked at construction of this value and may fail
326 /// if it contains invalid or unsupported directives. See
327 /// [`to_str`](../struct.StrPTimeFmt.html#method.to_str) for details.
328 ///
329 /// ## Parameters
330 ///
331 /// - `s`: Input datetime string, parsed with `self`.
332 /// - `output_fmt`: Format string for the **output** (not pre-validated).
333 /// - `inp_can_end_before_fmt`, `fmt_can_end_before_inp`, `allow_partial_date`:
334 /// passed through to [`to_dt`](../struct.StrPTimeFmt.html#method.to_dt).
335 /// - `lang`: Language for weekday/month names in the output.
336 ///
337 /// ## Examples
338 ///
339 /// ```rust
340 /// use deep_time::{Lang, StrPTimeFmt};
341 ///
342 /// let fmt = StrPTimeFmt::new("%Y-%m-%dT%H:%M:%S").unwrap();
343 /// let s = fmt
344 /// .to_str_b("2000-01-01T12:00:00", "%d %m %Y %H:%M:%S", false, false, false, Lang::En)
345 /// .unwrap();
346 /// assert_eq!(s.as_str(), "01 01 2000 12:00:00");
347 /// ```
348 pub fn to_str_b(
349 &self,
350 s: &str,
351 output_fmt: &str,
352 inp_can_end_before_fmt: bool,
353 fmt_can_end_before_inp: bool,
354 allow_partial_date: bool,
355 lang: Lang,
356 ) -> Result<BufStr<STRTIME_SIZE>, DtErr> {
357 Parts::from_strptime(
358 self.as_str()?,
359 s,
360 inp_can_end_before_fmt,
361 fmt_can_end_before_inp,
362 allow_partial_date,
363 )?
364 .to_dt()?
365 .to_str_b(output_fmt, lang)
366 }
367
368 fn validate_format(mut fmt: &[u8]) -> Result<(), DtErr> {
369 while !fmt.is_empty() {
370 if fmt[0] != b'%' {
371 // literal character (including whitespace) — always valid
372 fmt = &fmt[1..];
373 continue;
374 }
375
376 // lone % at end of format
377 if fmt.len() == 1 {
378 return Err(an_err!(DtErrKind::TruncatedDirective));
379 }
380 fmt = &fmt[1..]; // eat %
381
382 // Skip format extensions (flag / width / colons)
383 // Flag (at most one)
384 if !fmt.is_empty() {
385 match fmt[0] {
386 b'-' | b'_' | b'0' | b'^' | b'#' => {
387 fmt = &fmt[1..];
388 }
389 _ => {}
390 }
391 }
392
393 // Width: consume all consecutive digits (parser consumes any number of digits)
394 while !fmt.is_empty() && fmt[0].is_ascii_digit() {
395 fmt = &fmt[1..];
396 }
397
398 // Colons: match the parser — hard cap at 3. Extra `:` become the
399 // directive byte and must be rejected as unknown (e.g. `%::::z`).
400 let mut colons = 0u8;
401 while colons < 3 && !fmt.is_empty() && fmt[0] == b':' {
402 fmt = &fmt[1..];
403 colons += 1;
404 }
405
406 if fmt.is_empty() {
407 return Err(an_err!(DtErrKind::UnexpectedEnd));
408 }
409
410 let directive = fmt[0];
411
412 match directive {
413 // all currently supported directives
414 b'%' | b'A' | b'a' | b'B' | b'b' | b'h' | b'C' | b'd' | b'e' |
415 b'f' | b'N' | b'G' | b'g' | b'H' | b'k' | b'I' | b'l' | b'j' |
416 b'J' | b'M' | b'm' | b'n' | b't' | b'P' | b'p' | b'Q' | b'S' | b's' |
417 b'U' | b'u' | b'V' | b'W' | b'w' | b'Y' | b'y' | b'z' |
418 // shortcuts
419 b'F' | b'D' | b'T' | b'R' |
420 // library directives
421 b'L' | b'*' => {
422 fmt = &fmt[1..];
423 }
424
425 b'.' => {
426 // special case for %.f / %.3N / %-.3f etc.
427 fmt = &fmt[1..]; // eat the .
428
429 // optional width/precision digits (e.g. 3 in %.3N)
430 while !fmt.is_empty() && fmt[0].is_ascii_digit() {
431 fmt = &fmt[1..];
432 }
433
434 if fmt.is_empty() {
435 return Err(an_err!(DtErrKind::ExpectedFractional));
436 }
437 let next = fmt[0];
438 if !matches!(next, b'f' | b'N') {
439 return Err(an_err!(DtErrKind::InvalidFractional, "{}", char::from(next)));
440 }
441 fmt = &fmt[1..];
442 }
443
444 // explicitly unsupported
445 b'c' | b'r' | b'X' | b'x' | b'Z' => {
446 return Err(an_err!(
447 DtErrKind::UnsupportedItem,
448 "{}",
449 char::from(directive)
450 ));
451 }
452
453 _ => {
454 return Err(an_err!(DtErrKind::UnknownItem));
455 }
456 }
457 }
458
459 Ok(())
460 }
461
462 #[inline]
463 fn as_bytes(&self) -> &[u8] {
464 &self.fmt[..self.len]
465 }
466
467 #[inline]
468 fn as_str(&self) -> Result<&str, DtErr> {
469 match core::str::from_utf8(self.as_bytes()) {
470 Ok(f) => Ok(f),
471 Err(e) => Err(an_err!(DtErrKind::InvalidBytes, "{}", e)),
472 }
473 }
474}
475
476impl Dt {
477 /// Validates a `strptime`-style format into a reusable [`StrPTimeFmt`].
478 ///
479 /// Convenience alias for
480 /// [`StrPTimeFmt::new`](../struct.StrPTimeFmt.html#method.new). The format is
481 /// checked once for syntax and supported directives, then stored in a
482 /// fixed-size buffer.
483 ///
484 /// After this succeeds, parsing with the returned value will not fail because
485 /// the format itself is unusable — only because the **input** does not match
486 /// (or conversion fails). See the guarantee on
487 /// [`StrPTimeFmt`](../struct.StrPTimeFmt.html).
488 ///
489 /// This is **not** a performance optimization; it only validates and owns a
490 /// copy of the format. Only ASCII formats up to
491 /// [`StrPTimeFmt::MAX_FMT_LEN`](../struct.StrPTimeFmt.html#associatedconstant.MAX_FMT_LEN)
492 /// bytes are accepted.
493 ///
494 /// ## Parameters
495 ///
496 /// - `strptime_fmt`: Parse format using `%` directives (e.g. `"%Y-%m-%d %H:%M:%S"`,
497 /// `"%F %T"`, `"%Y-%m-%dT%H:%M:%S%.3fZ"`). Same grammar as
498 /// [`from_strptime`](../struct.Dt.html#method.from_strptime).
499 ///
500 /// ## Errors
501 ///
502 /// Same as [`StrPTimeFmt::new`](../struct.StrPTimeFmt.html#method.new): overlong,
503 /// non-ASCII, truncated/malformed directives, unknown or unsupported items
504 /// (`%c`, `%r`, `%x`, `%X`, `%Z`, etc.).
505 ///
506 /// ## Examples
507 ///
508 /// ```rust
509 /// use deep_time::Dt;
510 ///
511 /// let fmt = Dt::parse_fmt("%F %T").unwrap();
512 /// let dt = fmt.to_dt("2025-05-23 14:30:00", false, false, false).unwrap();
513 /// assert_eq!(dt.to_ymd().min(), 30);
514 /// ```
515 #[inline(always)]
516 pub fn parse_fmt(strptime_fmt: &str) -> Result<StrPTimeFmt, DtErr> {
517 StrPTimeFmt::new(strptime_fmt)
518 }
519}
520
521#[cfg(feature = "defmt")]
522impl defmt::Format for StrPTimeFmt {
523 fn format(&self, f: defmt::Formatter) {
524 match self.as_str() {
525 Ok(fmt) => defmt::write!(f, "{}", fmt),
526 Err(_) => defmt::write!(f, "StrPTimeFmt<invalid utf8>"),
527 }
528 }
529}