Skip to main content

visi_core/core/
date.rs

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/// How a month name was capitalized in the text a date was typed as.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub enum StringCase {
24    /// All lowercase, as in `22-jun-2026`.
25    Lower,
26    /// All uppercase, as in `22-JUN-2026`.
27    Upper,
28    /// Leading capital, rest lowercase: `22-Jun-2026`. The default.
29    Title,
30    /// Mixed in some other way; rendered as the canonical title case.
31    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/// A calendar date, with no time-of-day and no timezone.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct SimpleDate {
54    /// Full year, four digits -- a two-digit year is widened by [`parse_date`].
55    pub year: i32,
56    /// Month, 1-12.
57    pub month: u32,
58    /// Day of month, 1-31.
59    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/// The notation a date was written in
82#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
83pub enum DateFormat {
84    /// Year-month-day, all numeric: `2026-06-22`
85    Ymd {
86        /// Character separating the fields, `-` or `/`
87        sep: char,
88    },
89    /// Month-day-year, all numeric: `06/22/2026`, `6/22/26`.
90    Mdy {
91        /// Character separating the fields, `-` or `/`.
92        sep: char,
93        /// Digits the year was written with: 2 or 4.
94        year_len: usize,
95    },
96    /// Day-month-year, all numeric: `22-06-2026`.
97    Dmy {
98        /// Character separating the fields, `-` or `/`.
99        sep: char,
100        /// Digits the year was written with: 2 or 4.
101        year_len: usize,
102    },
103    /// Day, month name, year: `22-Jun-2026`, `22-June-26`.
104    DMmmY {
105        /// Character separating the fields, `-` or `/`.
106        sep: char,
107        /// Digits the year was written with: 2 or 4.
108        year_len: usize,
109        /// Casing the month name was typed in.
110        month_case: StringCase,
111        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
112        month_full: bool,
113    },
114    /// Month name, day, year: `Jun-22-2026`, `June-22-26`.
115    MmmDY {
116        /// Character separating the fields, `-` or `/`.
117        sep: char,
118        /// Digits the year was written with: 2 or 4.
119        year_len: usize,
120        /// Casing the month name was typed in.
121        month_case: StringCase,
122        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
123        month_full: bool,
124    },
125    /// Year, month name, day: `2026-Jun-22`.
126    YMmmD {
127        /// Character separating the fields, `-` or `/`.
128        sep: char,
129        /// Digits the year was written with: 2 or 4.
130        year_len: usize,
131        /// Casing the month name was typed in.
132        month_case: StringCase,
133        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
134        month_full: bool,
135    },
136
137    /// Numeric month and day, year assumed: `6/22`.
138    Md {
139        /// Character separating the fields, `-` or `/`.
140        sep: char,
141    },
142    /// Numeric day and month, year assumed: `22/6`.
143    Dm {
144        /// Character separating the fields, `-`, `/`, or `.`.
145        sep: char,
146    },
147    /// Numeric month and year, day assumed to be the 1st: `6/2026`.
148    My {
149        /// Character separating the fields, `-` or `/`.
150        sep: char,
151        /// Digits the year was written with: 2 or 4.
152        year_len: usize,
153    },
154    /// Day then month name, year assumed: `22-Jun`.
155    DMmm {
156        /// Character separating the fields, `-` or `/`.
157        sep: char,
158        /// Casing the month name was typed in.
159        month_case: StringCase,
160        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
161        month_full: bool,
162    },
163    /// Month name then day, year assumed: `Jun-22`.
164    MmmD {
165        /// Character separating the fields, `-` or `/`.
166        sep: char,
167        /// Casing the month name was typed in.
168        month_case: StringCase,
169        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
170        month_full: bool,
171    },
172    /// Month name then year, day assumed to be the 1st: `Jun-2026`.
173    MmmY {
174        /// Character separating the fields, `-` or `/`.
175        sep: char,
176        /// Digits the year was written with: 2 or 4.
177        year_len: usize,
178        /// Casing the month name was typed in.
179        month_case: StringCase,
180        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
181        month_full: bool,
182    },
183    /// Year then month name, day assumed to be the 1st: `2026-Jun`.
184    YMmm {
185        /// Character separating the fields, `-` or `/`.
186        sep: char,
187        /// Digits the year was written with: 2 or 4.
188        year_len: usize,
189        /// Casing the month name was typed in.
190        month_case: StringCase,
191        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
192        month_full: bool,
193    },
194}
195
196impl DateFormat {
197    /// Lowers to an Excel number-format code (`m/d/yy`, `d-mmm-yyyy`, ...)
198    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    /// The casing the month name was typed in
253    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
275/// Renders a date through an Excel number-format code
276pub 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
340/// Renders a date back in the notation [`parse_date`] recognized it in.
341pub fn format_date(date: SimpleDate, format: &DateFormat) -> String {
342    render_date_code(date, &format.to_format_code(), format.month_case())
343}
344
345/// Whether a number-format code renders a date rather than a numeric or time value.
346pub 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
379/// The inverse of [`date_to_excel_serial`], for rendering a computed serial.
380pub 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
398/// Recognizes a date written as text using the default US locale.
399pub fn parse_date(src: &str) -> Option<(SimpleDate, DateFormat)> {
400    parse_date_with_locale(src, &Locale::en_us())
401}
402
403/// Recognizes a date written as text according to a specific [`Locale`].
404pub 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
877/// Converts a date to Excel's day count, where 1 is 1900-01-01.
878///
879/// Reproduces Excel's 1900 leap-year bug -- serial 60 is the nonexistent
880/// 1900-02-29 -- by adding a day for every date after 1900-02-28, which is
881/// what makes serials agree with Excel's for every date a workbook is likely
882/// to contain. Dates before 1900 have no serial and return `0.0`.
883pub 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_parse_date_accepts_single_digit_trailing_year() {
1226        let (date, _) = parse_date("1-3-4").unwrap();
1227        assert_eq!(date.year, 2004);
1228        assert_eq!(date.month, 1);
1229        assert_eq!(date.day, 3);
1230    }
1231
1232    #[test]
1233    fn test_format_date_preserves_month_name_case() {
1234        for src in ["22-JUN-2026", "22-jun-2026"] {
1235            let (date, format) = parse_date(src).unwrap();
1236            assert_eq!(format_date(date, &format), src);
1237        }
1238        let (date, format) = parse_date("22-JUN-2026").unwrap();
1239        assert_eq!(format.to_format_code(), "d-mmm-yyyy");
1240        assert_eq!(
1241            render_date_code(date, &format.to_format_code(), StringCase::Title),
1242            "22-Jun-2026"
1243        );
1244    }
1245
1246    #[test]
1247    fn test_render_date_code_does_not_rescan_substituted_month_names() {
1248        let dec = SimpleDate {
1249            year: 2026,
1250            month: 12,
1251            day: 5,
1252        };
1253        assert_eq!(
1254            render_date_code(dec, "mmmm d, yyyy", StringCase::Title),
1255            "December 5, 2026"
1256        );
1257        let may = SimpleDate {
1258            year: 2026,
1259            month: 5,
1260            day: 5,
1261        };
1262        assert_eq!(render_date_code(may, "mmm-yy", StringCase::Title), "May-26");
1263    }
1264
1265    #[test]
1266    fn test_render_date_code_token_widths() {
1267        let d = SimpleDate {
1268            year: 2026,
1269            month: 6,
1270            day: 7,
1271        };
1272        assert_eq!(
1273            render_date_code(d, "yyyy-mm-dd", StringCase::Title),
1274            "2026-06-07"
1275        );
1276        assert_eq!(render_date_code(d, "m/d/yy", StringCase::Title), "6/7/26");
1277        assert_eq!(render_date_code(d, "mmmm", StringCase::Title), "June");
1278        assert_eq!(
1279            render_date_code(d, "[yyyy] week of d", StringCase::Title),
1280            "[2026] week of 7"
1281        );
1282    }
1283
1284    #[test]
1285    fn test_render_date_code_honors_excel_escape_prefix() {
1286        let d = SimpleDate {
1287            year: 2026,
1288            month: 6,
1289            day: 7,
1290        };
1291        assert_eq!(
1292            render_date_code(d, "yyyy\\-mm\\-dd", StringCase::Title),
1293            "2026-06-07"
1294        );
1295        assert_eq!(
1296            render_date_code(d, "d\\-mmm\\-yyyy", StringCase::Title),
1297            "7-Jun-2026"
1298        );
1299    }
1300
1301    #[test]
1302    fn test_is_date_code_rejects_numeric_formats() {
1303        assert!(is_date_code("m/d/yy"));
1304        assert!(is_date_code("yyyy-mm-dd"));
1305        assert!(!is_date_code("0.00"));
1306        assert!(!is_date_code("#,##0"));
1307        assert!(!is_date_code(""));
1308    }
1309
1310    #[test]
1311    fn test_invalid_dates_do_not_parse() {
1312        assert!(parse_date("2026-02-30").is_none());
1313        assert!(parse_date("2025-02-29").is_none());
1314        assert!(parse_date("13/22/2026").is_none());
1315        assert!(parse_date("06-32-2026").is_none());
1316    }
1317
1318    #[test]
1319    fn test_date_to_excel_serial() {
1320        assert_eq!(
1321            date_to_excel_serial(SimpleDate {
1322                year: 1900,
1323                month: 1,
1324                day: 1
1325            }),
1326            1.0
1327        );
1328        assert_eq!(
1329            date_to_excel_serial(SimpleDate {
1330                year: 1900,
1331                month: 3,
1332                day: 1
1333            }),
1334            61.0
1335        );
1336        assert_eq!(
1337            date_to_excel_serial(SimpleDate {
1338                year: 2026,
1339                month: 6,
1340                day: 22
1341            }),
1342            46195.0
1343        );
1344    }
1345
1346    use proptest::prelude::*;
1347
1348    proptest! {
1349        #[test]
1350        fn fuzz_parse_date_never_panics(
1351            s in "\\PC*",
1352            loc_idx in 0..11usize
1353        ) {
1354            let locales = [
1355                Locale::en_us(),
1356                Locale::en_gb(),
1357                Locale::de_de(),
1358                Locale::fr_fr(),
1359                Locale::es_es(),
1360                Locale::it_it(),
1361                Locale::pt_br(),
1362                Locale::nl_nl(),
1363                Locale::ru_ru(),
1364                Locale::zh_cn(),
1365                Locale::ja_jp(),
1366            ];
1367            let loc = &locales[loc_idx];
1368            let _ = parse_date_with_locale(&s, loc);
1369        }
1370
1371        #[test]
1372        fn fuzz_date_parsing_roundtrip_us(
1373            y in 1900..=2099i32,
1374            m in 1..=12u32,
1375            d in 1..=28u32,
1376            sep in "[/\\-]"
1377        ) {
1378            let loc = Locale::en_us();
1379            let src = format!("{}{}{}{}{}", m, sep, d, sep, y);
1380            let (date, _) = parse_date_with_locale(&src, &loc).expect("valid MDY date must parse");
1381            prop_assert_eq!(date.year, y);
1382            prop_assert_eq!(date.month, m);
1383            prop_assert_eq!(date.day, d);
1384        }
1385
1386        #[test]
1387        fn fuzz_date_parsing_roundtrip_gb(
1388            y in 1900..=2099i32,
1389            m in 1..=12u32,
1390            d in 1..=28u32,
1391            sep in "[/\\-]"
1392        ) {
1393            let loc = Locale::en_gb();
1394            let src = format!("{}{}{}{}{}", d, sep, m, sep, y);
1395            let (date, _) = parse_date_with_locale(&src, &loc).expect("valid DMY date must parse");
1396            prop_assert_eq!(date.year, y);
1397            prop_assert_eq!(date.month, m);
1398            prop_assert_eq!(date.day, d);
1399        }
1400
1401        #[test]
1402        fn fuzz_date_parsing_roundtrip_de_dot(
1403            y in 1900..=2099i32,
1404            m in 1..=12u32,
1405            d in 1..=28u32,
1406        ) {
1407            let loc = Locale::de_de();
1408            let src = format!("{}.{}.{}", d, m, y);
1409            let (date, _) = parse_date_with_locale(&src, &loc).expect("valid German dot 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_2digit_pivot(
1417            yy in 0..=99i32,
1418            m in 1..=12u32,
1419            d in 1..=28u32
1420        ) {
1421            let loc = Locale::en_us();
1422            let src = format!("{}/{}/{:02}", m, d, yy);
1423            let (date, _) = parse_date_with_locale(&src, &loc).expect("valid 2-digit year date must parse");
1424            let expected_y = if yy <= 29 { 2000 + yy } else { 1900 + yy };
1425            prop_assert_eq!(date.year, expected_y);
1426            prop_assert_eq!(date.month, m);
1427            prop_assert_eq!(date.day, d);
1428        }
1429    }
1430}