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}