Skip to main content

visi_core/core/
date.rs

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