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 {
385 let has_date_token = code
386 .chars()
387 .any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd'));
388 let has_number_placeholder = code.contains('0') || code.contains('#');
389 has_date_token && !has_number_placeholder
390}
391
392pub fn excel_serial_to_date(serial: f64) -> SimpleDate {
394 let (year, month, day) = crate::core::date_fn::serial_to_ymd(serial);
395 SimpleDate {
396 year,
397 month: month.max(0) as u32,
398 day: day.max(0) as u32,
399 }
400}
401
402fn parse_digits(part: &str) -> Option<i32> {
403 let trimmed = part.trim().trim_end_matches('.');
404 if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
405 trimmed.parse::<i32>().ok()
406 } else {
407 None
408 }
409}
410
411pub fn parse_date(src: &str) -> Option<(SimpleDate, DateFormat)> {
413 parse_date_with_locale(src, &Locale::en_us())
414}
415
416pub fn parse_date_with_locale(src: &str, locale: &Locale) -> Option<(SimpleDate, DateFormat)> {
423 let default_year = locale.default_year();
424 let src_trim = src.trim();
425 if src_trim.is_empty() {
426 return None;
427 }
428
429 for &sep in &['-', '/', '.'] {
430 let parts: Vec<&str> = src_trim.split(sep).collect();
431
432 if parts.len() == 3 {
433 let mut month_word_info = None;
434 for (i, part) in parts.iter().enumerate() {
435 if let Some((m, is_full)) = locale.match_month_word(part) {
436 month_word_info = Some((i, m, is_full));
437 break;
438 }
439 }
440
441 if let Some((month_idx, month, is_full)) = month_word_info {
442 let mut digit_parts = Vec::new();
443 for (i, part) in parts.iter().enumerate() {
444 if i != month_idx
445 && let Some(val) = parse_digits(part)
446 {
447 digit_parts.push((i, val, part.trim().trim_end_matches('.').len()));
448 }
449 }
450
451 if digit_parts.len() == 2 {
452 let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
453
454 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
455 let day = digit_parts[0].1 as u32;
456 let year_raw = digit_parts[1].1;
457 let year_len = digit_parts[1].2;
458 let year = if year_len == 2 {
459 locale.expand_two_digit_year(year_raw)
460 } else {
461 year_raw
462 };
463 if day >= 1 && day <= days_in_month(year, month) {
464 return Some((
465 SimpleDate { year, month, day },
466 DateFormat::DMmmY {
467 sep,
468 year_len,
469 month_case: case,
470 month_full: is_full,
471 },
472 ));
473 }
474 }
475
476 if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
477 let day = digit_parts[0].1 as u32;
478 let year_raw = digit_parts[1].1;
479 let year_len = digit_parts[1].2;
480 let year = if year_len == 2 {
481 locale.expand_two_digit_year(year_raw)
482 } else {
483 year_raw
484 };
485 if day >= 1 && day <= days_in_month(year, month) {
486 return Some((
487 SimpleDate { year, month, day },
488 DateFormat::MmmDY {
489 sep,
490 year_len,
491 month_case: case,
492 month_full: is_full,
493 },
494 ));
495 }
496 }
497
498 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
499 let year_raw = digit_parts[0].1;
500 let year_len = digit_parts[0].2;
501 let year = if year_len == 2 {
502 locale.expand_two_digit_year(year_raw)
503 } else {
504 year_raw
505 };
506 let day = digit_parts[1].1 as u32;
507 if day >= 1 && day <= days_in_month(year, month) {
508 return Some((
509 SimpleDate { year, month, day },
510 DateFormat::YMmmD {
511 sep,
512 year_len,
513 month_case: case,
514 month_full: is_full,
515 },
516 ));
517 }
518 }
519 }
520 } else {
521 if let (Some(val0), Some(val1), Some(val2)) = (
522 parse_digits(parts[0]),
523 parse_digits(parts[1]),
524 parse_digits(parts[2]),
525 ) {
526 let len0 = parts[0].trim().len();
527 let len2 = parts[2].trim().len();
528
529 if len0 == 4 {
530 let year = val0;
531 let month = val1 as u32;
532 let day = val2 as u32;
533 if (1..=12).contains(&month)
534 && day >= 1
535 && day <= days_in_month(year, month)
536 {
537 return Some((
538 SimpleDate { year, month, day },
539 DateFormat::Ymd { sep },
540 ));
541 }
542 }
543
544 if len2 == 4 || len2 == 2 {
545 let year_raw = val2;
546 let year = if len2 == 2 {
547 locale.expand_two_digit_year(year_raw)
548 } else {
549 year_raw
550 };
551
552 match locale.date_order {
553 DateOrder::Dmy => {
554 let day = val0 as u32;
555 let month = val1 as u32;
556 if (1..=12).contains(&month)
557 && day >= 1
558 && day <= days_in_month(year, month)
559 {
560 return Some((
561 SimpleDate { year, month, day },
562 DateFormat::Dmy {
563 sep,
564 year_len: len2,
565 },
566 ));
567 }
568 }
569 DateOrder::Mdy => {
570 if val0 > 12 && val1 <= 12 {
571 let day = val0 as u32;
572 let month = val1 as u32;
573 if (1..=12).contains(&month)
574 && day >= 1
575 && day <= days_in_month(year, month)
576 {
577 return Some((
578 SimpleDate { year, month, day },
579 DateFormat::Dmy {
580 sep,
581 year_len: len2,
582 },
583 ));
584 }
585 } else {
586 let month = val0 as u32;
587 let day = val1 as u32;
588 if (1..=12).contains(&month)
589 && day >= 1
590 && day <= days_in_month(year, month)
591 {
592 return Some((
593 SimpleDate { year, month, day },
594 DateFormat::Mdy {
595 sep,
596 year_len: len2,
597 },
598 ));
599 }
600 }
601 }
602 DateOrder::Ymd => {
603 if len0 == 2 && len2 <= 2 {
604 let y = locale.expand_two_digit_year(val0);
605 let m = val1 as u32;
606 let d = val2 as u32;
607 if (1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m) {
608 return Some((
609 SimpleDate {
610 year: y,
611 month: m,
612 day: d,
613 },
614 DateFormat::Ymd { sep },
615 ));
616 }
617 }
618 let month = val0 as u32;
619 let day = val1 as u32;
620 if (1..=12).contains(&month)
621 && day >= 1
622 && day <= days_in_month(year, month)
623 {
624 return Some((
625 SimpleDate { year, month, day },
626 DateFormat::Mdy {
627 sep,
628 year_len: len2,
629 },
630 ));
631 }
632 }
633 }
634 }
635 }
636 }
637 }
638
639 if parts.len() == 2 {
640 let mut month_word_info = None;
641 for (i, part) in parts.iter().enumerate() {
642 if let Some((m, is_full)) = locale.match_month_word(part) {
643 month_word_info = Some((i, m, is_full));
644 break;
645 }
646 }
647
648 if let Some((month_idx, month, is_full)) = month_word_info {
649 let digit_idx = if month_idx == 0 { 1 } else { 0 };
650 if let Some(digit_val) = parse_digits(parts[digit_idx]) {
651 let digit_len = parts[digit_idx].trim().trim_end_matches('.').len();
652 let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
653
654 if digit_len == 4 || (digit_len == 2 && digit_val == default_year % 100) {
655 let year = if digit_len == 2 {
656 locale.expand_two_digit_year(digit_val)
657 } else {
658 digit_val
659 };
660 if (1..=12).contains(&month) {
661 if month_idx == 0 {
662 return Some((
663 SimpleDate {
664 year,
665 month,
666 day: 1,
667 },
668 DateFormat::MmmY {
669 sep,
670 year_len: digit_len,
671 month_case: case,
672 month_full: is_full,
673 },
674 ));
675 } else {
676 return Some((
677 SimpleDate {
678 year,
679 month,
680 day: 1,
681 },
682 DateFormat::YMmm {
683 sep,
684 year_len: digit_len,
685 month_case: case,
686 month_full: is_full,
687 },
688 ));
689 }
690 }
691 } else {
692 let day = digit_val as u32;
693 if day >= 1 && day <= days_in_month(default_year, month) {
694 if month_idx == 1 {
695 return Some((
696 SimpleDate {
697 year: default_year,
698 month,
699 day,
700 },
701 DateFormat::DMmm {
702 sep,
703 month_case: case,
704 month_full: is_full,
705 },
706 ));
707 } else {
708 return Some((
709 SimpleDate {
710 year: default_year,
711 month,
712 day,
713 },
714 DateFormat::MmmD {
715 sep,
716 month_case: case,
717 month_full: is_full,
718 },
719 ));
720 }
721 }
722 }
723 }
724 } else {
725 if let (Some(val0), Some(val1)) = (parse_digits(parts[0]), parse_digits(parts[1])) {
726 let len0 = parts[0].trim().len();
727 let len1 = parts[1].trim().len();
728
729 if len1 == 4 {
730 let month = val0 as u32;
731 let year = val1;
732 if (1..=12).contains(&month) {
733 return Some((
734 SimpleDate {
735 year,
736 month,
737 day: 1,
738 },
739 DateFormat::My { sep, year_len: 4 },
740 ));
741 }
742 } else if len0 == 4 {
743 let year = val0;
744 let month = val1 as u32;
745 if (1..=12).contains(&month) {
746 return Some((
747 SimpleDate {
748 year,
749 month,
750 day: 1,
751 },
752 DateFormat::My { sep, year_len: 4 },
753 ));
754 }
755 } else if locale.date_order == DateOrder::Dmy {
756 let day = val0 as u32;
757 let month = val1 as u32;
758 if (1..=12).contains(&month)
759 && day >= 1
760 && day <= days_in_month(default_year, month)
761 {
762 return Some((
763 SimpleDate {
764 year: default_year,
765 month,
766 day,
767 },
768 DateFormat::Dm { sep },
769 ));
770 }
771 if (1..=12).contains(&(val0 as u32)) && len1 == 2 && val1 > 31 {
772 let year = locale.expand_two_digit_year(val1);
773 return Some((
774 SimpleDate {
775 year,
776 month: val0 as u32,
777 day: 1,
778 },
779 DateFormat::My { sep, year_len: 2 },
780 ));
781 }
782 } else {
783 let month = val0 as u32;
784 let day = val1 as u32;
785 if (1..=12).contains(&month)
786 && day >= 1
787 && day <= days_in_month(default_year, month)
788 {
789 return Some((
790 SimpleDate {
791 year: default_year,
792 month,
793 day,
794 },
795 DateFormat::Md { sep },
796 ));
797 }
798
799 if (1..=12).contains(&month) && len1 == 2 && val1 > 31 {
800 let year = locale.expand_two_digit_year(val1);
801 return Some((
802 SimpleDate {
803 year,
804 month,
805 day: 1,
806 },
807 DateFormat::My { sep, year_len: 2 },
808 ));
809 }
810 }
811 }
812 }
813 }
814 }
815
816 if src_trim.contains(' ') || src_trim.contains(',') {
817 let space_parts: Vec<&str> = src_trim
818 .split([' ', ','])
819 .filter(|p| !p.trim().is_empty())
820 .map(|p| p.trim())
821 .collect();
822
823 if space_parts.len() == 3 {
824 let mut month_word_info = None;
825 for (i, part) in space_parts.iter().enumerate() {
826 if let Some((m, is_full)) = locale.match_month_word(part) {
827 month_word_info = Some((i, m, is_full));
828 break;
829 }
830 }
831
832 if let Some((month_idx, month, is_full)) = month_word_info {
833 let mut digit_parts = Vec::new();
834 for (i, part) in space_parts.iter().enumerate() {
835 if i != month_idx
836 && let Some(val) = parse_digits(part)
837 {
838 digit_parts.push((i, val, part.trim_end_matches('.').len()));
839 }
840 }
841
842 if digit_parts.len() == 2 {
843 let case = detect_case(space_parts[month_idx].trim_end_matches('.'));
844 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
845 let day = digit_parts[0].1 as u32;
846 let year_raw = digit_parts[1].1;
847 let year_len = digit_parts[1].2;
848 let year = if year_len == 2 {
849 locale.expand_two_digit_year(year_raw)
850 } else {
851 year_raw
852 };
853 if day >= 1 && day <= days_in_month(year, month) {
854 return Some((
855 SimpleDate { year, month, day },
856 DateFormat::DMmmY {
857 sep: '-',
858 year_len,
859 month_case: case,
860 month_full: is_full,
861 },
862 ));
863 }
864 }
865
866 if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
867 let day = digit_parts[0].1 as u32;
868 let year_raw = digit_parts[1].1;
869 let year_len = digit_parts[1].2;
870 let year = if year_len == 2 {
871 locale.expand_two_digit_year(year_raw)
872 } else {
873 year_raw
874 };
875 if day >= 1 && day <= days_in_month(year, month) {
876 return Some((
877 SimpleDate { year, month, day },
878 DateFormat::MmmDY {
879 sep: '-',
880 year_len,
881 month_case: case,
882 month_full: is_full,
883 },
884 ));
885 }
886 }
887 }
888 }
889 }
890 }
891
892 None
893}
894
895pub fn date_to_excel_serial(date: SimpleDate) -> f64 {
902 if date.year < 1900 {
903 return 0.0;
904 }
905 let mut days = 0;
906 for y in 1900..date.year {
907 days += if is_leap_year(y) { 366 } else { 365 };
908 }
909 for m in 1..date.month {
910 days += days_in_month(date.year, m) as i32;
911 }
912 days += date.day as i32;
913 if date.year > 1900 || (date.year == 1900 && date.month > 2) {
914 days += 1;
915 }
916 days as f64
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922
923 #[test]
926 fn test_date_parsing_and_format_detection() {
927 let cases: &[(&str, SimpleDate, DateFormat)] = &[
928 (
929 "2026-06-22",
930 SimpleDate {
931 year: 2026,
932 month: 6,
933 day: 22,
934 },
935 DateFormat::Ymd { sep: '-' },
936 ),
937 (
938 "2026/06/22",
939 SimpleDate {
940 year: 2026,
941 month: 6,
942 day: 22,
943 },
944 DateFormat::Ymd { sep: '/' },
945 ),
946 (
947 "06-22-2026",
948 SimpleDate {
949 year: 2026,
950 month: 6,
951 day: 22,
952 },
953 DateFormat::Mdy {
954 sep: '-',
955 year_len: 4,
956 },
957 ),
958 (
959 "22-06-2026",
960 SimpleDate {
961 year: 2026,
962 month: 6,
963 day: 22,
964 },
965 DateFormat::Dmy {
966 sep: '-',
967 year_len: 4,
968 },
969 ),
970 (
971 "06/22/26",
972 SimpleDate {
973 year: 2026,
974 month: 6,
975 day: 22,
976 },
977 DateFormat::Mdy {
978 sep: '/',
979 year_len: 2,
980 },
981 ),
982 (
983 "06/22/99",
984 SimpleDate {
985 year: 1999,
986 month: 6,
987 day: 22,
988 },
989 DateFormat::Mdy {
990 sep: '/',
991 year_len: 2,
992 },
993 ),
994 (
995 "22-Jun-2026",
996 SimpleDate {
997 year: 2026,
998 month: 6,
999 day: 22,
1000 },
1001 DateFormat::DMmmY {
1002 sep: '-',
1003 year_len: 4,
1004 month_case: StringCase::Title,
1005 month_full: false,
1006 },
1007 ),
1008 (
1009 "22-June-2026",
1010 SimpleDate {
1011 year: 2026,
1012 month: 6,
1013 day: 22,
1014 },
1015 DateFormat::DMmmY {
1016 sep: '-',
1017 year_len: 4,
1018 month_case: StringCase::Title,
1019 month_full: true,
1020 },
1021 ),
1022 (
1023 "Jun-22-2026",
1024 SimpleDate {
1025 year: 2026,
1026 month: 6,
1027 day: 22,
1028 },
1029 DateFormat::MmmDY {
1030 sep: '-',
1031 year_len: 4,
1032 month_case: StringCase::Title,
1033 month_full: false,
1034 },
1035 ),
1036 (
1037 "6/22",
1038 SimpleDate {
1039 year: 2026,
1040 month: 6,
1041 day: 22,
1042 },
1043 DateFormat::Md { sep: '/' },
1044 ),
1045 (
1046 "22-Jun",
1047 SimpleDate {
1048 year: 2026,
1049 month: 6,
1050 day: 22,
1051 },
1052 DateFormat::DMmm {
1053 sep: '-',
1054 month_case: StringCase::Title,
1055 month_full: false,
1056 },
1057 ),
1058 (
1059 "Jun-22",
1060 SimpleDate {
1061 year: 2026,
1062 month: 6,
1063 day: 22,
1064 },
1065 DateFormat::MmmD {
1066 sep: '-',
1067 month_case: StringCase::Title,
1068 month_full: false,
1069 },
1070 ),
1071 (
1072 "6/2026",
1073 SimpleDate {
1074 year: 2026,
1075 month: 6,
1076 day: 1,
1077 },
1078 DateFormat::My {
1079 sep: '/',
1080 year_len: 4,
1081 },
1082 ),
1083 (
1084 "Jun-26",
1085 SimpleDate {
1086 year: 2026,
1087 month: 6,
1088 day: 1,
1089 },
1090 DateFormat::MmmY {
1091 sep: '-',
1092 year_len: 2,
1093 month_case: StringCase::Title,
1094 month_full: false,
1095 },
1096 ),
1097 (
1098 "2026-Jun",
1099 SimpleDate {
1100 year: 2026,
1101 month: 6,
1102 day: 1,
1103 },
1104 DateFormat::YMmm {
1105 sep: '-',
1106 year_len: 4,
1107 month_case: StringCase::Title,
1108 month_full: false,
1109 },
1110 ),
1111 ];
1112
1113 for (src, want_date, want_format) in cases {
1114 let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1115 assert_eq!(date, *want_date, "date mismatch for {src}");
1116 assert_eq!(format, *want_format, "format mismatch for {src}");
1117 }
1118 }
1119
1120 #[test]
1121 fn test_locale_aware_date_parsing() {
1122 let de = Locale::de_de();
1123 let gb = Locale::en_gb();
1124 let us = Locale::en_us();
1125
1126 let (date, fmt) = parse_date_with_locale("22.06.2026", &de).unwrap();
1127 assert_eq!(
1128 date,
1129 SimpleDate {
1130 year: 2026,
1131 month: 6,
1132 day: 22
1133 }
1134 );
1135 assert_eq!(
1136 fmt,
1137 DateFormat::Dmy {
1138 sep: '.',
1139 year_len: 4
1140 }
1141 );
1142
1143 let (date, _) = parse_date_with_locale("22. Juni 2026", &de).unwrap();
1144 assert_eq!(
1145 date,
1146 SimpleDate {
1147 year: 2026,
1148 month: 6,
1149 day: 22
1150 }
1151 );
1152
1153 let (date, _) = parse_date_with_locale("05/06/2026", &gb).unwrap();
1154 assert_eq!(
1155 date,
1156 SimpleDate {
1157 year: 2026,
1158 month: 6,
1159 day: 5
1160 }
1161 );
1162
1163 let (date, _) = parse_date_with_locale("05/06/2026", &us).unwrap();
1164 assert_eq!(
1165 date,
1166 SimpleDate {
1167 year: 2026,
1168 month: 5,
1169 day: 6
1170 }
1171 );
1172
1173 let (date, fmt) = parse_date_with_locale("22/6", &gb).unwrap();
1174 assert_eq!(
1175 date,
1176 SimpleDate {
1177 year: 2026,
1178 month: 6,
1179 day: 22
1180 }
1181 );
1182 assert_eq!(fmt, DateFormat::Dm { sep: '/' });
1183
1184 let fr = Locale::fr_fr();
1185 let (date, _) = parse_date_with_locale("14 juillet 2026", &fr).unwrap();
1186 assert_eq!(
1187 date,
1188 SimpleDate {
1189 year: 2026,
1190 month: 7,
1191 day: 14
1192 }
1193 );
1194
1195 let es = Locale::es_es();
1196 let (date, _) = parse_date_with_locale("12 octubre 2026", &es).unwrap();
1197 assert_eq!(
1198 date,
1199 SimpleDate {
1200 year: 2026,
1201 month: 10,
1202 day: 12
1203 }
1204 );
1205 }
1206
1207 #[test]
1210 fn test_format_date_round_trips_the_typed_notation() {
1211 let sources = [
1212 "2026-06-22",
1213 "2026/06/22",
1214 "6/22/26",
1215 "22-Jun-2026",
1216 "22-June-2026",
1217 "Jun-22-2026",
1218 "22-Jun",
1219 "Jun-22",
1220 "6/2026",
1221 "Jun-26",
1222 "2026-Jun",
1223 ];
1224 for src in sources {
1225 let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1226 assert_eq!(
1227 format_date(date, &format),
1228 src,
1229 "round trip failed for {src}"
1230 );
1231 }
1232 }
1233
1234 #[test]
1235 fn test_format_date_normalizes_zero_padding() {
1236 let (date, format) = parse_date("22-06-2026").unwrap();
1237 assert_eq!(format_date(date, &format), "22-6-2026");
1238
1239 let (date, format) = parse_date("06/22/2026").unwrap();
1240 assert_eq!(format_date(date, &format), "6/22/2026");
1241
1242 let (date, format) = parse_date("2026-06-22").unwrap();
1243 assert_eq!(format_date(date, &format), "2026-06-22");
1244 }
1245
1246 #[test]
1247 fn test_format_date_preserves_month_name_case() {
1248 for src in ["22-JUN-2026", "22-jun-2026"] {
1249 let (date, format) = parse_date(src).unwrap();
1250 assert_eq!(format_date(date, &format), src);
1251 }
1252 let (date, format) = parse_date("22-JUN-2026").unwrap();
1253 assert_eq!(format.to_format_code(), "d-mmm-yyyy");
1254 assert_eq!(
1255 render_date_code(date, &format.to_format_code(), StringCase::Title),
1256 "22-Jun-2026"
1257 );
1258 }
1259
1260 #[test]
1261 fn test_render_date_code_does_not_rescan_substituted_month_names() {
1262 let dec = SimpleDate {
1263 year: 2026,
1264 month: 12,
1265 day: 5,
1266 };
1267 assert_eq!(
1268 render_date_code(dec, "mmmm d, yyyy", StringCase::Title),
1269 "December 5, 2026"
1270 );
1271 let may = SimpleDate {
1272 year: 2026,
1273 month: 5,
1274 day: 5,
1275 };
1276 assert_eq!(render_date_code(may, "mmm-yy", StringCase::Title), "May-26");
1277 }
1278
1279 #[test]
1280 fn test_render_date_code_token_widths() {
1281 let d = SimpleDate {
1282 year: 2026,
1283 month: 6,
1284 day: 7,
1285 };
1286 assert_eq!(
1287 render_date_code(d, "yyyy-mm-dd", StringCase::Title),
1288 "2026-06-07"
1289 );
1290 assert_eq!(render_date_code(d, "m/d/yy", StringCase::Title), "6/7/26");
1291 assert_eq!(render_date_code(d, "mmmm", StringCase::Title), "June");
1292 assert_eq!(
1293 render_date_code(d, "[yyyy] week of d", StringCase::Title),
1294 "[2026] week of 7"
1295 );
1296 }
1297
1298 #[test]
1299 fn test_render_date_code_honors_excel_escape_prefix() {
1300 let d = SimpleDate {
1301 year: 2026,
1302 month: 6,
1303 day: 7,
1304 };
1305 assert_eq!(
1306 render_date_code(d, "yyyy\\-mm\\-dd", StringCase::Title),
1307 "2026-06-07"
1308 );
1309 assert_eq!(
1310 render_date_code(d, "d\\-mmm\\-yyyy", StringCase::Title),
1311 "7-Jun-2026"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_is_date_code_rejects_numeric_formats() {
1317 assert!(is_date_code("m/d/yy"));
1318 assert!(is_date_code("yyyy-mm-dd"));
1319 assert!(!is_date_code("0.00"));
1320 assert!(!is_date_code("#,##0"));
1321 assert!(!is_date_code(""));
1322 }
1323
1324 #[test]
1325 fn test_invalid_dates_do_not_parse() {
1326 assert!(parse_date("2026-02-30").is_none());
1327 assert!(parse_date("2025-02-29").is_none());
1328 assert!(parse_date("13/22/2026").is_none());
1329 assert!(parse_date("06-32-2026").is_none());
1330 }
1331
1332 #[test]
1333 fn test_date_to_excel_serial() {
1334 assert_eq!(
1335 date_to_excel_serial(SimpleDate {
1336 year: 1900,
1337 month: 1,
1338 day: 1
1339 }),
1340 1.0
1341 );
1342 assert_eq!(
1343 date_to_excel_serial(SimpleDate {
1344 year: 1900,
1345 month: 3,
1346 day: 1
1347 }),
1348 61.0
1349 );
1350 assert_eq!(
1351 date_to_excel_serial(SimpleDate {
1352 year: 2026,
1353 month: 6,
1354 day: 22
1355 }),
1356 46195.0
1357 );
1358 }
1359
1360 use proptest::prelude::*;
1361
1362 proptest! {
1363 #[test]
1364 fn fuzz_parse_date_never_panics(
1365 s in "\\PC*",
1366 loc_idx in 0..11usize
1367 ) {
1368 let locales = [
1369 Locale::en_us(),
1370 Locale::en_gb(),
1371 Locale::de_de(),
1372 Locale::fr_fr(),
1373 Locale::es_es(),
1374 Locale::it_it(),
1375 Locale::pt_br(),
1376 Locale::nl_nl(),
1377 Locale::ru_ru(),
1378 Locale::zh_cn(),
1379 Locale::ja_jp(),
1380 ];
1381 let loc = &locales[loc_idx];
1382 let _ = parse_date_with_locale(&s, loc);
1383 }
1384
1385 #[test]
1386 fn fuzz_date_parsing_roundtrip_us(
1387 y in 1900..=2099i32,
1388 m in 1..=12u32,
1389 d in 1..=28u32,
1390 sep in "[/\\-]"
1391 ) {
1392 let loc = Locale::en_us();
1393 let src = format!("{}{}{}{}{}", m, sep, d, sep, y);
1394 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid MDY date must parse");
1395 prop_assert_eq!(date.year, y);
1396 prop_assert_eq!(date.month, m);
1397 prop_assert_eq!(date.day, d);
1398 }
1399
1400 #[test]
1401 fn fuzz_date_parsing_roundtrip_gb(
1402 y in 1900..=2099i32,
1403 m in 1..=12u32,
1404 d in 1..=28u32,
1405 sep in "[/\\-]"
1406 ) {
1407 let loc = Locale::en_gb();
1408 let src = format!("{}{}{}{}{}", d, sep, m, sep, y);
1409 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid DMY date must parse");
1410 prop_assert_eq!(date.year, y);
1411 prop_assert_eq!(date.month, m);
1412 prop_assert_eq!(date.day, d);
1413 }
1414
1415 #[test]
1416 fn fuzz_date_parsing_roundtrip_de_dot(
1417 y in 1900..=2099i32,
1418 m in 1..=12u32,
1419 d in 1..=28u32,
1420 ) {
1421 let loc = Locale::de_de();
1422 let src = format!("{}.{}.{}", d, m, y);
1423 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid German dot date must parse");
1424 prop_assert_eq!(date.year, y);
1425 prop_assert_eq!(date.month, m);
1426 prop_assert_eq!(date.day, d);
1427 }
1428
1429 #[test]
1430 fn fuzz_date_parsing_2digit_pivot(
1431 yy in 0..=99i32,
1432 m in 1..=12u32,
1433 d in 1..=28u32
1434 ) {
1435 let loc = Locale::en_us();
1436 let src = format!("{}/{}/{:02}", m, d, yy);
1437 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid 2-digit year date must parse");
1438 let expected_y = if yy <= 29 { 2000 + yy } else { 1900 + yy };
1439 prop_assert_eq!(date.year, expected_y);
1440 prop_assert_eq!(date.month, m);
1441 prop_assert_eq!(date.day, d);
1442 }
1443 }
1444}