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