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