Skip to main content

visi_core/core/
date.rs

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