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        let lower = c.to_ascii_lowercase();
338        if !matches!(lower, 'y' | 'm' | 'd') {
339            out.push(c);
340            i += 1;
341            continue;
342        }
343
344        let mut run = 0;
345        while i + run < chars.len() && chars[i + run].to_ascii_lowercase() == lower {
346            run += 1;
347        }
348        i += run;
349
350        match lower {
351            'y' => {
352                if run <= 2 {
353                    out.push_str(&format!("{:02}", date.year.rem_euclid(100)));
354                } else {
355                    out.push_str(&format!("{:04}", date.year));
356                }
357            }
358            'm' => {
359                let idx = (date.month as usize).saturating_sub(1);
360                match run {
361                    1 => out.push_str(&date.month.to_string()),
362                    2 => out.push_str(&format!("{:02}", date.month)),
363                    3 => out.push_str(&apply_case(
364                        MONTHS_SHORT.get(idx).copied().unwrap_or(""),
365                        month_case,
366                    )),
367                    _ => out.push_str(&apply_case(
368                        MONTHS_FULL.get(idx).copied().unwrap_or(""),
369                        month_case,
370                    )),
371                }
372            }
373            _ => {
374                if run == 1 {
375                    out.push_str(&date.day.to_string());
376                } else {
377                    out.push_str(&format!("{:02}", date.day));
378                }
379            }
380        }
381    }
382    out
383}
384
385/// Renders a date back in the notation [`parse_date`] recognized it in.
386pub fn format_date(date: SimpleDate, format: &DateFormat) -> String {
387    render_date_code(date, &format.to_format_code(), format.month_case())
388}
389
390/// Whether a number-format code renders a date, as opposed to a numeric
391/// format like `0.00` or `#,##0`.
392///
393/// Deliberately narrow: it wants a `y`/`m`/`d` token and no digit placeholder,
394/// so an unrecognized or numeric code falls back to plain number rendering
395/// rather than being mangled into a date.
396pub fn is_date_code(code: &str) -> bool {
397    let has_date_token = code
398        .chars()
399        .any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd'));
400    let has_number_placeholder = code.contains('0') || code.contains('#');
401    has_date_token && !has_number_placeholder
402}
403
404/// The inverse of [`date_to_excel_serial`], for rendering a computed serial.
405pub fn excel_serial_to_date(serial: f64) -> SimpleDate {
406    let (year, month, day) = crate::core::date_fn::serial_to_ymd(serial);
407    SimpleDate {
408        year,
409        month: month.max(0) as u32,
410        day: day.max(0) as u32,
411    }
412}
413
414fn find_month_word(part: &str) -> Option<(u32, bool)> {
415    // returns (month_1_based, is_full_name)
416    let p_lower = part.to_lowercase();
417    for (idx, &m) in MONTHS_FULL.iter().enumerate() {
418        if m.to_lowercase() == p_lower {
419            return Some((idx as u32 + 1, true));
420        }
421    }
422    for (idx, &m) in MONTHS_SHORT.iter().enumerate() {
423        if m.to_lowercase() == p_lower {
424            return Some((idx as u32 + 1, false));
425        }
426    }
427    None
428}
429
430fn parse_digits(part: &str) -> Option<i32> {
431    if !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()) {
432        part.parse::<i32>().ok()
433    } else {
434        None
435    }
436}
437
438/// Recognizes a date written as text, returning both the date and the
439/// notation it was written in.
440///
441/// Returns `None` for anything that is not a date, which is how
442/// `Sheet::commit` decides whether a literal becomes a plain number or a
443/// number carrying a date format. Text that merely *looks* like a date is
444/// therefore quoted on import (`xlsx::text_cell_src`) to keep it text.
445///
446/// Recognizes `-` and `/` as separators, two- and three-part forms, and
447/// month names in either spelling; a two-digit year below 30 is read as
448/// 20xx, otherwise 19xx. Day and month are validated against the calendar,
449/// so `2/30/2026` is not a date.
450pub fn parse_date(src: &str) -> Option<(SimpleDate, DateFormat)> {
451    const DEFAULT_YEAR: i32 = 2026;
452
453    for &sep in &['-', '/'] {
454        let parts: Vec<&str> = src.split(sep).collect();
455
456        // --- 3 PARTS ---
457        if parts.len() == 3 {
458            // Check if there is a word month in the parts
459            let mut month_word_info = None;
460            for (i, part) in parts.iter().enumerate() {
461                if let Some((m, is_full)) = find_month_word(part) {
462                    month_word_info = Some((i, m, is_full));
463                    break;
464                }
465            }
466
467            if let Some((month_idx, month, is_full)) = month_word_info {
468                // If one part is a word month, the other two must be digits
469                let mut digit_parts = Vec::new();
470                for (i, part) in parts.iter().enumerate() {
471                    if i != month_idx
472                        && let Some(val) = parse_digits(part)
473                    {
474                        digit_parts.push((i, val, part.len()));
475                    }
476                }
477
478                if digit_parts.len() == 2 {
479                    let case = detect_case(parts[month_idx]);
480
481                    // Case A: Day-Month-Year (e.g., 22-Jun-2026, 22-Jun-26)
482                    // month_idx is 1. digit_parts[0] is index 0 (day), digit_parts[1] is index 2 (year).
483                    if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
484                        let day = digit_parts[0].1 as u32;
485                        let year_raw = digit_parts[1].1;
486                        let year_len = digit_parts[1].2;
487                        let year = if year_len == 2 {
488                            if year_raw < 30 {
489                                2000 + year_raw
490                            } else {
491                                1900 + year_raw
492                            }
493                        } else {
494                            year_raw
495                        };
496                        if day >= 1 && day <= days_in_month(year, month) {
497                            return Some((
498                                SimpleDate { year, month, day },
499                                DateFormat::DMmmY {
500                                    sep,
501                                    year_len,
502                                    month_case: case,
503                                    month_full: is_full,
504                                },
505                            ));
506                        }
507                    }
508
509                    // Case B: Month-Day-Year (e.g., Jun-22-2026)
510                    // month_idx is 0. digit_parts[0] is index 1 (day), digit_parts[1] is index 2 (year).
511                    if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
512                        let day = digit_parts[0].1 as u32;
513                        let year_raw = digit_parts[1].1;
514                        let year_len = digit_parts[1].2;
515                        let year = if year_len == 2 {
516                            if year_raw < 30 {
517                                2000 + year_raw
518                            } else {
519                                1900 + year_raw
520                            }
521                        } else {
522                            year_raw
523                        };
524                        if day >= 1 && day <= days_in_month(year, month) {
525                            return Some((
526                                SimpleDate { year, month, day },
527                                DateFormat::MmmDY {
528                                    sep,
529                                    year_len,
530                                    month_case: case,
531                                    month_full: is_full,
532                                },
533                            ));
534                        }
535                    }
536
537                    // Case C: Year-Month-Day (e.g. 2026-Jun-22)
538                    // month_idx is 1. digit_parts[0] is index 0 (year), digit_parts[1] is index 2 (day).
539                    if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
540                        let year_raw = digit_parts[0].1;
541                        let year_len = digit_parts[0].2;
542                        let year = if year_len == 2 {
543                            if year_raw < 30 {
544                                2000 + year_raw
545                            } else {
546                                1900 + year_raw
547                            }
548                        } else {
549                            year_raw
550                        };
551                        let day = digit_parts[1].1 as u32;
552                        if day >= 1 && day <= days_in_month(year, month) {
553                            return Some((
554                                SimpleDate { year, month, day },
555                                DateFormat::YMmmD {
556                                    sep,
557                                    year_len,
558                                    month_case: case,
559                                    month_full: is_full,
560                                },
561                            ));
562                        }
563                    }
564                }
565            } else {
566                // All 3 parts are digits (e.g. 2026-06-22, 06-22-2026, 22-06-2026)
567                if let (Some(val0), Some(val1), Some(val2)) = (
568                    parse_digits(parts[0]),
569                    parse_digits(parts[1]),
570                    parse_digits(parts[2]),
571                ) {
572                    let len0 = parts[0].len();
573                    let len2 = parts[2].len();
574
575                    // Option A: YMD (Year first) - len0 == 4
576                    if len0 == 4 {
577                        let year = val0;
578                        let month = val1 as u32;
579                        let day = val2 as u32;
580                        if (1..=12).contains(&month)
581                            && day >= 1
582                            && day <= days_in_month(year, month)
583                        {
584                            return Some((
585                                SimpleDate { year, month, day },
586                                DateFormat::Ymd { sep },
587                            ));
588                        }
589                    }
590
591                    // Option B: MDY or DMY (Year last) - len2 == 4 or 2
592                    if len2 == 4 || len2 == 2 {
593                        let year_raw = val2;
594                        let year = if len2 == 2 {
595                            if year_raw < 30 {
596                                2000 + year_raw
597                            } else {
598                                1900 + year_raw
599                            }
600                        } else {
601                            year_raw
602                        };
603
604                        // Check if MDY or DMY
605                        // If val0 > 12, it must be DMY
606                        if val0 > 12 {
607                            let day = val0 as u32;
608                            let month = val1 as u32;
609                            if (1..=12).contains(&month)
610                                && day >= 1
611                                && day <= days_in_month(year, month)
612                            {
613                                return Some((
614                                    SimpleDate { year, month, day },
615                                    DateFormat::Dmy {
616                                        sep,
617                                        year_len: len2,
618                                    },
619                                ));
620                            }
621                        } else if val1 > 12 {
622                            // If val1 > 12, it must be MDY
623                            let month = val0 as u32;
624                            let day = val1 as u32;
625                            if (1..=12).contains(&month)
626                                && day >= 1
627                                && day <= days_in_month(year, month)
628                            {
629                                return Some((
630                                    SimpleDate { year, month, day },
631                                    DateFormat::Mdy {
632                                        sep,
633                                        year_len: len2,
634                                    },
635                                ));
636                            }
637                        } else {
638                            // Defaults to MDY (standard US locale)
639                            let month = val0 as u32;
640                            let day = val1 as u32;
641                            if (1..=12).contains(&month)
642                                && day >= 1
643                                && day <= days_in_month(year, month)
644                            {
645                                return Some((
646                                    SimpleDate { year, month, day },
647                                    DateFormat::Mdy {
648                                        sep,
649                                        year_len: len2,
650                                    },
651                                ));
652                            }
653                        }
654                    }
655                }
656            }
657        }
658
659        // --- 2 PARTS ---
660        if parts.len() == 2 {
661            // Check if there is a word month in the parts
662            let mut month_word_info = None;
663            for (i, part) in parts.iter().enumerate() {
664                if let Some((m, is_full)) = find_month_word(part) {
665                    month_word_info = Some((i, m, is_full));
666                    break;
667                }
668            }
669
670            if let Some((month_idx, month, is_full)) = month_word_info {
671                let digit_idx = if month_idx == 0 { 1 } else { 0 };
672                if let Some(digit_val) = parse_digits(parts[digit_idx]) {
673                    let digit_len = parts[digit_idx].len();
674                    let case = detect_case(parts[month_idx]);
675
676                    // Case A: Month-Year (e.g. Jun-2026 or Jun-26 or 2026-Jun)
677                    // If digit_len == 4 or digit_val > 31 {
678                    if digit_len == 4 || (digit_len == 2 && digit_val == DEFAULT_YEAR % 100) {
679                        let year = if digit_len == 2 {
680                            if digit_val < 30 {
681                                2000 + digit_val
682                            } else {
683                                1900 + digit_val
684                            }
685                        } else {
686                            digit_val
687                        };
688                        if (1..=12).contains(&month) {
689                            if month_idx == 0 {
690                                return Some((
691                                    SimpleDate {
692                                        year,
693                                        month,
694                                        day: 1,
695                                    },
696                                    DateFormat::MmmY {
697                                        sep,
698                                        year_len: digit_len,
699                                        month_case: case,
700                                        month_full: is_full,
701                                    },
702                                ));
703                            } else {
704                                return Some((
705                                    SimpleDate {
706                                        year,
707                                        month,
708                                        day: 1,
709                                    },
710                                    DateFormat::YMmm {
711                                        sep,
712                                        year_len: digit_len,
713                                        month_case: case,
714                                        month_full: is_full,
715                                    },
716                                ));
717                            }
718                        }
719                    } else {
720                        // Case B: Day-Month or Month-Day (assumes DEFAULT_YEAR)
721                        let day = digit_val as u32;
722                        if day >= 1 && day <= days_in_month(DEFAULT_YEAR, month) {
723                            if month_idx == 1 {
724                                // digit_idx is 0 (Day) -> e.g. 22-Jun
725                                return Some((
726                                    SimpleDate {
727                                        year: DEFAULT_YEAR,
728                                        month,
729                                        day,
730                                    },
731                                    DateFormat::DMmm {
732                                        sep,
733                                        month_case: case,
734                                        month_full: is_full,
735                                    },
736                                ));
737                            } else {
738                                // digit_idx is 1 (Day) -> e.g. Jun-22
739                                return Some((
740                                    SimpleDate {
741                                        year: DEFAULT_YEAR,
742                                        month,
743                                        day,
744                                    },
745                                    DateFormat::MmmD {
746                                        sep,
747                                        month_case: case,
748                                        month_full: is_full,
749                                    },
750                                ));
751                            }
752                        }
753                    }
754                }
755            } else {
756                // All 2 parts are digits (e.g. 6/22, 6/2026)
757                if let (Some(val0), Some(val1)) = (parse_digits(parts[0]), parse_digits(parts[1])) {
758                    let len1 = parts[1].len();
759
760                    // Option A: Month-Year (e.g. 6/2026)
761                    if len1 == 4 {
762                        let month = val0 as u32;
763                        let year = val1;
764                        if (1..=12).contains(&month) {
765                            return Some((
766                                SimpleDate {
767                                    year,
768                                    month,
769                                    day: 1,
770                                },
771                                DateFormat::My { sep, year_len: 4 },
772                            ));
773                        }
774                    } else {
775                        // Option B: Month-Day (assumes DEFAULT_YEAR)
776                        let month = val0 as u32;
777                        let day = 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::Md { sep },
789                            ));
790                        }
791
792                        // Option C: Month-Year with a 2-digit year that isn't a
793                        // valid day (e.g. "1-34" -> Jan 1934), matching Excel's
794                        // fallback when the second part can't be a day.
795                        if (1..=12).contains(&month) && len1 == 2 {
796                            let year = if val1 < 30 { 2000 + val1 } else { 1900 + val1 };
797                            return Some((
798                                SimpleDate {
799                                    year,
800                                    month,
801                                    day: 1,
802                                },
803                                DateFormat::My { sep, year_len: 2 },
804                            ));
805                        }
806                    }
807                }
808            }
809        }
810    }
811    None
812}
813
814/// Converts a date to Excel's day count, where 1 is 1900-01-01.
815///
816/// Reproduces Excel's 1900 leap-year bug -- serial 60 is the nonexistent
817/// 1900-02-29 -- by adding a day for every date after 1900-02-28, which is
818/// what makes serials agree with Excel's for every date a workbook is likely
819/// to contain. Dates before 1900 have no serial and return `0.0`.
820pub fn date_to_excel_serial(date: SimpleDate) -> f64 {
821    if date.year < 1900 {
822        return 0.0;
823    }
824    let mut days = 0;
825    for y in 1900..date.year {
826        days += if is_leap_year(y) { 366 } else { 365 };
827    }
828    for m in 1..date.month {
829        days += days_in_month(date.year, m) as i32;
830    }
831    days += date.day as i32;
832    if date.year > 1900 || (date.year == 1900 && date.month > 2) {
833        days += 1;
834    }
835    days as f64
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841
842    /// Every case pins both the parsed date and the [`DateFormat`] that
843    /// `parse_date` inferred. The format half used to be checked by feeding it
844    /// back through a `format_date` that nothing shipped; asserting the enum
845    /// directly covers the same detection logic without the dead round-trip.
846    #[test]
847    fn test_date_parsing_and_format_detection() {
848        let cases: &[(&str, SimpleDate, DateFormat)] = &[
849            (
850                "2026-06-22",
851                SimpleDate {
852                    year: 2026,
853                    month: 6,
854                    day: 22,
855                },
856                DateFormat::Ymd { sep: '-' },
857            ),
858            (
859                "2026/06/22",
860                SimpleDate {
861                    year: 2026,
862                    month: 6,
863                    day: 22,
864                },
865                DateFormat::Ymd { sep: '/' },
866            ),
867            (
868                "06-22-2026",
869                SimpleDate {
870                    year: 2026,
871                    month: 6,
872                    day: 22,
873                },
874                DateFormat::Mdy {
875                    sep: '-',
876                    year_len: 4,
877                },
878            ),
879            (
880                "22-06-2026",
881                SimpleDate {
882                    year: 2026,
883                    month: 6,
884                    day: 22,
885                },
886                DateFormat::Dmy {
887                    sep: '-',
888                    year_len: 4,
889                },
890            ),
891            (
892                "06/22/26",
893                SimpleDate {
894                    year: 2026,
895                    month: 6,
896                    day: 22,
897                },
898                DateFormat::Mdy {
899                    sep: '/',
900                    year_len: 2,
901                },
902            ),
903            // 2-digit years below the pivot roll back into the 1900s.
904            (
905                "06/22/99",
906                SimpleDate {
907                    year: 1999,
908                    month: 6,
909                    day: 22,
910                },
911                DateFormat::Mdy {
912                    sep: '/',
913                    year_len: 2,
914                },
915            ),
916            (
917                "22-Jun-2026",
918                SimpleDate {
919                    year: 2026,
920                    month: 6,
921                    day: 22,
922                },
923                DateFormat::DMmmY {
924                    sep: '-',
925                    year_len: 4,
926                    month_case: StringCase::Title,
927                    month_full: false,
928                },
929            ),
930            (
931                "22-June-2026",
932                SimpleDate {
933                    year: 2026,
934                    month: 6,
935                    day: 22,
936                },
937                DateFormat::DMmmY {
938                    sep: '-',
939                    year_len: 4,
940                    month_case: StringCase::Title,
941                    month_full: true,
942                },
943            ),
944            (
945                "Jun-22-2026",
946                SimpleDate {
947                    year: 2026,
948                    month: 6,
949                    day: 22,
950                },
951                DateFormat::MmmDY {
952                    sep: '-',
953                    year_len: 4,
954                    month_case: StringCase::Title,
955                    month_full: false,
956                },
957            ),
958            // 2-part forms infer the missing component.
959            (
960                "6/22",
961                SimpleDate {
962                    year: 2026,
963                    month: 6,
964                    day: 22,
965                },
966                DateFormat::Md { sep: '/' },
967            ),
968            (
969                "22-Jun",
970                SimpleDate {
971                    year: 2026,
972                    month: 6,
973                    day: 22,
974                },
975                DateFormat::DMmm {
976                    sep: '-',
977                    month_case: StringCase::Title,
978                    month_full: false,
979                },
980            ),
981            (
982                "Jun-22",
983                SimpleDate {
984                    year: 2026,
985                    month: 6,
986                    day: 22,
987                },
988                DateFormat::MmmD {
989                    sep: '-',
990                    month_case: StringCase::Title,
991                    month_full: false,
992                },
993            ),
994            (
995                "6/2026",
996                SimpleDate {
997                    year: 2026,
998                    month: 6,
999                    day: 1,
1000                },
1001                DateFormat::My {
1002                    sep: '/',
1003                    year_len: 4,
1004                },
1005            ),
1006            (
1007                "Jun-26",
1008                SimpleDate {
1009                    year: 2026,
1010                    month: 6,
1011                    day: 1,
1012                },
1013                DateFormat::MmmY {
1014                    sep: '-',
1015                    year_len: 2,
1016                    month_case: StringCase::Title,
1017                    month_full: false,
1018                },
1019            ),
1020            (
1021                "2026-Jun",
1022                SimpleDate {
1023                    year: 2026,
1024                    month: 6,
1025                    day: 1,
1026                },
1027                DateFormat::YMmm {
1028                    sep: '-',
1029                    year_len: 4,
1030                    month_case: StringCase::Title,
1031                    month_full: false,
1032                },
1033            ),
1034        ];
1035
1036        for (src, want_date, want_format) in cases {
1037            let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1038            assert_eq!(date, *want_date, "date mismatch for {src}");
1039            assert_eq!(format, *want_format, "format mismatch for {src}");
1040        }
1041    }
1042
1043    /// The point of detecting a format at all: a date echoes back in the
1044    /// notation it was typed in. This is the round trip the detection was
1045    /// built for and had no consumer for until `format_date` existed.
1046    #[test]
1047    fn test_format_date_round_trips_the_typed_notation() {
1048        let sources = [
1049            "2026-06-22",
1050            "2026/06/22",
1051            "6/22/26",
1052            "22-Jun-2026",
1053            "22-June-2026",
1054            "Jun-22-2026",
1055            "22-Jun",
1056            "Jun-22",
1057            "6/2026",
1058            "Jun-26",
1059            "2026-Jun",
1060        ];
1061        for src in sources {
1062            let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1063            assert_eq!(
1064                format_date(date, &format),
1065                src,
1066                "round trip failed for {src}"
1067            );
1068        }
1069    }
1070
1071    /// `DateFormat` records the field order and year width but not whether a
1072    /// numeric month or day was zero-padded, so a padded day-first or
1073    /// month-first date comes back unpadded. Excel normalizes the same way
1074    /// (`m/d/yyyy`); the ISO form is padded because its format code is.
1075    #[test]
1076    fn test_format_date_normalizes_zero_padding() {
1077        let (date, format) = parse_date("22-06-2026").unwrap();
1078        assert_eq!(format_date(date, &format), "22-6-2026");
1079
1080        let (date, format) = parse_date("06/22/2026").unwrap();
1081        assert_eq!(format_date(date, &format), "6/22/2026");
1082
1083        // Year-first keeps its padding: the code really is yyyy-mm-dd.
1084        let (date, format) = parse_date("2026-06-22").unwrap();
1085        assert_eq!(format_date(date, &format), "2026-06-22");
1086    }
1087
1088    /// Month-name casing is carried by `DateFormat`, not by the format code,
1089    /// so it survives `format_date` but not the trip through a worksheet
1090    /// `numFmt` -- which is Excel's own behavior.
1091    #[test]
1092    fn test_format_date_preserves_month_name_case() {
1093        for src in ["22-JUN-2026", "22-jun-2026"] {
1094            let (date, format) = parse_date(src).unwrap();
1095            assert_eq!(format_date(date, &format), src);
1096        }
1097        let (date, format) = parse_date("22-JUN-2026").unwrap();
1098        assert_eq!(format.to_format_code(), "d-mmm-yyyy");
1099        assert_eq!(
1100            render_date_code(date, &format.to_format_code(), StringCase::Title),
1101            "22-Jun-2026"
1102        );
1103    }
1104
1105    /// A month *name* contains letters that are themselves format tokens --
1106    /// `December` an `m`, `May` a `y`. The renderer scans runs in one pass
1107    /// precisely so a substituted name is never re-scanned; successive
1108    /// string replacement mangled these.
1109    #[test]
1110    fn test_render_date_code_does_not_rescan_substituted_month_names() {
1111        let dec = SimpleDate {
1112            year: 2026,
1113            month: 12,
1114            day: 5,
1115        };
1116        assert_eq!(
1117            render_date_code(dec, "mmmm d, yyyy", StringCase::Title),
1118            "December 5, 2026"
1119        );
1120        let may = SimpleDate {
1121            year: 2026,
1122            month: 5,
1123            day: 5,
1124        };
1125        assert_eq!(render_date_code(may, "mmm-yy", StringCase::Title), "May-26");
1126    }
1127
1128    #[test]
1129    fn test_render_date_code_token_widths() {
1130        let d = SimpleDate {
1131            year: 2026,
1132            month: 6,
1133            day: 7,
1134        };
1135        assert_eq!(
1136            render_date_code(d, "yyyy-mm-dd", StringCase::Title),
1137            "2026-06-07"
1138        );
1139        assert_eq!(render_date_code(d, "m/d/yy", StringCase::Title), "6/7/26");
1140        assert_eq!(render_date_code(d, "mmmm", StringCase::Title), "June");
1141        // Non-token characters pass through untouched.
1142        assert_eq!(
1143            render_date_code(d, "[yyyy] week of d", StringCase::Title),
1144            "[2026] week of 7"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_is_date_code_rejects_numeric_formats() {
1150        assert!(is_date_code("m/d/yy"));
1151        assert!(is_date_code("yyyy-mm-dd"));
1152        assert!(!is_date_code("0.00"));
1153        assert!(!is_date_code("#,##0"));
1154        assert!(!is_date_code(""));
1155    }
1156
1157    #[test]
1158    fn test_invalid_dates_do_not_parse() {
1159        assert!(parse_date("2026-02-30").is_none());
1160        assert!(parse_date("2025-02-29").is_none()); // non-leap year
1161        assert!(parse_date("13/22/2026").is_none()); // invalid month
1162        assert!(parse_date("06-32-2026").is_none()); // invalid day
1163    }
1164
1165    #[test]
1166    fn test_date_to_excel_serial() {
1167        // Excel's epoch: 1900-01-01 is serial 1.
1168        assert_eq!(
1169            date_to_excel_serial(SimpleDate {
1170                year: 1900,
1171                month: 1,
1172                day: 1
1173            }),
1174            1.0
1175        );
1176        // Excel's deliberate 1900 leap-year bug means 1900-03-01 is 61, not 60.
1177        assert_eq!(
1178            date_to_excel_serial(SimpleDate {
1179                year: 1900,
1180                month: 3,
1181                day: 1
1182            }),
1183            61.0
1184        );
1185        assert_eq!(
1186            date_to_excel_serial(SimpleDate {
1187                year: 2026,
1188                month: 6,
1189                day: 22
1190            }),
1191            46195.0
1192        );
1193    }
1194}