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}