Skip to main content

visi_core/core/
date.rs

1//! Recognizing dates written as text, and converting them to Excel serials.
2//!
3//! [`parse_date`] infers both the date and the [`DateFormat`] it was written
4//! in; [`date_to_excel_serial`] converts to Excel's day count, reproducing the
5//! 1900 leap-year bug.
6//!
7//! The `DateFormat` half is what lets a date cell echo back in the notation it
8//! was typed in, the way Excel does: `6/22/26` stays `6/22/26` rather than
9//! normalizing to ISO. [`DateFormat::to_format_code`] lowers it to an Excel
10//! number-format code and [`render_date_code`] renders that code, so this
11//! module and `text::text_fn`'s `TEXT()` share one date formatter instead of
12//! keeping two.
13//!
14//! The value itself stays a plain numeric serial, as it is in Excel -- the
15//! notation lives on the cell, as `CellStyle::num_format`. `engine::sheet`
16//! records it when it recognizes a literal and renders through it in
17//! `get_display_string`; `xlsx` maps it to and from a worksheet `numFmt`.
18//! Month-name casing is the one detail a format code cannot carry, so it
19//! survives [`format_date`] but not a round trip through a worksheet --
20//! which is Excel's behavior too.
21//!
22//! `DateFormat` records the separator, field order, year width and month-name
23//! spelling, but not whether a numeric month or day was zero-padded --
24//! `06/22/2026` and `6/22/2026` are the same format. Rendering is unpadded
25//! there, which is what Excel also does with `m/d/yyyy`.
26
27use crate::core::locale::{DateOrder, Locale};
28
29const MONTHS_FULL: [&str; 12] = [
30    "January",
31    "February",
32    "March",
33    "April",
34    "May",
35    "June",
36    "July",
37    "August",
38    "September",
39    "October",
40    "November",
41    "December",
42];
43const MONTHS_SHORT: [&str; 12] = [
44    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
45];
46
47/// How a month name was capitalized in the text a date was typed as.
48///
49/// A format code cannot carry casing, so this rides alongside
50/// [`DateFormat::to_format_code`] and is lost on a round trip through a
51/// worksheet -- as it is in Excel.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53pub enum StringCase {
54    /// All lowercase, as in `22-jun-2026`.
55    Lower,
56    /// All uppercase, as in `22-JUN-2026`.
57    Upper,
58    /// Leading capital, rest lowercase: `22-Jun-2026`. The default.
59    Title,
60    /// Mixed in some other way; rendered as the canonical title case.
61    Original,
62}
63
64pub fn detect_case(s: &str) -> StringCase {
65    if s.chars().all(|c| c.is_uppercase()) {
66        StringCase::Upper
67    } else if s.chars().all(|c| c.is_lowercase()) {
68        StringCase::Lower
69    } else {
70        let mut chars = s.chars();
71        if let Some(first) = chars.next()
72            && first.is_uppercase()
73            && chars.all(|c| c.is_lowercase())
74        {
75            return StringCase::Title;
76        }
77        StringCase::Original
78    }
79}
80
81/// A calendar date, with no time-of-day and no timezone.
82///
83/// Only an intermediate: cells hold an Excel serial, not a `SimpleDate`. This
84/// is what [`parse_date`] produces and what [`date_to_excel_serial`] consumes,
85/// so the calendar arithmetic happens in one place.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct SimpleDate {
88    /// Full year, four digits -- a two-digit year is widened by [`parse_date`].
89    pub year: i32,
90    /// Month, 1-12.
91    pub month: u32,
92    /// Day of month, 1-31.
93    pub day: u32,
94}
95
96pub fn is_leap_year(year: i32) -> bool {
97    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
98}
99
100pub fn days_in_month(year: i32, month: u32) -> u32 {
101    match month {
102        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
103        4 | 6 | 9 | 11 => 30,
104        2 => {
105            if is_leap_year(year) {
106                29
107            } else {
108                28
109            }
110        }
111        _ => 0,
112    }
113}
114
115/// The notation a date was written in: field order, separator, year width and
116/// month-name spelling.
117///
118/// This is *detection* output, not the storage form. A cell stores an Excel
119/// serial plus the format code this lowers to (`CellStyle::num_format`), which
120/// is why a `DateFormat` can express a little more than survives a save --
121/// month-name casing has no format-code equivalent, and zero-padding of a
122/// numeric month or day is not recorded at all, so `06/22/2026` and
123/// `6/22/2026` are the same variant and both render unpadded.
124///
125/// The two-part variants fill in the missing field: a month/day pair takes
126/// `parse_date`'s default year, a month/year pair takes day 1.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
128pub enum DateFormat {
129    /// Year-month-day, all numeric: `2026-06-22`.
130    Ymd {
131        /// Character separating the fields, `-` or `/`.
132        sep: char,
133    },
134    /// Month-day-year, all numeric: `06/22/2026`, `6/22/26`.
135    Mdy {
136        /// Character separating the fields, `-` or `/`.
137        sep: char,
138        /// Digits the year was written with: 2 or 4.
139        year_len: usize,
140    },
141    /// Day-month-year, all numeric: `22-06-2026`.
142    Dmy {
143        /// Character separating the fields, `-` or `/`.
144        sep: char,
145        /// Digits the year was written with: 2 or 4.
146        year_len: usize,
147    },
148    /// Day, month name, year: `22-Jun-2026`, `22-June-26`.
149    DMmmY {
150        /// Character separating the fields, `-` or `/`.
151        sep: char,
152        /// Digits the year was written with: 2 or 4.
153        year_len: usize,
154        /// Casing the month name was typed in.
155        month_case: StringCase,
156        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
157        month_full: bool,
158    },
159    /// Month name, day, year: `Jun-22-2026`, `June-22-26`.
160    MmmDY {
161        /// Character separating the fields, `-` or `/`.
162        sep: char,
163        /// Digits the year was written with: 2 or 4.
164        year_len: usize,
165        /// Casing the month name was typed in.
166        month_case: StringCase,
167        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
168        month_full: bool,
169    },
170    /// Year, month name, day: `2026-Jun-22`.
171    YMmmD {
172        /// Character separating the fields, `-` or `/`.
173        sep: char,
174        /// Digits the year was written with: 2 or 4.
175        year_len: usize,
176        /// Casing the month name was typed in.
177        month_case: StringCase,
178        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
179        month_full: bool,
180    },
181
182    /// Numeric month and day, year assumed: `6/22`.
183    Md {
184        /// Character separating the fields, `-` or `/`.
185        sep: char,
186    },
187    /// Numeric day and month, year assumed: `22/6`.
188    Dm {
189        /// Character separating the fields, `-`, `/`, or `.`.
190        sep: char,
191    },
192    /// Numeric month and year, day assumed to be the 1st: `6/2026`.
193    My {
194        /// Character separating the fields, `-` or `/`.
195        sep: char,
196        /// Digits the year was written with: 2 or 4.
197        year_len: usize,
198    },
199    /// Day then month name, year assumed: `22-Jun`.
200    DMmm {
201        /// Character separating the fields, `-` or `/`.
202        sep: char,
203        /// Casing the month name was typed in.
204        month_case: StringCase,
205        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
206        month_full: bool,
207    },
208    /// Month name then day, year assumed: `Jun-22`.
209    MmmD {
210        /// Character separating the fields, `-` or `/`.
211        sep: char,
212        /// Casing the month name was typed in.
213        month_case: StringCase,
214        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
215        month_full: bool,
216    },
217    /// Month name then year, day assumed to be the 1st: `Jun-2026`.
218    MmmY {
219        /// Character separating the fields, `-` or `/`.
220        sep: char,
221        /// Digits the year was written with: 2 or 4.
222        year_len: usize,
223        /// Casing the month name was typed in.
224        month_case: StringCase,
225        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
226        month_full: bool,
227    },
228    /// Year then month name, day assumed to be the 1st: `2026-Jun`.
229    YMmm {
230        /// Character separating the fields, `-` or `/`.
231        sep: char,
232        /// Digits the year was written with: 2 or 4.
233        year_len: usize,
234        /// Casing the month name was typed in.
235        month_case: StringCase,
236        /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
237        month_full: bool,
238    },
239}
240
241impl DateFormat {
242    /// Lowers to an Excel number-format code (`m/d/yy`, `d-mmm-yyyy`, ...).
243    ///
244    /// This is the interchange form: it is what gets written to the worksheet
245    /// as a `numFmt` and what [`render_date_code`] consumes. Month-name casing
246    /// has no representation in a format code, so it rides alongside as
247    /// [`DateFormat::month_case`].
248    pub fn to_format_code(&self) -> String {
249        // A month name is "mmm"/"mmmm"; a numeric month is bare "m" because
250        // `DateFormat` does not record zero-padding.
251        fn month_word(full: bool) -> &'static str {
252            if full { "mmmm" } else { "mmm" }
253        }
254        fn year(len: usize) -> &'static str {
255            if len == 2 { "yy" } else { "yyyy" }
256        }
257
258        match *self {
259            DateFormat::Ymd { sep } => format!("yyyy{sep}mm{sep}dd"),
260            DateFormat::Mdy { sep, year_len } => format!("m{sep}d{sep}{}", year(year_len)),
261            DateFormat::Dmy { sep, year_len } => format!("d{sep}m{sep}{}", year(year_len)),
262            DateFormat::DMmmY {
263                sep,
264                year_len,
265                month_full,
266                ..
267            } => format!("d{sep}{}{sep}{}", month_word(month_full), year(year_len)),
268            DateFormat::MmmDY {
269                sep,
270                year_len,
271                month_full,
272                ..
273            } => format!("{}{sep}d{sep}{}", month_word(month_full), year(year_len)),
274            DateFormat::YMmmD {
275                sep,
276                year_len,
277                month_full,
278                ..
279            } => format!("{}{sep}{}{sep}d", year(year_len), month_word(month_full)),
280            DateFormat::Md { sep } => format!("m{sep}d"),
281            DateFormat::Dm { sep } => format!("d{sep}m"),
282            DateFormat::My { sep, year_len } => format!("m{sep}{}", year(year_len)),
283            DateFormat::DMmm {
284                sep, month_full, ..
285            } => format!("d{sep}{}", month_word(month_full)),
286            DateFormat::MmmD {
287                sep, month_full, ..
288            } => format!("{}{sep}d", month_word(month_full)),
289            DateFormat::MmmY {
290                sep,
291                year_len,
292                month_full,
293                ..
294            } => format!("{}{sep}{}", month_word(month_full), year(year_len)),
295            DateFormat::YMmm {
296                sep,
297                year_len,
298                month_full,
299                ..
300            } => format!("{}{sep}{}", year(year_len), month_word(month_full)),
301        }
302    }
303
304    /// The casing the month name was typed in, for the formats that have one.
305    pub fn month_case(&self) -> StringCase {
306        match *self {
307            DateFormat::DMmmY { month_case, .. }
308            | DateFormat::MmmDY { month_case, .. }
309            | DateFormat::YMmmD { month_case, .. }
310            | DateFormat::DMmm { month_case, .. }
311            | DateFormat::MmmD { month_case, .. }
312            | DateFormat::MmmY { month_case, .. }
313            | DateFormat::YMmm { month_case, .. } => month_case,
314            _ => StringCase::Title,
315        }
316    }
317}
318
319fn apply_case(s: &str, case: StringCase) -> String {
320    match case {
321        StringCase::Upper => s.to_uppercase(),
322        StringCase::Lower => s.to_lowercase(),
323        // Month names are stored title-cased already.
324        StringCase::Title | StringCase::Original => s.to_string(),
325    }
326}
327
328/// Renders a date through an Excel number-format code.
329///
330/// Handles the date tokens visi recognizes: runs of `y` (1-2 -> 2-digit year,
331/// 3+ -> 4-digit), `m` (1 -> bare month, 2 -> zero-padded, 3 -> `Jun`, 4+ ->
332/// `June`) and `d` (1 -> bare day, 2+ -> zero-padded). Anything else is copied
333/// through verbatim, so separators and literal text survive.
334///
335/// Tokens are matched as runs in a single pass rather than by successive
336/// string replacement, which is what keeps a substituted month name from being
337/// re-scanned -- `December` contains an `m` and `May` a `y`.
338pub fn render_date_code(date: SimpleDate, code: &str, month_case: StringCase) -> String {
339    let chars: Vec<char> = code.chars().collect();
340    let mut out = String::with_capacity(code.len() + 8);
341    let mut i = 0;
342
343    while i < chars.len() {
344        let c = chars[i];
345        if c == '\\' {
346            if let Some(next) = chars.get(i + 1) {
347                out.push(*next);
348                i += 2;
349            } else {
350                i += 1;
351            }
352            continue;
353        }
354        let lower = c.to_ascii_lowercase();
355        if !matches!(lower, 'y' | 'm' | 'd') {
356            out.push(c);
357            i += 1;
358            continue;
359        }
360
361        let mut run = 0;
362        while i + run < chars.len() && chars[i + run].to_ascii_lowercase() == lower {
363            run += 1;
364        }
365        i += run;
366
367        match lower {
368            'y' => {
369                if run <= 2 {
370                    out.push_str(&format!("{:02}", date.year.rem_euclid(100)));
371                } else {
372                    out.push_str(&format!("{:04}", date.year));
373                }
374            }
375            'm' => {
376                let idx = (date.month as usize).saturating_sub(1);
377                match run {
378                    1 => out.push_str(&date.month.to_string()),
379                    2 => out.push_str(&format!("{:02}", date.month)),
380                    3 => out.push_str(&apply_case(
381                        MONTHS_SHORT.get(idx).copied().unwrap_or(""),
382                        month_case,
383                    )),
384                    _ => out.push_str(&apply_case(
385                        MONTHS_FULL.get(idx).copied().unwrap_or(""),
386                        month_case,
387                    )),
388                }
389            }
390            _ => {
391                if run == 1 {
392                    out.push_str(&date.day.to_string());
393                } else {
394                    out.push_str(&format!("{:02}", date.day));
395                }
396            }
397        }
398    }
399    out
400}
401
402/// Renders a date back in the notation [`parse_date`] recognized it in.
403pub fn format_date(date: SimpleDate, format: &DateFormat) -> String {
404    render_date_code(date, &format.to_format_code(), format.month_case())
405}
406
407/// Whether a number-format code renders a date, as opposed to a numeric
408/// format like `0.00` or `#,##0`.
409///
410/// Deliberately narrow: it wants a `y`/`m`/`d` token and no digit placeholder,
411/// so an unrecognized or numeric code falls back to plain number rendering
412/// rather than being mangled into a date.
413pub fn is_date_code(code: &str) -> bool {
414    let has_date_token = code
415        .chars()
416        .any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd'));
417    let has_number_placeholder = code.contains('0') || code.contains('#');
418    has_date_token && !has_number_placeholder
419}
420
421/// The inverse of [`date_to_excel_serial`], for rendering a computed serial.
422pub fn excel_serial_to_date(serial: f64) -> SimpleDate {
423    let (year, month, day) = crate::core::date_fn::serial_to_ymd(serial);
424    SimpleDate {
425        year,
426        month: month.max(0) as u32,
427        day: day.max(0) as u32,
428    }
429}
430
431fn parse_digits(part: &str) -> Option<i32> {
432    let trimmed = part.trim().trim_end_matches('.');
433    if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
434        trimmed.parse::<i32>().ok()
435    } else {
436        None
437    }
438}
439
440/// Recognizes a date written as text using the default US locale.
441pub fn parse_date(src: &str) -> Option<(SimpleDate, DateFormat)> {
442    parse_date_with_locale(src, &Locale::en_us())
443}
444
445/// Recognizes a date written as text according to a specific [`Locale`].
446///
447/// Returns `None` for anything that is not a date, which is how
448/// `Sheet::commit` decides whether a literal becomes a plain number or a
449/// number carrying a date format. Text that merely *looks* like a date is
450/// therefore quoted on import (`xlsx::text_cell_src`) to keep it text.
451pub fn parse_date_with_locale(src: &str, locale: &Locale) -> Option<(SimpleDate, DateFormat)> {
452    let default_year = locale.default_year();
453    let src_trim = src.trim();
454    if src_trim.is_empty() {
455        return None;
456    }
457
458    for &sep in &['-', '/', '.'] {
459        let parts: Vec<&str> = src_trim.split(sep).collect();
460
461        // --- 3 PARTS ---
462        if parts.len() == 3 {
463            let mut month_word_info = None;
464            for (i, part) in parts.iter().enumerate() {
465                if let Some((m, is_full)) = locale.match_month_word(part) {
466                    month_word_info = Some((i, m, is_full));
467                    break;
468                }
469            }
470
471            if let Some((month_idx, month, is_full)) = month_word_info {
472                // If one part is a word month, the other two must be digits
473                let mut digit_parts = Vec::new();
474                for (i, part) in parts.iter().enumerate() {
475                    if i != month_idx
476                        && let Some(val) = parse_digits(part)
477                    {
478                        digit_parts.push((i, val, part.trim().trim_end_matches('.').len()));
479                    }
480                }
481
482                if digit_parts.len() == 2 {
483                    let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
484
485                    // Case A: Day-Month-Year (e.g., 22-Jun-2026, 22-Jun-26)
486                    // month_idx is 1. digit_parts[0] is index 0 (day), digit_parts[1] is index 2 (year).
487                    if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
488                        let day = digit_parts[0].1 as u32;
489                        let year_raw = digit_parts[1].1;
490                        let year_len = digit_parts[1].2;
491                        let year = if year_len == 2 {
492                            locale.expand_two_digit_year(year_raw)
493                        } else {
494                            year_raw
495                        };
496                        if day >= 1 && day <= days_in_month(year, month) {
497                            return Some((
498                                SimpleDate { year, month, day },
499                                DateFormat::DMmmY {
500                                    sep,
501                                    year_len,
502                                    month_case: case,
503                                    month_full: is_full,
504                                },
505                            ));
506                        }
507                    }
508
509                    // Case B: Month-Day-Year (e.g., Jun-22-2026)
510                    // month_idx is 0. digit_parts[0] is index 1 (day), digit_parts[1] is index 2 (year).
511                    if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
512                        let day = digit_parts[0].1 as u32;
513                        let year_raw = digit_parts[1].1;
514                        let year_len = digit_parts[1].2;
515                        let year = if year_len == 2 {
516                            locale.expand_two_digit_year(year_raw)
517                        } else {
518                            year_raw
519                        };
520                        if day >= 1 && day <= days_in_month(year, month) {
521                            return Some((
522                                SimpleDate { year, month, day },
523                                DateFormat::MmmDY {
524                                    sep,
525                                    year_len,
526                                    month_case: case,
527                                    month_full: is_full,
528                                },
529                            ));
530                        }
531                    }
532
533                    // Case C: Year-Month-Day (e.g. 2026-Jun-22)
534                    // month_idx is 1. digit_parts[0] is index 0 (year), digit_parts[1] is index 2 (day).
535                    if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
536                        let year_raw = digit_parts[0].1;
537                        let year_len = digit_parts[0].2;
538                        let year = if year_len == 2 {
539                            locale.expand_two_digit_year(year_raw)
540                        } else {
541                            year_raw
542                        };
543                        let day = digit_parts[1].1 as u32;
544                        if day >= 1 && day <= days_in_month(year, month) {
545                            return Some((
546                                SimpleDate { year, month, day },
547                                DateFormat::YMmmD {
548                                    sep,
549                                    year_len,
550                                    month_case: case,
551                                    month_full: is_full,
552                                },
553                            ));
554                        }
555                    }
556                }
557            } else {
558                // All 3 parts are digits (e.g. 2026-06-22, 06-22-2026, 22-06-2026)
559                if let (Some(val0), Some(val1), Some(val2)) = (
560                    parse_digits(parts[0]),
561                    parse_digits(parts[1]),
562                    parse_digits(parts[2]),
563                ) {
564                    let len0 = parts[0].trim().len();
565                    let len2 = parts[2].trim().len();
566
567                    // Option A: YMD (Year first) - len0 == 4
568                    if len0 == 4 {
569                        let year = val0;
570                        let month = val1 as u32;
571                        let day = val2 as u32;
572                        if (1..=12).contains(&month)
573                            && day >= 1
574                            && day <= days_in_month(year, month)
575                        {
576                            return Some((
577                                SimpleDate { year, month, day },
578                                DateFormat::Ymd { sep },
579                            ));
580                        }
581                    }
582
583                    // Option B: MDY or DMY (Year last) - len2 == 4 or 2
584                    if len2 == 4 || len2 == 2 {
585                        let year_raw = val2;
586                        let year = if len2 == 2 {
587                            locale.expand_two_digit_year(year_raw)
588                        } else {
589                            year_raw
590                        };
591
592                        match locale.date_order {
593                            DateOrder::Dmy => {
594                                let day = val0 as u32;
595                                let month = val1 as u32;
596                                if (1..=12).contains(&month)
597                                    && day >= 1
598                                    && day <= days_in_month(year, month)
599                                {
600                                    return Some((
601                                        SimpleDate { year, month, day },
602                                        DateFormat::Dmy {
603                                            sep,
604                                            year_len: len2,
605                                        },
606                                    ));
607                                }
608                            }
609                            DateOrder::Mdy => {
610                                if val0 > 12 && val1 <= 12 {
611                                    // Unambiguous DMY fallback
612                                    let day = val0 as u32;
613                                    let month = val1 as u32;
614                                    if (1..=12).contains(&month)
615                                        && day >= 1
616                                        && day <= days_in_month(year, month)
617                                    {
618                                        return Some((
619                                            SimpleDate { year, month, day },
620                                            DateFormat::Dmy {
621                                                sep,
622                                                year_len: len2,
623                                            },
624                                        ));
625                                    }
626                                } else {
627                                    let month = val0 as u32;
628                                    let day = val1 as u32;
629                                    if (1..=12).contains(&month)
630                                        && day >= 1
631                                        && day <= days_in_month(year, month)
632                                    {
633                                        return Some((
634                                            SimpleDate { year, month, day },
635                                            DateFormat::Mdy {
636                                                sep,
637                                                year_len: len2,
638                                            },
639                                        ));
640                                    }
641                                }
642                            }
643                            DateOrder::Ymd => {
644                                if len0 == 2 && len2 <= 2 {
645                                    let y = locale.expand_two_digit_year(val0);
646                                    let m = val1 as u32;
647                                    let d = val2 as u32;
648                                    if (1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m) {
649                                        return Some((
650                                            SimpleDate {
651                                                year: y,
652                                                month: m,
653                                                day: d,
654                                            },
655                                            DateFormat::Ymd { sep },
656                                        ));
657                                    }
658                                }
659                                let month = val0 as u32;
660                                let day = val1 as u32;
661                                if (1..=12).contains(&month)
662                                    && day >= 1
663                                    && day <= days_in_month(year, month)
664                                {
665                                    return Some((
666                                        SimpleDate { year, month, day },
667                                        DateFormat::Mdy {
668                                            sep,
669                                            year_len: len2,
670                                        },
671                                    ));
672                                }
673                            }
674                        }
675                    }
676                }
677            }
678        }
679
680        // --- 2 PARTS ---
681        if parts.len() == 2 {
682            let mut month_word_info = None;
683            for (i, part) in parts.iter().enumerate() {
684                if let Some((m, is_full)) = locale.match_month_word(part) {
685                    month_word_info = Some((i, m, is_full));
686                    break;
687                }
688            }
689
690            if let Some((month_idx, month, is_full)) = month_word_info {
691                let digit_idx = if month_idx == 0 { 1 } else { 0 };
692                if let Some(digit_val) = parse_digits(parts[digit_idx]) {
693                    let digit_len = parts[digit_idx].trim().trim_end_matches('.').len();
694                    let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
695
696                    // Case A: Month-Year (e.g. Jun-2026 or Jun-26 or 2026-Jun)
697                    if digit_len == 4 || (digit_len == 2 && digit_val == default_year % 100) {
698                        let year = if digit_len == 2 {
699                            locale.expand_two_digit_year(digit_val)
700                        } else {
701                            digit_val
702                        };
703                        if (1..=12).contains(&month) {
704                            if month_idx == 0 {
705                                return Some((
706                                    SimpleDate {
707                                        year,
708                                        month,
709                                        day: 1,
710                                    },
711                                    DateFormat::MmmY {
712                                        sep,
713                                        year_len: digit_len,
714                                        month_case: case,
715                                        month_full: is_full,
716                                    },
717                                ));
718                            } else {
719                                return Some((
720                                    SimpleDate {
721                                        year,
722                                        month,
723                                        day: 1,
724                                    },
725                                    DateFormat::YMmm {
726                                        sep,
727                                        year_len: digit_len,
728                                        month_case: case,
729                                        month_full: is_full,
730                                    },
731                                ));
732                            }
733                        }
734                    } else {
735                        // Case B: Day-Month or Month-Day (assumes default_year)
736                        let day = digit_val as u32;
737                        if day >= 1 && day <= days_in_month(default_year, month) {
738                            if month_idx == 1 {
739                                // digit_idx is 0 (Day) -> e.g. 22-Jun
740                                return Some((
741                                    SimpleDate {
742                                        year: default_year,
743                                        month,
744                                        day,
745                                    },
746                                    DateFormat::DMmm {
747                                        sep,
748                                        month_case: case,
749                                        month_full: is_full,
750                                    },
751                                ));
752                            } else {
753                                // digit_idx is 1 (Day) -> e.g. Jun-22
754                                return Some((
755                                    SimpleDate {
756                                        year: default_year,
757                                        month,
758                                        day,
759                                    },
760                                    DateFormat::MmmD {
761                                        sep,
762                                        month_case: case,
763                                        month_full: is_full,
764                                    },
765                                ));
766                            }
767                        }
768                    }
769                }
770            } else {
771                // All 2 parts are digits (e.g. 6/22, 6/2026)
772                if let (Some(val0), Some(val1)) = (parse_digits(parts[0]), parse_digits(parts[1])) {
773                    let len0 = parts[0].trim().len();
774                    let len1 = parts[1].trim().len();
775
776                    // Option A: Month-Year (e.g. 6/2026)
777                    if len1 == 4 {
778                        let month = val0 as u32;
779                        let year = val1;
780                        if (1..=12).contains(&month) {
781                            return Some((
782                                SimpleDate {
783                                    year,
784                                    month,
785                                    day: 1,
786                                },
787                                DateFormat::My { sep, year_len: 4 },
788                            ));
789                        }
790                    } else if len0 == 4 {
791                        let year = val0;
792                        let month = val1 as u32;
793                        if (1..=12).contains(&month) {
794                            return Some((
795                                SimpleDate {
796                                    year,
797                                    month,
798                                    day: 1,
799                                },
800                                DateFormat::My { sep, year_len: 4 },
801                            ));
802                        }
803                    } else if locale.date_order == DateOrder::Dmy {
804                        let day = val0 as u32;
805                        let month = val1 as u32;
806                        if (1..=12).contains(&month)
807                            && day >= 1
808                            && day <= days_in_month(default_year, month)
809                        {
810                            return Some((
811                                SimpleDate {
812                                    year: default_year,
813                                    month,
814                                    day,
815                                },
816                                DateFormat::Dm { sep },
817                            ));
818                        }
819                        if (1..=12).contains(&(val0 as u32)) && len1 == 2 && val1 > 31 {
820                            let year = locale.expand_two_digit_year(val1);
821                            return Some((
822                                SimpleDate {
823                                    year,
824                                    month: val0 as u32,
825                                    day: 1,
826                                },
827                                DateFormat::My { sep, year_len: 2 },
828                            ));
829                        }
830                    } else {
831                        // Option B: Month-Day (assumes default_year)
832                        let month = val0 as u32;
833                        let day = val1 as u32;
834                        if (1..=12).contains(&month)
835                            && day >= 1
836                            && day <= days_in_month(default_year, month)
837                        {
838                            return Some((
839                                SimpleDate {
840                                    year: default_year,
841                                    month,
842                                    day,
843                                },
844                                DateFormat::Md { sep },
845                            ));
846                        }
847
848                        // Option C: Month-Year with a 2-digit year that isn't a
849                        // valid day (e.g. "1-34" -> Jan 1934), matching Excel's
850                        // fallback when the second part can't be a day.
851                        if (1..=12).contains(&month) && len1 == 2 && val1 > 31 {
852                            let year = locale.expand_two_digit_year(val1);
853                            return Some((
854                                SimpleDate {
855                                    year,
856                                    month,
857                                    day: 1,
858                                },
859                                DateFormat::My { sep, year_len: 2 },
860                            ));
861                        }
862                    }
863                }
864            }
865        }
866    }
867
868    // Also check space/comma-separated strings (e.g. "June 22, 2026", "22 June 2026", "22. Juni 2026")
869    if src_trim.contains(' ') || src_trim.contains(',') {
870        let space_parts: Vec<&str> = src_trim
871            .split([' ', ','])
872            .filter(|p| !p.trim().is_empty())
873            .map(|p| p.trim())
874            .collect();
875
876        if space_parts.len() == 3 {
877            let mut month_word_info = None;
878            for (i, part) in space_parts.iter().enumerate() {
879                if let Some((m, is_full)) = locale.match_month_word(part) {
880                    month_word_info = Some((i, m, is_full));
881                    break;
882                }
883            }
884
885            if let Some((month_idx, month, is_full)) = month_word_info {
886                let mut digit_parts = Vec::new();
887                for (i, part) in space_parts.iter().enumerate() {
888                    if i != month_idx
889                        && let Some(val) = parse_digits(part)
890                    {
891                        digit_parts.push((i, val, part.trim_end_matches('.').len()));
892                    }
893                }
894
895                if digit_parts.len() == 2 {
896                    let case = detect_case(space_parts[month_idx].trim_end_matches('.'));
897                    // e.g. "22 June 2026" or "22. Juni 2026" (month_idx == 1, digit_parts[0] is index 0, digit_parts[1] is index 2)
898                    if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
899                        let day = digit_parts[0].1 as u32;
900                        let year_raw = digit_parts[1].1;
901                        let year_len = digit_parts[1].2;
902                        let year = if year_len == 2 {
903                            locale.expand_two_digit_year(year_raw)
904                        } else {
905                            year_raw
906                        };
907                        if day >= 1 && day <= days_in_month(year, month) {
908                            return Some((
909                                SimpleDate { year, month, day },
910                                DateFormat::DMmmY {
911                                    sep: '-',
912                                    year_len,
913                                    month_case: case,
914                                    month_full: is_full,
915                                },
916                            ));
917                        }
918                    }
919
920                    // e.g. "June 22, 2026" (month_idx == 0, digit_parts[0] is index 1, digit_parts[1] is index 2)
921                    if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
922                        let day = digit_parts[0].1 as u32;
923                        let year_raw = digit_parts[1].1;
924                        let year_len = digit_parts[1].2;
925                        let year = if year_len == 2 {
926                            locale.expand_two_digit_year(year_raw)
927                        } else {
928                            year_raw
929                        };
930                        if day >= 1 && day <= days_in_month(year, month) {
931                            return Some((
932                                SimpleDate { year, month, day },
933                                DateFormat::MmmDY {
934                                    sep: '-',
935                                    year_len,
936                                    month_case: case,
937                                    month_full: is_full,
938                                },
939                            ));
940                        }
941                    }
942                }
943            }
944        }
945    }
946
947    None
948}
949
950/// Converts a date to Excel's day count, where 1 is 1900-01-01.
951///
952/// Reproduces Excel's 1900 leap-year bug -- serial 60 is the nonexistent
953/// 1900-02-29 -- by adding a day for every date after 1900-02-28, which is
954/// what makes serials agree with Excel's for every date a workbook is likely
955/// to contain. Dates before 1900 have no serial and return `0.0`.
956pub fn date_to_excel_serial(date: SimpleDate) -> f64 {
957    if date.year < 1900 {
958        return 0.0;
959    }
960    let mut days = 0;
961    for y in 1900..date.year {
962        days += if is_leap_year(y) { 366 } else { 365 };
963    }
964    for m in 1..date.month {
965        days += days_in_month(date.year, m) as i32;
966    }
967    days += date.day as i32;
968    if date.year > 1900 || (date.year == 1900 && date.month > 2) {
969        days += 1;
970    }
971    days as f64
972}
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    /// Every case pins both the parsed date and the [`DateFormat`] that
979    /// `parse_date` inferred.
980    #[test]
981    fn test_date_parsing_and_format_detection() {
982        let cases: &[(&str, SimpleDate, DateFormat)] = &[
983            (
984                "2026-06-22",
985                SimpleDate {
986                    year: 2026,
987                    month: 6,
988                    day: 22,
989                },
990                DateFormat::Ymd { sep: '-' },
991            ),
992            (
993                "2026/06/22",
994                SimpleDate {
995                    year: 2026,
996                    month: 6,
997                    day: 22,
998                },
999                DateFormat::Ymd { sep: '/' },
1000            ),
1001            (
1002                "06-22-2026",
1003                SimpleDate {
1004                    year: 2026,
1005                    month: 6,
1006                    day: 22,
1007                },
1008                DateFormat::Mdy {
1009                    sep: '-',
1010                    year_len: 4,
1011                },
1012            ),
1013            (
1014                "22-06-2026",
1015                SimpleDate {
1016                    year: 2026,
1017                    month: 6,
1018                    day: 22,
1019                },
1020                DateFormat::Dmy {
1021                    sep: '-',
1022                    year_len: 4,
1023                },
1024            ),
1025            (
1026                "06/22/26",
1027                SimpleDate {
1028                    year: 2026,
1029                    month: 6,
1030                    day: 22,
1031                },
1032                DateFormat::Mdy {
1033                    sep: '/',
1034                    year_len: 2,
1035                },
1036            ),
1037            // 2-digit years below the pivot roll back into the 1900s.
1038            (
1039                "06/22/99",
1040                SimpleDate {
1041                    year: 1999,
1042                    month: 6,
1043                    day: 22,
1044                },
1045                DateFormat::Mdy {
1046                    sep: '/',
1047                    year_len: 2,
1048                },
1049            ),
1050            (
1051                "22-Jun-2026",
1052                SimpleDate {
1053                    year: 2026,
1054                    month: 6,
1055                    day: 22,
1056                },
1057                DateFormat::DMmmY {
1058                    sep: '-',
1059                    year_len: 4,
1060                    month_case: StringCase::Title,
1061                    month_full: false,
1062                },
1063            ),
1064            (
1065                "22-June-2026",
1066                SimpleDate {
1067                    year: 2026,
1068                    month: 6,
1069                    day: 22,
1070                },
1071                DateFormat::DMmmY {
1072                    sep: '-',
1073                    year_len: 4,
1074                    month_case: StringCase::Title,
1075                    month_full: true,
1076                },
1077            ),
1078            (
1079                "Jun-22-2026",
1080                SimpleDate {
1081                    year: 2026,
1082                    month: 6,
1083                    day: 22,
1084                },
1085                DateFormat::MmmDY {
1086                    sep: '-',
1087                    year_len: 4,
1088                    month_case: StringCase::Title,
1089                    month_full: false,
1090                },
1091            ),
1092            // 2-part forms infer the missing component.
1093            (
1094                "6/22",
1095                SimpleDate {
1096                    year: 2026,
1097                    month: 6,
1098                    day: 22,
1099                },
1100                DateFormat::Md { sep: '/' },
1101            ),
1102            (
1103                "22-Jun",
1104                SimpleDate {
1105                    year: 2026,
1106                    month: 6,
1107                    day: 22,
1108                },
1109                DateFormat::DMmm {
1110                    sep: '-',
1111                    month_case: StringCase::Title,
1112                    month_full: false,
1113                },
1114            ),
1115            (
1116                "Jun-22",
1117                SimpleDate {
1118                    year: 2026,
1119                    month: 6,
1120                    day: 22,
1121                },
1122                DateFormat::MmmD {
1123                    sep: '-',
1124                    month_case: StringCase::Title,
1125                    month_full: false,
1126                },
1127            ),
1128            (
1129                "6/2026",
1130                SimpleDate {
1131                    year: 2026,
1132                    month: 6,
1133                    day: 1,
1134                },
1135                DateFormat::My {
1136                    sep: '/',
1137                    year_len: 4,
1138                },
1139            ),
1140            (
1141                "Jun-26",
1142                SimpleDate {
1143                    year: 2026,
1144                    month: 6,
1145                    day: 1,
1146                },
1147                DateFormat::MmmY {
1148                    sep: '-',
1149                    year_len: 2,
1150                    month_case: StringCase::Title,
1151                    month_full: false,
1152                },
1153            ),
1154            (
1155                "2026-Jun",
1156                SimpleDate {
1157                    year: 2026,
1158                    month: 6,
1159                    day: 1,
1160                },
1161                DateFormat::YMmm {
1162                    sep: '-',
1163                    year_len: 4,
1164                    month_case: StringCase::Title,
1165                    month_full: false,
1166                },
1167            ),
1168        ];
1169
1170        for (src, want_date, want_format) in cases {
1171            let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1172            assert_eq!(date, *want_date, "date mismatch for {src}");
1173            assert_eq!(format, *want_format, "format mismatch for {src}");
1174        }
1175    }
1176
1177    #[test]
1178    fn test_locale_aware_date_parsing() {
1179        let de = Locale::de_de();
1180        let gb = Locale::en_gb();
1181        let us = Locale::en_us();
1182
1183        // In German locale: 22.06.2026 is Day=22, Month=6
1184        let (date, fmt) = parse_date_with_locale("22.06.2026", &de).unwrap();
1185        assert_eq!(
1186            date,
1187            SimpleDate {
1188                year: 2026,
1189                month: 6,
1190                day: 22
1191            }
1192        );
1193        assert_eq!(
1194            fmt,
1195            DateFormat::Dmy {
1196                sep: '.',
1197                year_len: 4
1198            }
1199        );
1200
1201        // German month word: 22. Juni 2026
1202        let (date, _) = parse_date_with_locale("22. Juni 2026", &de).unwrap();
1203        assert_eq!(
1204            date,
1205            SimpleDate {
1206                year: 2026,
1207                month: 6,
1208                day: 22
1209            }
1210        );
1211
1212        // In UK locale: 05/06/2026 is 5th June 2026
1213        let (date, _) = parse_date_with_locale("05/06/2026", &gb).unwrap();
1214        assert_eq!(
1215            date,
1216            SimpleDate {
1217                year: 2026,
1218                month: 6,
1219                day: 5
1220            }
1221        );
1222
1223        // In US locale: 05/06/2026 is May 6th 2026
1224        let (date, _) = parse_date_with_locale("05/06/2026", &us).unwrap();
1225        assert_eq!(
1226            date,
1227            SimpleDate {
1228                year: 2026,
1229                month: 5,
1230                day: 6
1231            }
1232        );
1233
1234        // In UK/DE locale: 2-part "22/6" is Day 22, Month 6
1235        let (date, fmt) = parse_date_with_locale("22/6", &gb).unwrap();
1236        assert_eq!(
1237            date,
1238            SimpleDate {
1239                year: 2026,
1240                month: 6,
1241                day: 22
1242            }
1243        );
1244        assert_eq!(fmt, DateFormat::Dm { sep: '/' });
1245
1246        // French month name: "14 juillet 2026"
1247        let fr = Locale::fr_fr();
1248        let (date, _) = parse_date_with_locale("14 juillet 2026", &fr).unwrap();
1249        assert_eq!(
1250            date,
1251            SimpleDate {
1252                year: 2026,
1253                month: 7,
1254                day: 14
1255            }
1256        );
1257
1258        // Spanish month name: "12 de octubre de 2026" or "12 octubre 2026"
1259        let es = Locale::es_es();
1260        let (date, _) = parse_date_with_locale("12 octubre 2026", &es).unwrap();
1261        assert_eq!(
1262            date,
1263            SimpleDate {
1264                year: 2026,
1265                month: 10,
1266                day: 12
1267            }
1268        );
1269    }
1270
1271    /// The point of detecting a format at all: a date echoes back in the
1272    /// notation it was typed in.
1273    #[test]
1274    fn test_format_date_round_trips_the_typed_notation() {
1275        let sources = [
1276            "2026-06-22",
1277            "2026/06/22",
1278            "6/22/26",
1279            "22-Jun-2026",
1280            "22-June-2026",
1281            "Jun-22-2026",
1282            "22-Jun",
1283            "Jun-22",
1284            "6/2026",
1285            "Jun-26",
1286            "2026-Jun",
1287        ];
1288        for src in sources {
1289            let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1290            assert_eq!(
1291                format_date(date, &format),
1292                src,
1293                "round trip failed for {src}"
1294            );
1295        }
1296    }
1297
1298    #[test]
1299    fn test_format_date_normalizes_zero_padding() {
1300        let (date, format) = parse_date("22-06-2026").unwrap();
1301        assert_eq!(format_date(date, &format), "22-6-2026");
1302
1303        let (date, format) = parse_date("06/22/2026").unwrap();
1304        assert_eq!(format_date(date, &format), "6/22/2026");
1305
1306        // Year-first keeps its padding: the code really is yyyy-mm-dd.
1307        let (date, format) = parse_date("2026-06-22").unwrap();
1308        assert_eq!(format_date(date, &format), "2026-06-22");
1309    }
1310
1311    #[test]
1312    fn test_format_date_preserves_month_name_case() {
1313        for src in ["22-JUN-2026", "22-jun-2026"] {
1314            let (date, format) = parse_date(src).unwrap();
1315            assert_eq!(format_date(date, &format), src);
1316        }
1317        let (date, format) = parse_date("22-JUN-2026").unwrap();
1318        assert_eq!(format.to_format_code(), "d-mmm-yyyy");
1319        assert_eq!(
1320            render_date_code(date, &format.to_format_code(), StringCase::Title),
1321            "22-Jun-2026"
1322        );
1323    }
1324
1325    #[test]
1326    fn test_render_date_code_does_not_rescan_substituted_month_names() {
1327        let dec = SimpleDate {
1328            year: 2026,
1329            month: 12,
1330            day: 5,
1331        };
1332        assert_eq!(
1333            render_date_code(dec, "mmmm d, yyyy", StringCase::Title),
1334            "December 5, 2026"
1335        );
1336        let may = SimpleDate {
1337            year: 2026,
1338            month: 5,
1339            day: 5,
1340        };
1341        assert_eq!(render_date_code(may, "mmm-yy", StringCase::Title), "May-26");
1342    }
1343
1344    #[test]
1345    fn test_render_date_code_token_widths() {
1346        let d = SimpleDate {
1347            year: 2026,
1348            month: 6,
1349            day: 7,
1350        };
1351        assert_eq!(
1352            render_date_code(d, "yyyy-mm-dd", StringCase::Title),
1353            "2026-06-07"
1354        );
1355        assert_eq!(render_date_code(d, "m/d/yy", StringCase::Title), "6/7/26");
1356        assert_eq!(render_date_code(d, "mmmm", StringCase::Title), "June");
1357        // Non-token characters pass through untouched.
1358        assert_eq!(
1359            render_date_code(d, "[yyyy] week of d", StringCase::Title),
1360            "[2026] week of 7"
1361        );
1362    }
1363
1364    #[test]
1365    fn test_render_date_code_honors_excel_escape_prefix() {
1366        let d = SimpleDate {
1367            year: 2026,
1368            month: 6,
1369            day: 7,
1370        };
1371        assert_eq!(
1372            render_date_code(d, "yyyy\\-mm\\-dd", StringCase::Title),
1373            "2026-06-07"
1374        );
1375        assert_eq!(
1376            render_date_code(d, "d\\-mmm\\-yyyy", StringCase::Title),
1377            "7-Jun-2026"
1378        );
1379    }
1380
1381    #[test]
1382    fn test_is_date_code_rejects_numeric_formats() {
1383        assert!(is_date_code("m/d/yy"));
1384        assert!(is_date_code("yyyy-mm-dd"));
1385        assert!(!is_date_code("0.00"));
1386        assert!(!is_date_code("#,##0"));
1387        assert!(!is_date_code(""));
1388    }
1389
1390    #[test]
1391    fn test_invalid_dates_do_not_parse() {
1392        assert!(parse_date("2026-02-30").is_none());
1393        assert!(parse_date("2025-02-29").is_none()); // non-leap year
1394        assert!(parse_date("13/22/2026").is_none()); // invalid month
1395        assert!(parse_date("06-32-2026").is_none()); // invalid day
1396    }
1397
1398    #[test]
1399    fn test_date_to_excel_serial() {
1400        // Excel's epoch: 1900-01-01 is serial 1.
1401        assert_eq!(
1402            date_to_excel_serial(SimpleDate {
1403                year: 1900,
1404                month: 1,
1405                day: 1
1406            }),
1407            1.0
1408        );
1409        // Excel's deliberate 1900 leap-year bug means 1900-03-01 is 61, not 60.
1410        assert_eq!(
1411            date_to_excel_serial(SimpleDate {
1412                year: 1900,
1413                month: 3,
1414                day: 1
1415            }),
1416            61.0
1417        );
1418        assert_eq!(
1419            date_to_excel_serial(SimpleDate {
1420                year: 2026,
1421                month: 6,
1422                day: 22
1423            }),
1424            46195.0
1425        );
1426    }
1427
1428    use proptest::prelude::*;
1429
1430    proptest! {
1431        #[test]
1432        fn fuzz_parse_date_never_panics(
1433            s in "\\PC*",
1434            loc_idx in 0..11usize
1435        ) {
1436            let locales = [
1437                Locale::en_us(),
1438                Locale::en_gb(),
1439                Locale::de_de(),
1440                Locale::fr_fr(),
1441                Locale::es_es(),
1442                Locale::it_it(),
1443                Locale::pt_br(),
1444                Locale::nl_nl(),
1445                Locale::ru_ru(),
1446                Locale::zh_cn(),
1447                Locale::ja_jp(),
1448            ];
1449            let loc = &locales[loc_idx];
1450            let _ = parse_date_with_locale(&s, loc);
1451        }
1452
1453        #[test]
1454        fn fuzz_date_parsing_roundtrip_us(
1455            y in 1900..=2099i32,
1456            m in 1..=12u32,
1457            d in 1..=28u32,
1458            sep in "[/\\-]"
1459        ) {
1460            let loc = Locale::en_us();
1461            let src = format!("{}{}{}{}{}", m, sep, d, sep, y);
1462            let (date, _) = parse_date_with_locale(&src, &loc).expect("valid MDY date must parse");
1463            prop_assert_eq!(date.year, y);
1464            prop_assert_eq!(date.month, m);
1465            prop_assert_eq!(date.day, d);
1466        }
1467
1468        #[test]
1469        fn fuzz_date_parsing_roundtrip_gb(
1470            y in 1900..=2099i32,
1471            m in 1..=12u32,
1472            d in 1..=28u32,
1473            sep in "[/\\-]"
1474        ) {
1475            let loc = Locale::en_gb();
1476            let src = format!("{}{}{}{}{}", d, sep, m, sep, y);
1477            let (date, _) = parse_date_with_locale(&src, &loc).expect("valid DMY date must parse");
1478            prop_assert_eq!(date.year, y);
1479            prop_assert_eq!(date.month, m);
1480            prop_assert_eq!(date.day, d);
1481        }
1482
1483        #[test]
1484        fn fuzz_date_parsing_roundtrip_de_dot(
1485            y in 1900..=2099i32,
1486            m in 1..=12u32,
1487            d in 1..=28u32,
1488        ) {
1489            let loc = Locale::de_de();
1490            let src = format!("{}.{}.{}", d, m, y);
1491            let (date, _) = parse_date_with_locale(&src, &loc).expect("valid German dot date must parse");
1492            prop_assert_eq!(date.year, y);
1493            prop_assert_eq!(date.month, m);
1494            prop_assert_eq!(date.day, d);
1495        }
1496
1497        #[test]
1498        fn fuzz_date_parsing_2digit_pivot(
1499            yy in 0..=99i32,
1500            m in 1..=12u32,
1501            d in 1..=28u32
1502        ) {
1503            let loc = Locale::en_us();
1504            let src = format!("{}/{}/{:02}", m, d, yy);
1505            let (date, _) = parse_date_with_locale(&src, &loc).expect("valid 2-digit year date must parse");
1506            let expected_y = if yy <= 29 { 2000 + yy } else { 1900 + yy };
1507            prop_assert_eq!(date.year, expected_y);
1508            prop_assert_eq!(date.month, m);
1509            prop_assert_eq!(date.day, d);
1510        }
1511    }
1512}