1use crate::core::locale::{DateOrder, Locale};
2
3const MONTHS_FULL: [&str; 12] = [
4 "January",
5 "February",
6 "March",
7 "April",
8 "May",
9 "June",
10 "July",
11 "August",
12 "September",
13 "October",
14 "November",
15 "December",
16];
17const MONTHS_SHORT: [&str; 12] = [
18 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
19];
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub enum StringCase {
28 Lower,
30 Upper,
32 Title,
34 Original,
36}
37
38pub fn detect_case(s: &str) -> StringCase {
39 if s.chars().all(|c| c.is_uppercase()) {
40 StringCase::Upper
41 } else if s.chars().all(|c| c.is_lowercase()) {
42 StringCase::Lower
43 } else {
44 let mut chars = s.chars();
45 if let Some(first) = chars.next()
46 && first.is_uppercase()
47 && chars.all(|c| c.is_lowercase())
48 {
49 return StringCase::Title;
50 }
51 StringCase::Original
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct SimpleDate {
62 pub year: i32,
64 pub month: u32,
66 pub day: u32,
68}
69
70pub fn is_leap_year(year: i32) -> bool {
71 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
72}
73
74pub fn days_in_month(year: i32, month: u32) -> u32 {
75 match month {
76 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
77 4 | 6 | 9 | 11 => 30,
78 2 => {
79 if is_leap_year(year) {
80 29
81 } else {
82 28
83 }
84 }
85 _ => 0,
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
102pub enum DateFormat {
103 Ymd {
105 sep: char,
107 },
108 Mdy {
110 sep: char,
112 year_len: usize,
114 },
115 Dmy {
117 sep: char,
119 year_len: usize,
121 },
122 DMmmY {
124 sep: char,
126 year_len: usize,
128 month_case: StringCase,
130 month_full: bool,
132 },
133 MmmDY {
135 sep: char,
137 year_len: usize,
139 month_case: StringCase,
141 month_full: bool,
143 },
144 YMmmD {
146 sep: char,
148 year_len: usize,
150 month_case: StringCase,
152 month_full: bool,
154 },
155
156 Md {
158 sep: char,
160 },
161 Dm {
163 sep: char,
165 },
166 My {
168 sep: char,
170 year_len: usize,
172 },
173 DMmm {
175 sep: char,
177 month_case: StringCase,
179 month_full: bool,
181 },
182 MmmD {
184 sep: char,
186 month_case: StringCase,
188 month_full: bool,
190 },
191 MmmY {
193 sep: char,
195 year_len: usize,
197 month_case: StringCase,
199 month_full: bool,
201 },
202 YMmm {
204 sep: char,
206 year_len: usize,
208 month_case: StringCase,
210 month_full: bool,
212 },
213}
214
215impl DateFormat {
216 pub fn to_format_code(&self) -> String {
223 fn month_word(full: bool) -> &'static str {
224 if full { "mmmm" } else { "mmm" }
225 }
226 fn year(len: usize) -> &'static str {
227 if len == 2 { "yy" } else { "yyyy" }
228 }
229
230 match *self {
231 DateFormat::Ymd { sep } => format!("yyyy{sep}mm{sep}dd"),
232 DateFormat::Mdy { sep, year_len } => format!("m{sep}d{sep}{}", year(year_len)),
233 DateFormat::Dmy { sep, year_len } => format!("d{sep}m{sep}{}", year(year_len)),
234 DateFormat::DMmmY {
235 sep,
236 year_len,
237 month_full,
238 ..
239 } => format!("d{sep}{}{sep}{}", month_word(month_full), year(year_len)),
240 DateFormat::MmmDY {
241 sep,
242 year_len,
243 month_full,
244 ..
245 } => format!("{}{sep}d{sep}{}", month_word(month_full), year(year_len)),
246 DateFormat::YMmmD {
247 sep,
248 year_len,
249 month_full,
250 ..
251 } => format!("{}{sep}{}{sep}d", year(year_len), month_word(month_full)),
252 DateFormat::Md { sep } => format!("m{sep}d"),
253 DateFormat::Dm { sep } => format!("d{sep}m"),
254 DateFormat::My { sep, year_len } => format!("m{sep}{}", year(year_len)),
255 DateFormat::DMmm {
256 sep, month_full, ..
257 } => format!("d{sep}{}", month_word(month_full)),
258 DateFormat::MmmD {
259 sep, month_full, ..
260 } => format!("{}{sep}d", month_word(month_full)),
261 DateFormat::MmmY {
262 sep,
263 year_len,
264 month_full,
265 ..
266 } => format!("{}{sep}{}", month_word(month_full), year(year_len)),
267 DateFormat::YMmm {
268 sep,
269 year_len,
270 month_full,
271 ..
272 } => format!("{}{sep}{}", year(year_len), month_word(month_full)),
273 }
274 }
275
276 pub fn month_case(&self) -> StringCase {
278 match *self {
279 DateFormat::DMmmY { month_case, .. }
280 | DateFormat::MmmDY { month_case, .. }
281 | DateFormat::YMmmD { month_case, .. }
282 | DateFormat::DMmm { month_case, .. }
283 | DateFormat::MmmD { month_case, .. }
284 | DateFormat::MmmY { month_case, .. }
285 | DateFormat::YMmm { month_case, .. } => month_case,
286 _ => StringCase::Title,
287 }
288 }
289}
290
291fn apply_case(s: &str, case: StringCase) -> String {
292 match case {
293 StringCase::Upper => s.to_uppercase(),
294 StringCase::Lower => s.to_lowercase(),
295 StringCase::Title | StringCase::Original => s.to_string(),
296 }
297}
298
299pub fn render_date_code(date: SimpleDate, code: &str, month_case: StringCase) -> String {
310 let chars: Vec<char> = code.chars().collect();
311 let mut out = String::with_capacity(code.len() + 8);
312 let mut i = 0;
313
314 while i < chars.len() {
315 let c = chars[i];
316 if c == '\\' {
317 if let Some(next) = chars.get(i + 1) {
318 out.push(*next);
319 i += 2;
320 } else {
321 i += 1;
322 }
323 continue;
324 }
325 let lower = c.to_ascii_lowercase();
326 if !matches!(lower, 'y' | 'm' | 'd') {
327 out.push(c);
328 i += 1;
329 continue;
330 }
331
332 let mut run = 0;
333 while i + run < chars.len() && chars[i + run].to_ascii_lowercase() == lower {
334 run += 1;
335 }
336 i += run;
337
338 match lower {
339 'y' => {
340 if run <= 2 {
341 out.push_str(&format!("{:02}", date.year.rem_euclid(100)));
342 } else {
343 out.push_str(&format!("{:04}", date.year));
344 }
345 }
346 'm' => {
347 let idx = (date.month as usize).saturating_sub(1);
348 match run {
349 1 => out.push_str(&date.month.to_string()),
350 2 => out.push_str(&format!("{:02}", date.month)),
351 3 => out.push_str(&apply_case(
352 MONTHS_SHORT.get(idx).copied().unwrap_or(""),
353 month_case,
354 )),
355 _ => out.push_str(&apply_case(
356 MONTHS_FULL.get(idx).copied().unwrap_or(""),
357 month_case,
358 )),
359 }
360 }
361 _ => {
362 if run == 1 {
363 out.push_str(&date.day.to_string());
364 } else {
365 out.push_str(&format!("{:02}", date.day));
366 }
367 }
368 }
369 }
370 out
371}
372
373pub fn format_date(date: SimpleDate, format: &DateFormat) -> String {
375 render_date_code(date, &format.to_format_code(), format.month_case())
376}
377
378pub fn is_date_code(code: &str) -> bool {
380 let mut has_year_or_day = false;
381 let mut has_month = false;
382 let mut has_time_token = false;
383 let mut has_number_placeholder = false;
384 let mut in_quote = false;
385 let mut in_bracket = false;
386 let mut escaped = false;
387 for c in code.chars() {
388 if escaped {
389 escaped = false;
390 continue;
391 }
392 match c {
393 '\\' => {
394 escaped = true;
395 }
396 '"' if !in_bracket => in_quote = !in_quote,
397 '[' if !in_quote => in_bracket = true,
398 ']' if in_bracket => in_bracket = false,
399 _ if in_quote || in_bracket => {}
400 '0' | '#' => has_number_placeholder = true,
401 _ => match c.to_ascii_lowercase() {
402 'y' | 'd' => has_year_or_day = true,
403 'm' => has_month = true,
404 'h' | 's' => has_time_token = true,
405 _ => {}
406 },
407 }
408 }
409 (has_year_or_day || has_month) && !has_time_token && !has_number_placeholder
410}
411
412pub fn excel_serial_to_date(serial: f64) -> SimpleDate {
414 let (year, month, day) = crate::core::date_fn::serial_to_ymd(serial);
415 SimpleDate {
416 year,
417 month: month.max(0) as u32,
418 day: day.max(0) as u32,
419 }
420}
421
422fn parse_digits(part: &str) -> Option<i32> {
423 let trimmed = part.trim().trim_end_matches('.');
424 if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
425 trimmed.parse::<i32>().ok()
426 } else {
427 None
428 }
429}
430
431pub fn parse_date(src: &str) -> Option<(SimpleDate, DateFormat)> {
433 parse_date_with_locale(src, &Locale::en_us())
434}
435
436pub fn parse_date_with_locale(src: &str, locale: &Locale) -> Option<(SimpleDate, DateFormat)> {
443 let default_year = locale.default_year();
444 let src_trim = src.trim();
445 if src_trim.is_empty() {
446 return None;
447 }
448
449 for &sep in &['-', '/', '.'] {
450 let parts: Vec<&str> = src_trim.split(sep).collect();
451
452 if parts.len() == 3 {
453 let mut month_word_info = None;
454 for (i, part) in parts.iter().enumerate() {
455 if let Some((m, is_full)) = locale.match_month_word(part) {
456 month_word_info = Some((i, m, is_full));
457 break;
458 }
459 }
460
461 if let Some((month_idx, month, is_full)) = month_word_info {
462 let mut digit_parts = Vec::new();
463 for (i, part) in parts.iter().enumerate() {
464 if i != month_idx
465 && let Some(val) = parse_digits(part)
466 {
467 digit_parts.push((i, val, part.trim().trim_end_matches('.').len()));
468 }
469 }
470
471 if digit_parts.len() == 2 {
472 let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
473
474 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
475 let day = digit_parts[0].1 as u32;
476 let year_raw = digit_parts[1].1;
477 let year_len = digit_parts[1].2;
478 let year = if year_len == 2 {
479 locale.expand_two_digit_year(year_raw)
480 } else {
481 year_raw
482 };
483 if day >= 1 && day <= days_in_month(year, month) {
484 return Some((
485 SimpleDate { year, month, day },
486 DateFormat::DMmmY {
487 sep,
488 year_len,
489 month_case: case,
490 month_full: is_full,
491 },
492 ));
493 }
494 }
495
496 if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
497 let day = digit_parts[0].1 as u32;
498 let year_raw = digit_parts[1].1;
499 let year_len = digit_parts[1].2;
500 let year = if year_len == 2 {
501 locale.expand_two_digit_year(year_raw)
502 } else {
503 year_raw
504 };
505 if day >= 1 && day <= days_in_month(year, month) {
506 return Some((
507 SimpleDate { year, month, day },
508 DateFormat::MmmDY {
509 sep,
510 year_len,
511 month_case: case,
512 month_full: is_full,
513 },
514 ));
515 }
516 }
517
518 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
519 let year_raw = digit_parts[0].1;
520 let year_len = digit_parts[0].2;
521 let year = if year_len == 2 {
522 locale.expand_two_digit_year(year_raw)
523 } else {
524 year_raw
525 };
526 let day = digit_parts[1].1 as u32;
527 if day >= 1 && day <= days_in_month(year, month) {
528 return Some((
529 SimpleDate { year, month, day },
530 DateFormat::YMmmD {
531 sep,
532 year_len,
533 month_case: case,
534 month_full: is_full,
535 },
536 ));
537 }
538 }
539 }
540 } else {
541 if let (Some(val0), Some(val1), Some(val2)) = (
542 parse_digits(parts[0]),
543 parse_digits(parts[1]),
544 parse_digits(parts[2]),
545 ) {
546 let len0 = parts[0].trim().len();
547 let len2 = parts[2].trim().len();
548
549 if len0 == 4 {
550 let year = val0;
551 let month = val1 as u32;
552 let day = val2 as u32;
553 if (1..=12).contains(&month)
554 && day >= 1
555 && day <= days_in_month(year, month)
556 {
557 return Some((
558 SimpleDate { year, month, day },
559 DateFormat::Ymd { sep },
560 ));
561 }
562 }
563
564 if len2 == 4 || len2 == 2 {
565 let year_raw = val2;
566 let year = if len2 == 2 {
567 locale.expand_two_digit_year(year_raw)
568 } else {
569 year_raw
570 };
571
572 match locale.date_order {
573 DateOrder::Dmy => {
574 let day = val0 as u32;
575 let month = val1 as u32;
576 if (1..=12).contains(&month)
577 && day >= 1
578 && day <= days_in_month(year, month)
579 {
580 return Some((
581 SimpleDate { year, month, day },
582 DateFormat::Dmy {
583 sep,
584 year_len: len2,
585 },
586 ));
587 }
588 }
589 DateOrder::Mdy => {
590 if val0 > 12 && val1 <= 12 {
591 let day = val0 as u32;
592 let month = val1 as u32;
593 if (1..=12).contains(&month)
594 && day >= 1
595 && day <= days_in_month(year, month)
596 {
597 return Some((
598 SimpleDate { year, month, day },
599 DateFormat::Dmy {
600 sep,
601 year_len: len2,
602 },
603 ));
604 }
605 } else {
606 let month = val0 as u32;
607 let day = val1 as u32;
608 if (1..=12).contains(&month)
609 && day >= 1
610 && day <= days_in_month(year, month)
611 {
612 return Some((
613 SimpleDate { year, month, day },
614 DateFormat::Mdy {
615 sep,
616 year_len: len2,
617 },
618 ));
619 }
620 }
621 }
622 DateOrder::Ymd => {
623 if len0 == 2 && len2 <= 2 {
624 let y = locale.expand_two_digit_year(val0);
625 let m = val1 as u32;
626 let d = val2 as u32;
627 if (1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m) {
628 return Some((
629 SimpleDate {
630 year: y,
631 month: m,
632 day: d,
633 },
634 DateFormat::Ymd { sep },
635 ));
636 }
637 }
638 let month = val0 as u32;
639 let day = val1 as u32;
640 if (1..=12).contains(&month)
641 && day >= 1
642 && day <= days_in_month(year, month)
643 {
644 return Some((
645 SimpleDate { year, month, day },
646 DateFormat::Mdy {
647 sep,
648 year_len: len2,
649 },
650 ));
651 }
652 }
653 }
654 }
655 }
656 }
657 }
658
659 if parts.len() == 2 {
660 let mut month_word_info = None;
661 for (i, part) in parts.iter().enumerate() {
662 if let Some((m, is_full)) = locale.match_month_word(part) {
663 month_word_info = Some((i, m, is_full));
664 break;
665 }
666 }
667
668 if let Some((month_idx, month, is_full)) = month_word_info {
669 let digit_idx = if month_idx == 0 { 1 } else { 0 };
670 if let Some(digit_val) = parse_digits(parts[digit_idx]) {
671 let digit_len = parts[digit_idx].trim().trim_end_matches('.').len();
672 let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
673
674 if digit_len == 4 || (digit_len == 2 && digit_val == default_year % 100) {
675 let year = if digit_len == 2 {
676 locale.expand_two_digit_year(digit_val)
677 } else {
678 digit_val
679 };
680 if (1..=12).contains(&month) {
681 if month_idx == 0 {
682 return Some((
683 SimpleDate {
684 year,
685 month,
686 day: 1,
687 },
688 DateFormat::MmmY {
689 sep,
690 year_len: digit_len,
691 month_case: case,
692 month_full: is_full,
693 },
694 ));
695 } else {
696 return Some((
697 SimpleDate {
698 year,
699 month,
700 day: 1,
701 },
702 DateFormat::YMmm {
703 sep,
704 year_len: digit_len,
705 month_case: case,
706 month_full: is_full,
707 },
708 ));
709 }
710 }
711 } else {
712 let day = digit_val as u32;
713 if day >= 1 && day <= days_in_month(default_year, month) {
714 if month_idx == 1 {
715 return Some((
716 SimpleDate {
717 year: default_year,
718 month,
719 day,
720 },
721 DateFormat::DMmm {
722 sep,
723 month_case: case,
724 month_full: is_full,
725 },
726 ));
727 } else {
728 return Some((
729 SimpleDate {
730 year: default_year,
731 month,
732 day,
733 },
734 DateFormat::MmmD {
735 sep,
736 month_case: case,
737 month_full: is_full,
738 },
739 ));
740 }
741 }
742 }
743 }
744 } else {
745 if let (Some(val0), Some(val1)) = (parse_digits(parts[0]), parse_digits(parts[1])) {
746 let len0 = parts[0].trim().len();
747 let len1 = parts[1].trim().len();
748
749 if len1 == 4 {
750 let month = val0 as u32;
751 let year = val1;
752 if (1..=12).contains(&month) {
753 return Some((
754 SimpleDate {
755 year,
756 month,
757 day: 1,
758 },
759 DateFormat::My { sep, year_len: 4 },
760 ));
761 }
762 } else if len0 == 4 {
763 let year = val0;
764 let month = val1 as u32;
765 if (1..=12).contains(&month) {
766 return Some((
767 SimpleDate {
768 year,
769 month,
770 day: 1,
771 },
772 DateFormat::My { sep, year_len: 4 },
773 ));
774 }
775 } else if locale.date_order == DateOrder::Dmy {
776 let day = val0 as u32;
777 let month = val1 as u32;
778 if (1..=12).contains(&month)
779 && day >= 1
780 && day <= days_in_month(default_year, month)
781 {
782 return Some((
783 SimpleDate {
784 year: default_year,
785 month,
786 day,
787 },
788 DateFormat::Dm { sep },
789 ));
790 }
791 if (1..=12).contains(&(val0 as u32)) && len1 == 2 && val1 > 31 {
792 let year = locale.expand_two_digit_year(val1);
793 return Some((
794 SimpleDate {
795 year,
796 month: val0 as u32,
797 day: 1,
798 },
799 DateFormat::My { sep, year_len: 2 },
800 ));
801 }
802 } else {
803 let month = val0 as u32;
804 let day = val1 as u32;
805 if (1..=12).contains(&month)
806 && day >= 1
807 && day <= days_in_month(default_year, month)
808 {
809 return Some((
810 SimpleDate {
811 year: default_year,
812 month,
813 day,
814 },
815 DateFormat::Md { sep },
816 ));
817 }
818
819 if (1..=12).contains(&month) && len1 == 2 && val1 > 31 {
820 let year = locale.expand_two_digit_year(val1);
821 return Some((
822 SimpleDate {
823 year,
824 month,
825 day: 1,
826 },
827 DateFormat::My { sep, year_len: 2 },
828 ));
829 }
830 }
831 }
832 }
833 }
834 }
835
836 if src_trim.contains(' ') || src_trim.contains(',') {
837 let space_parts: Vec<&str> = src_trim
838 .split([' ', ','])
839 .filter(|p| !p.trim().is_empty())
840 .map(|p| p.trim())
841 .collect();
842
843 if space_parts.len() == 3 {
844 let mut month_word_info = None;
845 for (i, part) in space_parts.iter().enumerate() {
846 if let Some((m, is_full)) = locale.match_month_word(part) {
847 month_word_info = Some((i, m, is_full));
848 break;
849 }
850 }
851
852 if let Some((month_idx, month, is_full)) = month_word_info {
853 let mut digit_parts = Vec::new();
854 for (i, part) in space_parts.iter().enumerate() {
855 if i != month_idx
856 && let Some(val) = parse_digits(part)
857 {
858 digit_parts.push((i, val, part.trim_end_matches('.').len()));
859 }
860 }
861
862 if digit_parts.len() == 2 {
863 let case = detect_case(space_parts[month_idx].trim_end_matches('.'));
864 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
865 let day = digit_parts[0].1 as u32;
866 let year_raw = digit_parts[1].1;
867 let year_len = digit_parts[1].2;
868 let year = if year_len == 2 {
869 locale.expand_two_digit_year(year_raw)
870 } else {
871 year_raw
872 };
873 if day >= 1 && day <= days_in_month(year, month) {
874 return Some((
875 SimpleDate { year, month, day },
876 DateFormat::DMmmY {
877 sep: '-',
878 year_len,
879 month_case: case,
880 month_full: is_full,
881 },
882 ));
883 }
884 }
885
886 if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
887 let day = digit_parts[0].1 as u32;
888 let year_raw = digit_parts[1].1;
889 let year_len = digit_parts[1].2;
890 let year = if year_len == 2 {
891 locale.expand_two_digit_year(year_raw)
892 } else {
893 year_raw
894 };
895 if day >= 1 && day <= days_in_month(year, month) {
896 return Some((
897 SimpleDate { year, month, day },
898 DateFormat::MmmDY {
899 sep: '-',
900 year_len,
901 month_case: case,
902 month_full: is_full,
903 },
904 ));
905 }
906 }
907 }
908 }
909 }
910 }
911
912 None
913}
914
915pub fn date_to_excel_serial(date: SimpleDate) -> f64 {
922 if date.year < 1900 {
923 return 0.0;
924 }
925 let mut days = 0;
926 for y in 1900..date.year {
927 days += if is_leap_year(y) { 366 } else { 365 };
928 }
929 for m in 1..date.month {
930 days += days_in_month(date.year, m) as i32;
931 }
932 days += date.day as i32;
933 if date.year > 1900 || (date.year == 1900 && date.month > 2) {
934 days += 1;
935 }
936 days as f64
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942
943 #[test]
944 fn test_date_parsing_and_format_detection() {
945 let cases: &[(&str, SimpleDate, DateFormat)] = &[
946 (
947 "2026-06-22",
948 SimpleDate {
949 year: 2026,
950 month: 6,
951 day: 22,
952 },
953 DateFormat::Ymd { sep: '-' },
954 ),
955 (
956 "2026/06/22",
957 SimpleDate {
958 year: 2026,
959 month: 6,
960 day: 22,
961 },
962 DateFormat::Ymd { sep: '/' },
963 ),
964 (
965 "06-22-2026",
966 SimpleDate {
967 year: 2026,
968 month: 6,
969 day: 22,
970 },
971 DateFormat::Mdy {
972 sep: '-',
973 year_len: 4,
974 },
975 ),
976 (
977 "22-06-2026",
978 SimpleDate {
979 year: 2026,
980 month: 6,
981 day: 22,
982 },
983 DateFormat::Dmy {
984 sep: '-',
985 year_len: 4,
986 },
987 ),
988 (
989 "06/22/26",
990 SimpleDate {
991 year: 2026,
992 month: 6,
993 day: 22,
994 },
995 DateFormat::Mdy {
996 sep: '/',
997 year_len: 2,
998 },
999 ),
1000 (
1001 "06/22/99",
1002 SimpleDate {
1003 year: 1999,
1004 month: 6,
1005 day: 22,
1006 },
1007 DateFormat::Mdy {
1008 sep: '/',
1009 year_len: 2,
1010 },
1011 ),
1012 (
1013 "22-Jun-2026",
1014 SimpleDate {
1015 year: 2026,
1016 month: 6,
1017 day: 22,
1018 },
1019 DateFormat::DMmmY {
1020 sep: '-',
1021 year_len: 4,
1022 month_case: StringCase::Title,
1023 month_full: false,
1024 },
1025 ),
1026 (
1027 "22-June-2026",
1028 SimpleDate {
1029 year: 2026,
1030 month: 6,
1031 day: 22,
1032 },
1033 DateFormat::DMmmY {
1034 sep: '-',
1035 year_len: 4,
1036 month_case: StringCase::Title,
1037 month_full: true,
1038 },
1039 ),
1040 (
1041 "Jun-22-2026",
1042 SimpleDate {
1043 year: 2026,
1044 month: 6,
1045 day: 22,
1046 },
1047 DateFormat::MmmDY {
1048 sep: '-',
1049 year_len: 4,
1050 month_case: StringCase::Title,
1051 month_full: false,
1052 },
1053 ),
1054 (
1055 "6/22",
1056 SimpleDate {
1057 year: 2026,
1058 month: 6,
1059 day: 22,
1060 },
1061 DateFormat::Md { sep: '/' },
1062 ),
1063 (
1064 "22-Jun",
1065 SimpleDate {
1066 year: 2026,
1067 month: 6,
1068 day: 22,
1069 },
1070 DateFormat::DMmm {
1071 sep: '-',
1072 month_case: StringCase::Title,
1073 month_full: false,
1074 },
1075 ),
1076 (
1077 "Jun-22",
1078 SimpleDate {
1079 year: 2026,
1080 month: 6,
1081 day: 22,
1082 },
1083 DateFormat::MmmD {
1084 sep: '-',
1085 month_case: StringCase::Title,
1086 month_full: false,
1087 },
1088 ),
1089 (
1090 "6/2026",
1091 SimpleDate {
1092 year: 2026,
1093 month: 6,
1094 day: 1,
1095 },
1096 DateFormat::My {
1097 sep: '/',
1098 year_len: 4,
1099 },
1100 ),
1101 (
1102 "Jun-26",
1103 SimpleDate {
1104 year: 2026,
1105 month: 6,
1106 day: 1,
1107 },
1108 DateFormat::MmmY {
1109 sep: '-',
1110 year_len: 2,
1111 month_case: StringCase::Title,
1112 month_full: false,
1113 },
1114 ),
1115 (
1116 "2026-Jun",
1117 SimpleDate {
1118 year: 2026,
1119 month: 6,
1120 day: 1,
1121 },
1122 DateFormat::YMmm {
1123 sep: '-',
1124 year_len: 4,
1125 month_case: StringCase::Title,
1126 month_full: false,
1127 },
1128 ),
1129 ];
1130
1131 for (src, want_date, want_format) in cases {
1132 let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1133 assert_eq!(date, *want_date, "date mismatch for {src}");
1134 assert_eq!(format, *want_format, "format mismatch for {src}");
1135 }
1136 }
1137
1138 #[test]
1139 fn test_locale_aware_date_parsing() {
1140 let de = Locale::de_de();
1141 let gb = Locale::en_gb();
1142 let us = Locale::en_us();
1143
1144 let (date, fmt) = parse_date_with_locale("22.06.2026", &de).unwrap();
1145 assert_eq!(
1146 date,
1147 SimpleDate {
1148 year: 2026,
1149 month: 6,
1150 day: 22
1151 }
1152 );
1153 assert_eq!(
1154 fmt,
1155 DateFormat::Dmy {
1156 sep: '.',
1157 year_len: 4
1158 }
1159 );
1160
1161 let (date, _) = parse_date_with_locale("22. Juni 2026", &de).unwrap();
1162 assert_eq!(
1163 date,
1164 SimpleDate {
1165 year: 2026,
1166 month: 6,
1167 day: 22
1168 }
1169 );
1170
1171 let (date, _) = parse_date_with_locale("05/06/2026", &gb).unwrap();
1172 assert_eq!(
1173 date,
1174 SimpleDate {
1175 year: 2026,
1176 month: 6,
1177 day: 5
1178 }
1179 );
1180
1181 let (date, _) = parse_date_with_locale("05/06/2026", &us).unwrap();
1182 assert_eq!(
1183 date,
1184 SimpleDate {
1185 year: 2026,
1186 month: 5,
1187 day: 6
1188 }
1189 );
1190
1191 let (date, fmt) = parse_date_with_locale("22/6", &gb).unwrap();
1192 assert_eq!(
1193 date,
1194 SimpleDate {
1195 year: 2026,
1196 month: 6,
1197 day: 22
1198 }
1199 );
1200 assert_eq!(fmt, DateFormat::Dm { sep: '/' });
1201
1202 let fr = Locale::fr_fr();
1203 let (date, _) = parse_date_with_locale("14 juillet 2026", &fr).unwrap();
1204 assert_eq!(
1205 date,
1206 SimpleDate {
1207 year: 2026,
1208 month: 7,
1209 day: 14
1210 }
1211 );
1212
1213 let es = Locale::es_es();
1214 let (date, _) = parse_date_with_locale("12 octubre 2026", &es).unwrap();
1215 assert_eq!(
1216 date,
1217 SimpleDate {
1218 year: 2026,
1219 month: 10,
1220 day: 12
1221 }
1222 );
1223 }
1224
1225 #[test]
1226 fn test_format_date_round_trips_the_typed_notation() {
1227 let sources = [
1228 "2026-06-22",
1229 "2026/06/22",
1230 "6/22/26",
1231 "22-Jun-2026",
1232 "22-June-2026",
1233 "Jun-22-2026",
1234 "22-Jun",
1235 "Jun-22",
1236 "6/2026",
1237 "Jun-26",
1238 "2026-Jun",
1239 ];
1240 for src in sources {
1241 let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1242 assert_eq!(
1243 format_date(date, &format),
1244 src,
1245 "round trip failed for {src}"
1246 );
1247 }
1248 }
1249
1250 #[test]
1251 fn test_format_date_normalizes_zero_padding() {
1252 let (date, format) = parse_date("22-06-2026").unwrap();
1253 assert_eq!(format_date(date, &format), "22-6-2026");
1254
1255 let (date, format) = parse_date("06/22/2026").unwrap();
1256 assert_eq!(format_date(date, &format), "6/22/2026");
1257
1258 let (date, format) = parse_date("2026-06-22").unwrap();
1259 assert_eq!(format_date(date, &format), "2026-06-22");
1260 }
1261
1262 #[test]
1263 fn test_format_date_preserves_month_name_case() {
1264 for src in ["22-JUN-2026", "22-jun-2026"] {
1265 let (date, format) = parse_date(src).unwrap();
1266 assert_eq!(format_date(date, &format), src);
1267 }
1268 let (date, format) = parse_date("22-JUN-2026").unwrap();
1269 assert_eq!(format.to_format_code(), "d-mmm-yyyy");
1270 assert_eq!(
1271 render_date_code(date, &format.to_format_code(), StringCase::Title),
1272 "22-Jun-2026"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_render_date_code_does_not_rescan_substituted_month_names() {
1278 let dec = SimpleDate {
1279 year: 2026,
1280 month: 12,
1281 day: 5,
1282 };
1283 assert_eq!(
1284 render_date_code(dec, "mmmm d, yyyy", StringCase::Title),
1285 "December 5, 2026"
1286 );
1287 let may = SimpleDate {
1288 year: 2026,
1289 month: 5,
1290 day: 5,
1291 };
1292 assert_eq!(render_date_code(may, "mmm-yy", StringCase::Title), "May-26");
1293 }
1294
1295 #[test]
1296 fn test_render_date_code_token_widths() {
1297 let d = SimpleDate {
1298 year: 2026,
1299 month: 6,
1300 day: 7,
1301 };
1302 assert_eq!(
1303 render_date_code(d, "yyyy-mm-dd", StringCase::Title),
1304 "2026-06-07"
1305 );
1306 assert_eq!(render_date_code(d, "m/d/yy", StringCase::Title), "6/7/26");
1307 assert_eq!(render_date_code(d, "mmmm", StringCase::Title), "June");
1308 assert_eq!(
1309 render_date_code(d, "[yyyy] week of d", StringCase::Title),
1310 "[2026] week of 7"
1311 );
1312 }
1313
1314 #[test]
1315 fn test_render_date_code_honors_excel_escape_prefix() {
1316 let d = SimpleDate {
1317 year: 2026,
1318 month: 6,
1319 day: 7,
1320 };
1321 assert_eq!(
1322 render_date_code(d, "yyyy\\-mm\\-dd", StringCase::Title),
1323 "2026-06-07"
1324 );
1325 assert_eq!(
1326 render_date_code(d, "d\\-mmm\\-yyyy", StringCase::Title),
1327 "7-Jun-2026"
1328 );
1329 }
1330
1331 #[test]
1332 fn test_is_date_code_rejects_numeric_formats() {
1333 assert!(is_date_code("m/d/yy"));
1334 assert!(is_date_code("yyyy-mm-dd"));
1335 assert!(!is_date_code("0.00"));
1336 assert!(!is_date_code("#,##0"));
1337 assert!(!is_date_code(""));
1338 }
1339
1340 #[test]
1341 fn test_invalid_dates_do_not_parse() {
1342 assert!(parse_date("2026-02-30").is_none());
1343 assert!(parse_date("2025-02-29").is_none());
1344 assert!(parse_date("13/22/2026").is_none());
1345 assert!(parse_date("06-32-2026").is_none());
1346 }
1347
1348 #[test]
1349 fn test_date_to_excel_serial() {
1350 assert_eq!(
1351 date_to_excel_serial(SimpleDate {
1352 year: 1900,
1353 month: 1,
1354 day: 1
1355 }),
1356 1.0
1357 );
1358 assert_eq!(
1359 date_to_excel_serial(SimpleDate {
1360 year: 1900,
1361 month: 3,
1362 day: 1
1363 }),
1364 61.0
1365 );
1366 assert_eq!(
1367 date_to_excel_serial(SimpleDate {
1368 year: 2026,
1369 month: 6,
1370 day: 22
1371 }),
1372 46195.0
1373 );
1374 }
1375
1376 use proptest::prelude::*;
1377
1378 proptest! {
1379 #[test]
1380 fn fuzz_parse_date_never_panics(
1381 s in "\\PC*",
1382 loc_idx in 0..11usize
1383 ) {
1384 let locales = [
1385 Locale::en_us(),
1386 Locale::en_gb(),
1387 Locale::de_de(),
1388 Locale::fr_fr(),
1389 Locale::es_es(),
1390 Locale::it_it(),
1391 Locale::pt_br(),
1392 Locale::nl_nl(),
1393 Locale::ru_ru(),
1394 Locale::zh_cn(),
1395 Locale::ja_jp(),
1396 ];
1397 let loc = &locales[loc_idx];
1398 let _ = parse_date_with_locale(&s, loc);
1399 }
1400
1401 #[test]
1402 fn fuzz_date_parsing_roundtrip_us(
1403 y in 1900..=2099i32,
1404 m in 1..=12u32,
1405 d in 1..=28u32,
1406 sep in "[/\\-]"
1407 ) {
1408 let loc = Locale::en_us();
1409 let src = format!("{}{}{}{}{}", m, sep, d, sep, y);
1410 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid MDY date must parse");
1411 prop_assert_eq!(date.year, y);
1412 prop_assert_eq!(date.month, m);
1413 prop_assert_eq!(date.day, d);
1414 }
1415
1416 #[test]
1417 fn fuzz_date_parsing_roundtrip_gb(
1418 y in 1900..=2099i32,
1419 m in 1..=12u32,
1420 d in 1..=28u32,
1421 sep in "[/\\-]"
1422 ) {
1423 let loc = Locale::en_gb();
1424 let src = format!("{}{}{}{}{}", d, sep, m, sep, y);
1425 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid DMY date must parse");
1426 prop_assert_eq!(date.year, y);
1427 prop_assert_eq!(date.month, m);
1428 prop_assert_eq!(date.day, d);
1429 }
1430
1431 #[test]
1432 fn fuzz_date_parsing_roundtrip_de_dot(
1433 y in 1900..=2099i32,
1434 m in 1..=12u32,
1435 d in 1..=28u32,
1436 ) {
1437 let loc = Locale::de_de();
1438 let src = format!("{}.{}.{}", d, m, y);
1439 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid German dot date must parse");
1440 prop_assert_eq!(date.year, y);
1441 prop_assert_eq!(date.month, m);
1442 prop_assert_eq!(date.day, d);
1443 }
1444
1445 #[test]
1446 fn fuzz_date_parsing_2digit_pivot(
1447 yy in 0..=99i32,
1448 m in 1..=12u32,
1449 d in 1..=28u32
1450 ) {
1451 let loc = Locale::en_us();
1452 let src = format!("{}/{}/{:02}", m, d, yy);
1453 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid 2-digit year date must parse");
1454 let expected_y = if yy <= 29 { 2000 + yy } else { 1900 + yy };
1455 prop_assert_eq!(date.year, expected_y);
1456 prop_assert_eq!(date.month, m);
1457 prop_assert_eq!(date.day, d);
1458 }
1459 }
1460}