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