1use std::fmt;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum FormatDirective {
44 Year4,
46 Year2,
48 MonthNumber,
50 DayOfMonth,
52 DayOfMonthSpaced,
54 Hour24,
56 Hour12,
58 Minute,
60 Second,
62 Fraction,
64 Meridiem,
66 DayOfYear,
68 MonthAbbrev,
70 MonthName,
72 WeekdayAbbrev,
74 WeekdayName,
76 Offset,
78 ZoneName,
80}
81
82impl FormatDirective {
83 #[must_use]
85 pub fn spelled(self) -> &'static str {
86 match self {
87 Self::Year4 => "%Y",
88 Self::Year2 => "%y",
89 Self::MonthNumber => "%m",
90 Self::DayOfMonth => "%d",
91 Self::DayOfMonthSpaced => "%e",
92 Self::Hour24 => "%H",
93 Self::Hour12 => "%I",
94 Self::Minute => "%M",
95 Self::Second => "%S",
96 Self::Fraction => "%f",
97 Self::Meridiem => "%p",
98 Self::DayOfYear => "%j",
99 Self::MonthAbbrev => "%b",
100 Self::MonthName => "%B",
101 Self::WeekdayAbbrev => "%a",
102 Self::WeekdayName => "%A",
103 Self::Offset => "%z",
104 Self::ZoneName => "%Z",
105 }
106 }
107
108 #[must_use]
110 pub fn means(self) -> &'static str {
111 match self {
112 Self::Year4 => "a four-digit year",
113 Self::Year2 => "a two-digit year",
114 Self::MonthNumber => "the month as a number",
115 Self::DayOfMonth | Self::DayOfMonthSpaced => "the day of the month",
116 Self::Hour24 => "the hour on a twenty-four hour clock",
117 Self::Hour12 => "the hour on a twelve hour clock",
118 Self::Minute => "the minute",
119 Self::Second => "the second",
120 Self::Fraction => "the fraction of a second",
121 Self::Meridiem => "AM or PM",
122 Self::DayOfYear => "the day of the year",
123 Self::MonthAbbrev => "the month's name, abbreviated",
124 Self::MonthName => "the month's name in full",
125 Self::WeekdayAbbrev => "the weekday's name, abbreviated",
126 Self::WeekdayName => "the weekday's name in full",
127 Self::Offset => "the offset from UTC",
128 Self::ZoneName => "the time zone's name",
129 }
130 }
131
132 #[must_use]
136 pub fn names_a_zone(self) -> bool {
137 matches!(self, Self::Offset | Self::ZoneName)
138 }
139
140 fn from_letter(letter: char) -> Option<Self> {
143 Some(match letter {
144 'Y' => Self::Year4,
145 'y' => Self::Year2,
146 'm' => Self::MonthNumber,
147 'd' => Self::DayOfMonth,
148 'e' => Self::DayOfMonthSpaced,
149 'H' => Self::Hour24,
150 'I' => Self::Hour12,
151 'M' => Self::Minute,
152 'S' => Self::Second,
153 'f' => Self::Fraction,
154 'p' => Self::Meridiem,
155 'j' => Self::DayOfYear,
156 'b' => Self::MonthAbbrev,
157 'B' => Self::MonthName,
158 'a' => Self::WeekdayAbbrev,
159 'A' => Self::WeekdayName,
160 'z' => Self::Offset,
161 'Z' => Self::ZoneName,
162 _ => return None,
163 })
164 }
165
166 fn shape(self) -> &'static str {
170 match self {
171 Self::Year4 => "([0-9]{4})",
172 Self::Year2 => "([0-9]{2})",
173 Self::MonthNumber | Self::Hour12 => "(0?[1-9]|1[0-2])",
174 Self::DayOfMonth => "(0?[1-9]|[12][0-9]|3[01])",
175 Self::DayOfMonthSpaced => "( ?[1-9]|[12][0-9]|3[01])",
176 Self::Hour24 => "([01]?[0-9]|2[0-3])",
177 Self::Minute | Self::Second => "([0-5]?[0-9])",
178 Self::Fraction => "([0-9]{1,9})",
179 Self::Meridiem => "([AaPp][Mm])",
180 Self::DayOfYear => "(0{0,2}[1-9]|0?[1-9][0-9]|[12][0-9]{2}|3[0-5][0-9]|36[0-6])",
181 Self::MonthAbbrev => {
187 "([Jj][Aa][Nn]|[Ff][Ee][Bb]|[Mm][Aa][Rr]|[Aa][Pp][Rr]|[Mm][Aa][Yy]|[Jj][Uu][Nn]|[Jj][Uu][Ll]|[Aa][Uu][Gg]|[Ss][Ee][Pp]|[Oo][Cc][Tt]|[Nn][Oo][Vv]|[Dd][Ee][Cc])"
188 }
189 Self::MonthName => {
190 "([Jj][Aa][Nn][Uu][Aa][Rr][Yy]|[Ff][Ee][Bb][Rr][Uu][Aa][Rr][Yy]|[Mm][Aa][Rr][Cc][Hh]|[Aa][Pp][Rr][Ii][Ll]|[Mm][Aa][Yy]|[Jj][Uu][Nn][Ee]|[Jj][Uu][Ll][Yy]|[Aa][Uu][Gg][Uu][Ss][Tt]|[Ss][Ee][Pp][Tt][Ee][Mm][Bb][Ee][Rr]|[Oo][Cc][Tt][Oo][Bb][Ee][Rr]|[Nn][Oo][Vv][Ee][Mm][Bb][Ee][Rr]|[Dd][Ee][Cc][Ee][Mm][Bb][Ee][Rr])"
191 }
192 Self::WeekdayAbbrev => {
193 "([Mm][Oo][Nn]|[Tt][Uu][Ee]|[Ww][Ee][Dd]|[Tt][Hh][Uu]|[Ff][Rr][Ii]|[Ss][Aa][Tt]|[Ss][Uu][Nn])"
194 }
195 Self::WeekdayName => {
196 "([Mm][Oo][Nn][Dd][Aa][Yy]|[Tt][Uu][Ee][Ss][Dd][Aa][Yy]|[Ww][Ee][Dd][Nn][Ee][Ss][Dd][Aa][Yy]|[Tt][Hh][Uu][Rr][Ss][Dd][Aa][Yy]|[Ff][Rr][Ii][Dd][Aa][Yy]|[Ss][Aa][Tt][Uu][Rr][Dd][Aa][Yy]|[Ss][Uu][Nn][Dd][Aa][Yy])"
197 }
198 Self::Offset => "([+-][0-9]{2}:?[0-9]{2}|[Zz])",
199 Self::ZoneName => "([A-Za-z_/+-]+)",
200 }
201 }
202
203 fn loose(self) -> &'static str {
206 match self {
207 Self::Year4 => "[0-9]{4}",
208 Self::Year2 => "[0-9]{2}",
209 Self::MonthNumber
210 | Self::DayOfMonth
211 | Self::Hour24
212 | Self::Hour12
213 | Self::Minute
214 | Self::Second => "[0-9]{1,2}",
215 Self::DayOfMonthSpaced => "[ 0-9]{1,2}",
216 Self::Fraction => "[0-9]{1,9}",
217 Self::Meridiem => "[AaPp][Mm]",
218 Self::DayOfYear => "[0-9]{1,3}",
219 Self::MonthAbbrev | Self::MonthName | Self::WeekdayAbbrev | Self::WeekdayName => {
220 "[A-Za-z]+"
221 }
222 Self::Offset => "[Zz+:0-9-]{1,6}",
223 Self::ZoneName => "[A-Za-z_/+-]+",
224 }
225 }
226}
227
228impl fmt::Display for FormatDirective {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 f.write_str(self.spelled())
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum FormatPart {
238 Literal(String),
240 Directive(FormatDirective),
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum DateFormatError {
247 UnknownDirective(char),
249 DanglingPercent,
251 Empty,
253}
254
255impl fmt::Display for DateFormatError {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 match self {
258 Self::UnknownDirective(letter) => write!(
259 f,
260 "`%{letter}` is not something a format can say. The directives are `%Y` `%y` \
261 `%m` `%d` `%e` `%H` `%I` `%M` `%S` `%f` `%p` `%j` `%b` `%B` `%a` `%A` `%z` `%Z` \
262 and `%%` for a literal percent sign; everything else in a format is written the \
263 way the value writes it"
264 ),
265 Self::DanglingPercent => f.write_str(
266 "the format ends in a `%` with nothing after it. Name what comes there, or write \
267 `%%` for a literal percent sign",
268 ),
269 Self::Empty => f.write_str(
270 "the format is empty. Write how the source spells the value: `%m/%d/%Y` for \
271 `1/1/2015`, `%Y-%m-%d` for `2015-01-01`",
272 ),
273 }
274 }
275}
276
277impl std::error::Error for DateFormatError {}
278
279#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct DateFormat {
285 spelled: String,
286 parts: Vec<FormatPart>,
287}
288
289impl DateFormat {
290 pub fn parse(spelled: &str) -> Result<Self, DateFormatError> {
297 if spelled.is_empty() {
298 return Err(DateFormatError::Empty);
299 }
300 let mut parts: Vec<FormatPart> = Vec::new();
301 let mut literal = String::new();
302 let mut letters = spelled.chars();
303 while let Some(character) = letters.next() {
304 if character != '%' {
305 literal.push(character);
306 continue;
307 }
308 let Some(next) = letters.next() else {
309 return Err(DateFormatError::DanglingPercent);
310 };
311 if next == '%' {
312 literal.push('%');
313 continue;
314 }
315 let Some(directive) = FormatDirective::from_letter(next) else {
316 return Err(DateFormatError::UnknownDirective(next));
317 };
318 if !literal.is_empty() {
319 parts.push(FormatPart::Literal(std::mem::take(&mut literal)));
320 }
321 parts.push(FormatPart::Directive(directive));
322 }
323 if !literal.is_empty() {
324 parts.push(FormatPart::Literal(literal));
325 }
326 Ok(Self { spelled: spelled.to_owned(), parts })
327 }
328
329 #[must_use]
331 pub fn spelled(&self) -> &str {
332 &self.spelled
333 }
334
335 #[must_use]
337 pub fn parts(&self) -> &[FormatPart] {
338 &self.parts
339 }
340
341 pub fn directives(&self) -> impl Iterator<Item = FormatDirective> + '_ {
343 self.parts.iter().filter_map(|part| match part {
344 FormatPart::Directive(directive) => Some(*directive),
345 FormatPart::Literal(_) => None,
346 })
347 }
348
349 #[must_use]
352 pub fn names_a_zone(&self) -> bool {
353 self.directives().any(FormatDirective::names_a_zone)
354 }
355
356 #[must_use]
358 pub fn says_a_time_of_day(&self) -> bool {
359 self.directives().any(|directive| {
360 matches!(
361 directive,
362 FormatDirective::Hour24
363 | FormatDirective::Hour12
364 | FormatDirective::Minute
365 | FormatDirective::Second
366 | FormatDirective::Fraction
367 | FormatDirective::Meridiem
368 )
369 })
370 }
371
372 pub fn translate(
386 &self,
387 spell: &dyn Fn(FormatDirective) -> Option<&'static str>,
388 literal: &dyn Fn(&str) -> String,
389 ) -> Result<String, FormatDirective> {
390 let mut pattern = String::new();
391 for part in &self.parts {
392 match part {
393 FormatPart::Literal(text) => pattern.push_str(&literal(text)),
394 FormatPart::Directive(directive) => {
395 pattern.push_str(spell(*directive).ok_or(*directive)?);
396 }
397 }
398 }
399 Ok(pattern)
400 }
401
402 #[must_use]
418 pub fn shape_pattern(&self) -> String {
419 let mut pattern = String::from("^");
420 for part in &self.parts {
421 match part {
422 FormatPart::Literal(text) => pattern.push_str(&escape_posix(text)),
423 FormatPart::Directive(directive) => pattern.push_str(directive.shape()),
424 }
425 }
426 pattern.push('$');
427 pattern
428 }
429
430 #[must_use]
439 pub fn capture_pattern(&self, wanted: FormatDirective) -> Option<String> {
440 let mut found = false;
441 let mut pattern = String::from("^");
442 for part in &self.parts {
443 match part {
444 FormatPart::Literal(text) => pattern.push_str(&escape_posix(text)),
445 FormatPart::Directive(directive) if *directive == wanted && !found => {
446 found = true;
447 pattern.push('(');
448 pattern.push_str(directive.loose());
449 pattern.push(')');
450 }
451 FormatPart::Directive(directive) => pattern.push_str(directive.loose()),
452 }
453 }
454 pattern.push('$');
455 found.then_some(pattern)
456 }
457}
458
459impl fmt::Display for DateFormat {
460 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461 f.write_str(&self.spelled)
462 }
463}
464
465fn escape_posix(text: &str) -> String {
467 let mut escaped = String::with_capacity(text.len());
468 for character in text.chars() {
469 if "\\^$.[]|()*+?{}".contains(character) {
470 escaped.push('\\');
471 }
472 escaped.push(character);
473 }
474 escaped
475}
476
477#[cfg(test)]
478mod tests {
479 use super::{DateFormat, DateFormatError, FormatDirective, FormatPart};
480
481 #[test]
484 fn a_day_spelled_with_slashes_reads_as_five_pieces() {
485 let format = DateFormat::parse("%m/%d/%Y").expect("a lawful format");
486 assert_eq!(
487 format.parts(),
488 [
489 FormatPart::Directive(FormatDirective::MonthNumber),
490 FormatPart::Literal("/".into()),
491 FormatPart::Directive(FormatDirective::DayOfMonth),
492 FormatPart::Literal("/".into()),
493 FormatPart::Directive(FormatDirective::Year4),
494 ]
495 );
496 assert_eq!(format.spelled(), "%m/%d/%Y");
497 assert!(!format.says_a_time_of_day());
498 assert!(!format.names_a_zone());
499 }
500
501 #[test]
503 fn every_directive_in_the_family_is_read() {
504 let every = "%Y %y %m %d %e %H %I %M %S %f %p %j %b %B %a %A %z %Z";
505 let format = DateFormat::parse(every).expect("a lawful format");
506 let read: Vec<_> = format.directives().map(FormatDirective::spelled).collect();
507 assert_eq!(
508 read,
509 [
510 "%Y", "%y", "%m", "%d", "%e", "%H", "%I", "%M", "%S", "%f", "%p", "%j", "%b", "%B",
511 "%a", "%A", "%z", "%Z"
512 ]
513 );
514 assert!(format.names_a_zone());
515 assert!(format.says_a_time_of_day());
516 }
517
518 #[test]
520 fn a_doubled_percent_is_a_literal_one() {
521 let format = DateFormat::parse("%d%%%m").expect("a lawful format");
522 assert_eq!(
523 format.parts(),
524 [
525 FormatPart::Directive(FormatDirective::DayOfMonth),
526 FormatPart::Literal("%".into()),
527 FormatPart::Directive(FormatDirective::MonthNumber),
528 ]
529 );
530 }
531
532 #[test]
535 fn a_letter_the_family_does_not_use_is_refused_by_name() {
536 assert_eq!(DateFormat::parse("%Q-%m"), Err(DateFormatError::UnknownDirective('Q')));
537 assert!(DateFormat::parse("%Q").unwrap_err().to_string().contains("`%Q`"));
538 }
539
540 #[test]
542 fn a_trailing_percent_is_refused() {
543 assert_eq!(DateFormat::parse("%Y-%m-%d %"), Err(DateFormatError::DanglingPercent));
544 assert_eq!(DateFormat::parse(""), Err(DateFormatError::Empty));
545 }
546
547 #[test]
550 fn a_translation_names_the_directive_the_engine_cannot_say() {
551 let format = DateFormat::parse("%Y-%m-%dT%H:%M:%S%z").expect("a lawful format");
552 let spell = |directive: FormatDirective| match directive {
553 FormatDirective::Year4 => Some("yyyy"),
554 FormatDirective::MonthNumber => Some("MM"),
555 FormatDirective::DayOfMonth => Some("dd"),
556 FormatDirective::Hour24 => Some("HH"),
557 FormatDirective::Minute => Some("mm"),
558 FormatDirective::Second => Some("ss"),
559 _ => None,
560 };
561 assert_eq!(
562 format.translate(&spell, &|text| format!("'{text}'")),
563 Err(FormatDirective::Offset)
564 );
565 let without = DateFormat::parse("%Y-%m-%d").expect("a lawful format");
566 assert_eq!(
567 without.translate(&spell, &|text: &str| text.to_owned()),
568 Ok("yyyy-MM-dd".to_owned())
569 );
570 }
571
572 #[test]
575 fn the_guard_and_the_read_back_come_from_one_list() {
576 let format = DateFormat::parse("%m/%d/%Y").expect("a lawful format");
577 assert_eq!(
578 format.shape_pattern(),
579 "^(0?[1-9]|1[0-2])/(0?[1-9]|[12][0-9]|3[01])/([0-9]{4})$"
580 );
581 assert_eq!(
582 format.capture_pattern(FormatDirective::DayOfMonth).as_deref(),
583 Some("^[0-9]{1,2}/([0-9]{1,2})/[0-9]{4}$")
584 );
585 assert_eq!(format.capture_pattern(FormatDirective::Hour24), None);
586 }
587
588 #[test]
591 fn a_literal_dot_means_a_dot() {
592 let format = DateFormat::parse("%d.%m.%Y").expect("a lawful format");
593 assert!(format.shape_pattern().contains("\\."));
594 }
595}