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