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
27use crate::core::locale::{DateOrder, Locale};
28
29const MONTHS_FULL: [&str; 12] = [
30 "January",
31 "February",
32 "March",
33 "April",
34 "May",
35 "June",
36 "July",
37 "August",
38 "September",
39 "October",
40 "November",
41 "December",
42];
43const MONTHS_SHORT: [&str; 12] = [
44 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
45];
46
47/// How a month name was capitalized in the text a date was typed as.
48///
49/// A format code cannot carry casing, so this rides alongside
50/// [`DateFormat::to_format_code`] and is lost on a round trip through a
51/// worksheet -- as it is in Excel.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53pub enum StringCase {
54 /// All lowercase, as in `22-jun-2026`.
55 Lower,
56 /// All uppercase, as in `22-JUN-2026`.
57 Upper,
58 /// Leading capital, rest lowercase: `22-Jun-2026`. The default.
59 Title,
60 /// Mixed in some other way; rendered as the canonical title case.
61 Original,
62}
63
64pub fn detect_case(s: &str) -> StringCase {
65 if s.chars().all(|c| c.is_uppercase()) {
66 StringCase::Upper
67 } else if s.chars().all(|c| c.is_lowercase()) {
68 StringCase::Lower
69 } else {
70 let mut chars = s.chars();
71 if let Some(first) = chars.next()
72 && first.is_uppercase()
73 && chars.all(|c| c.is_lowercase())
74 {
75 return StringCase::Title;
76 }
77 StringCase::Original
78 }
79}
80
81/// A calendar date, with no time-of-day and no timezone.
82///
83/// Only an intermediate: cells hold an Excel serial, not a `SimpleDate`. This
84/// is what [`parse_date`] produces and what [`date_to_excel_serial`] consumes,
85/// so the calendar arithmetic happens in one place.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct SimpleDate {
88 /// Full year, four digits -- a two-digit year is widened by [`parse_date`].
89 pub year: i32,
90 /// Month, 1-12.
91 pub month: u32,
92 /// Day of month, 1-31.
93 pub day: u32,
94}
95
96pub fn is_leap_year(year: i32) -> bool {
97 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
98}
99
100pub fn days_in_month(year: i32, month: u32) -> u32 {
101 match month {
102 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
103 4 | 6 | 9 | 11 => 30,
104 2 => {
105 if is_leap_year(year) {
106 29
107 } else {
108 28
109 }
110 }
111 _ => 0,
112 }
113}
114
115/// The notation a date was written in: field order, separator, year width and
116/// month-name spelling.
117///
118/// This is *detection* output, not the storage form. A cell stores an Excel
119/// serial plus the format code this lowers to (`CellStyle::num_format`), which
120/// is why a `DateFormat` can express a little more than survives a save --
121/// month-name casing has no format-code equivalent, and zero-padding of a
122/// numeric month or day is not recorded at all, so `06/22/2026` and
123/// `6/22/2026` are the same variant and both render unpadded.
124///
125/// The two-part variants fill in the missing field: a month/day pair takes
126/// `parse_date`'s default year, a month/year pair takes day 1.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
128pub enum DateFormat {
129 /// Year-month-day, all numeric: `2026-06-22`.
130 Ymd {
131 /// Character separating the fields, `-` or `/`.
132 sep: char,
133 },
134 /// Month-day-year, all numeric: `06/22/2026`, `6/22/26`.
135 Mdy {
136 /// Character separating the fields, `-` or `/`.
137 sep: char,
138 /// Digits the year was written with: 2 or 4.
139 year_len: usize,
140 },
141 /// Day-month-year, all numeric: `22-06-2026`.
142 Dmy {
143 /// Character separating the fields, `-` or `/`.
144 sep: char,
145 /// Digits the year was written with: 2 or 4.
146 year_len: usize,
147 },
148 /// Day, month name, year: `22-Jun-2026`, `22-June-26`.
149 DMmmY {
150 /// Character separating the fields, `-` or `/`.
151 sep: char,
152 /// Digits the year was written with: 2 or 4.
153 year_len: usize,
154 /// Casing the month name was typed in.
155 month_case: StringCase,
156 /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
157 month_full: bool,
158 },
159 /// Month name, day, year: `Jun-22-2026`, `June-22-26`.
160 MmmDY {
161 /// Character separating the fields, `-` or `/`.
162 sep: char,
163 /// Digits the year was written with: 2 or 4.
164 year_len: usize,
165 /// Casing the month name was typed in.
166 month_case: StringCase,
167 /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
168 month_full: bool,
169 },
170 /// Year, month name, day: `2026-Jun-22`.
171 YMmmD {
172 /// Character separating the fields, `-` or `/`.
173 sep: char,
174 /// Digits the year was written with: 2 or 4.
175 year_len: usize,
176 /// Casing the month name was typed in.
177 month_case: StringCase,
178 /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
179 month_full: bool,
180 },
181
182 /// Numeric month and day, year assumed: `6/22`.
183 Md {
184 /// Character separating the fields, `-` or `/`.
185 sep: char,
186 },
187 /// Numeric day and month, year assumed: `22/6`.
188 Dm {
189 /// Character separating the fields, `-`, `/`, or `.`.
190 sep: char,
191 },
192 /// Numeric month and year, day assumed to be the 1st: `6/2026`.
193 My {
194 /// Character separating the fields, `-` or `/`.
195 sep: char,
196 /// Digits the year was written with: 2 or 4.
197 year_len: usize,
198 },
199 /// Day then month name, year assumed: `22-Jun`.
200 DMmm {
201 /// Character separating the fields, `-` or `/`.
202 sep: char,
203 /// Casing the month name was typed in.
204 month_case: StringCase,
205 /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
206 month_full: bool,
207 },
208 /// Month name then day, year assumed: `Jun-22`.
209 MmmD {
210 /// Character separating the fields, `-` or `/`.
211 sep: char,
212 /// Casing the month name was typed in.
213 month_case: StringCase,
214 /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
215 month_full: bool,
216 },
217 /// Month name then year, day assumed to be the 1st: `Jun-2026`.
218 MmmY {
219 /// Character separating the fields, `-` or `/`.
220 sep: char,
221 /// Digits the year was written with: 2 or 4.
222 year_len: usize,
223 /// Casing the month name was typed in.
224 month_case: StringCase,
225 /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
226 month_full: bool,
227 },
228 /// Year then month name, day assumed to be the 1st: `2026-Jun`.
229 YMmm {
230 /// Character separating the fields, `-` or `/`.
231 sep: char,
232 /// Digits the year was written with: 2 or 4.
233 year_len: usize,
234 /// Casing the month name was typed in.
235 month_case: StringCase,
236 /// `true` for a full name (`June`), `false` for an abbreviation (`Jun`).
237 month_full: bool,
238 },
239}
240
241impl DateFormat {
242 /// Lowers to an Excel number-format code (`m/d/yy`, `d-mmm-yyyy`, ...).
243 ///
244 /// This is the interchange form: it is what gets written to the worksheet
245 /// as a `numFmt` and what [`render_date_code`] consumes. Month-name casing
246 /// has no representation in a format code, so it rides alongside as
247 /// [`DateFormat::month_case`].
248 pub fn to_format_code(&self) -> String {
249 // A month name is "mmm"/"mmmm"; a numeric month is bare "m" because
250 // `DateFormat` does not record zero-padding.
251 fn month_word(full: bool) -> &'static str {
252 if full { "mmmm" } else { "mmm" }
253 }
254 fn year(len: usize) -> &'static str {
255 if len == 2 { "yy" } else { "yyyy" }
256 }
257
258 match *self {
259 DateFormat::Ymd { sep } => format!("yyyy{sep}mm{sep}dd"),
260 DateFormat::Mdy { sep, year_len } => format!("m{sep}d{sep}{}", year(year_len)),
261 DateFormat::Dmy { sep, year_len } => format!("d{sep}m{sep}{}", year(year_len)),
262 DateFormat::DMmmY {
263 sep,
264 year_len,
265 month_full,
266 ..
267 } => format!("d{sep}{}{sep}{}", month_word(month_full), year(year_len)),
268 DateFormat::MmmDY {
269 sep,
270 year_len,
271 month_full,
272 ..
273 } => format!("{}{sep}d{sep}{}", month_word(month_full), year(year_len)),
274 DateFormat::YMmmD {
275 sep,
276 year_len,
277 month_full,
278 ..
279 } => format!("{}{sep}{}{sep}d", year(year_len), month_word(month_full)),
280 DateFormat::Md { sep } => format!("m{sep}d"),
281 DateFormat::Dm { sep } => format!("d{sep}m"),
282 DateFormat::My { sep, year_len } => format!("m{sep}{}", year(year_len)),
283 DateFormat::DMmm {
284 sep, month_full, ..
285 } => format!("d{sep}{}", month_word(month_full)),
286 DateFormat::MmmD {
287 sep, month_full, ..
288 } => format!("{}{sep}d", month_word(month_full)),
289 DateFormat::MmmY {
290 sep,
291 year_len,
292 month_full,
293 ..
294 } => format!("{}{sep}{}", month_word(month_full), year(year_len)),
295 DateFormat::YMmm {
296 sep,
297 year_len,
298 month_full,
299 ..
300 } => format!("{}{sep}{}", year(year_len), month_word(month_full)),
301 }
302 }
303
304 /// The casing the month name was typed in, for the formats that have one.
305 pub fn month_case(&self) -> StringCase {
306 match *self {
307 DateFormat::DMmmY { month_case, .. }
308 | DateFormat::MmmDY { month_case, .. }
309 | DateFormat::YMmmD { month_case, .. }
310 | DateFormat::DMmm { month_case, .. }
311 | DateFormat::MmmD { month_case, .. }
312 | DateFormat::MmmY { month_case, .. }
313 | DateFormat::YMmm { month_case, .. } => month_case,
314 _ => StringCase::Title,
315 }
316 }
317}
318
319fn apply_case(s: &str, case: StringCase) -> String {
320 match case {
321 StringCase::Upper => s.to_uppercase(),
322 StringCase::Lower => s.to_lowercase(),
323 // Month names are stored title-cased already.
324 StringCase::Title | StringCase::Original => s.to_string(),
325 }
326}
327
328/// Renders a date through an Excel number-format code.
329///
330/// Handles the date tokens visi recognizes: runs of `y` (1-2 -> 2-digit year,
331/// 3+ -> 4-digit), `m` (1 -> bare month, 2 -> zero-padded, 3 -> `Jun`, 4+ ->
332/// `June`) and `d` (1 -> bare day, 2+ -> zero-padded). Anything else is copied
333/// through verbatim, so separators and literal text survive.
334///
335/// Tokens are matched as runs in a single pass rather than by successive
336/// string replacement, which is what keeps a substituted month name from being
337/// re-scanned -- `December` contains an `m` and `May` a `y`.
338pub fn render_date_code(date: SimpleDate, code: &str, month_case: StringCase) -> String {
339 let chars: Vec<char> = code.chars().collect();
340 let mut out = String::with_capacity(code.len() + 8);
341 let mut i = 0;
342
343 while i < chars.len() {
344 let c = chars[i];
345 if c == '\\' {
346 if let Some(next) = chars.get(i + 1) {
347 out.push(*next);
348 i += 2;
349 } else {
350 i += 1;
351 }
352 continue;
353 }
354 let lower = c.to_ascii_lowercase();
355 if !matches!(lower, 'y' | 'm' | 'd') {
356 out.push(c);
357 i += 1;
358 continue;
359 }
360
361 let mut run = 0;
362 while i + run < chars.len() && chars[i + run].to_ascii_lowercase() == lower {
363 run += 1;
364 }
365 i += run;
366
367 match lower {
368 'y' => {
369 if run <= 2 {
370 out.push_str(&format!("{:02}", date.year.rem_euclid(100)));
371 } else {
372 out.push_str(&format!("{:04}", date.year));
373 }
374 }
375 'm' => {
376 let idx = (date.month as usize).saturating_sub(1);
377 match run {
378 1 => out.push_str(&date.month.to_string()),
379 2 => out.push_str(&format!("{:02}", date.month)),
380 3 => out.push_str(&apply_case(
381 MONTHS_SHORT.get(idx).copied().unwrap_or(""),
382 month_case,
383 )),
384 _ => out.push_str(&apply_case(
385 MONTHS_FULL.get(idx).copied().unwrap_or(""),
386 month_case,
387 )),
388 }
389 }
390 _ => {
391 if run == 1 {
392 out.push_str(&date.day.to_string());
393 } else {
394 out.push_str(&format!("{:02}", date.day));
395 }
396 }
397 }
398 }
399 out
400}
401
402/// Renders a date back in the notation [`parse_date`] recognized it in.
403pub fn format_date(date: SimpleDate, format: &DateFormat) -> String {
404 render_date_code(date, &format.to_format_code(), format.month_case())
405}
406
407/// Whether a number-format code renders a date, as opposed to a numeric
408/// format like `0.00` or `#,##0`.
409///
410/// Deliberately narrow: it wants a `y`/`m`/`d` token and no digit placeholder,
411/// so an unrecognized or numeric code falls back to plain number rendering
412/// rather than being mangled into a date.
413pub fn is_date_code(code: &str) -> bool {
414 let has_date_token = code
415 .chars()
416 .any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd'));
417 let has_number_placeholder = code.contains('0') || code.contains('#');
418 has_date_token && !has_number_placeholder
419}
420
421/// The inverse of [`date_to_excel_serial`], for rendering a computed serial.
422pub fn excel_serial_to_date(serial: f64) -> SimpleDate {
423 let (year, month, day) = crate::core::date_fn::serial_to_ymd(serial);
424 SimpleDate {
425 year,
426 month: month.max(0) as u32,
427 day: day.max(0) as u32,
428 }
429}
430
431fn parse_digits(part: &str) -> Option<i32> {
432 let trimmed = part.trim().trim_end_matches('.');
433 if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
434 trimmed.parse::<i32>().ok()
435 } else {
436 None
437 }
438}
439
440/// Recognizes a date written as text using the default US locale.
441pub fn parse_date(src: &str) -> Option<(SimpleDate, DateFormat)> {
442 parse_date_with_locale(src, &Locale::en_us())
443}
444
445/// Recognizes a date written as text according to a specific [`Locale`].
446///
447/// Returns `None` for anything that is not a date, which is how
448/// `Sheet::commit` decides whether a literal becomes a plain number or a
449/// number carrying a date format. Text that merely *looks* like a date is
450/// therefore quoted on import (`xlsx::text_cell_src`) to keep it text.
451pub fn parse_date_with_locale(src: &str, locale: &Locale) -> Option<(SimpleDate, DateFormat)> {
452 let default_year = locale.default_year();
453 let src_trim = src.trim();
454 if src_trim.is_empty() {
455 return None;
456 }
457
458 for &sep in &['-', '/', '.'] {
459 let parts: Vec<&str> = src_trim.split(sep).collect();
460
461 // --- 3 PARTS ---
462 if parts.len() == 3 {
463 let mut month_word_info = None;
464 for (i, part) in parts.iter().enumerate() {
465 if let Some((m, is_full)) = locale.match_month_word(part) {
466 month_word_info = Some((i, m, is_full));
467 break;
468 }
469 }
470
471 if let Some((month_idx, month, is_full)) = month_word_info {
472 // If one part is a word month, the other two must be digits
473 let mut digit_parts = Vec::new();
474 for (i, part) in parts.iter().enumerate() {
475 if i != month_idx
476 && let Some(val) = parse_digits(part)
477 {
478 digit_parts.push((i, val, part.trim().trim_end_matches('.').len()));
479 }
480 }
481
482 if digit_parts.len() == 2 {
483 let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
484
485 // Case A: Day-Month-Year (e.g., 22-Jun-2026, 22-Jun-26)
486 // month_idx is 1. digit_parts[0] is index 0 (day), digit_parts[1] is index 2 (year).
487 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
488 let day = digit_parts[0].1 as u32;
489 let year_raw = digit_parts[1].1;
490 let year_len = digit_parts[1].2;
491 let year = if year_len == 2 {
492 locale.expand_two_digit_year(year_raw)
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 locale.expand_two_digit_year(year_raw)
517 } else {
518 year_raw
519 };
520 if day >= 1 && day <= days_in_month(year, month) {
521 return Some((
522 SimpleDate { year, month, day },
523 DateFormat::MmmDY {
524 sep,
525 year_len,
526 month_case: case,
527 month_full: is_full,
528 },
529 ));
530 }
531 }
532
533 // Case C: Year-Month-Day (e.g. 2026-Jun-22)
534 // month_idx is 1. digit_parts[0] is index 0 (year), digit_parts[1] is index 2 (day).
535 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
536 let year_raw = digit_parts[0].1;
537 let year_len = digit_parts[0].2;
538 let year = if year_len == 2 {
539 locale.expand_two_digit_year(year_raw)
540 } else {
541 year_raw
542 };
543 let day = digit_parts[1].1 as u32;
544 if day >= 1 && day <= days_in_month(year, month) {
545 return Some((
546 SimpleDate { year, month, day },
547 DateFormat::YMmmD {
548 sep,
549 year_len,
550 month_case: case,
551 month_full: is_full,
552 },
553 ));
554 }
555 }
556 }
557 } else {
558 // All 3 parts are digits (e.g. 2026-06-22, 06-22-2026, 22-06-2026)
559 if let (Some(val0), Some(val1), Some(val2)) = (
560 parse_digits(parts[0]),
561 parse_digits(parts[1]),
562 parse_digits(parts[2]),
563 ) {
564 let len0 = parts[0].trim().len();
565 let len2 = parts[2].trim().len();
566
567 // Option A: YMD (Year first) - len0 == 4
568 if len0 == 4 {
569 let year = val0;
570 let month = val1 as u32;
571 let day = val2 as u32;
572 if (1..=12).contains(&month)
573 && day >= 1
574 && day <= days_in_month(year, month)
575 {
576 return Some((
577 SimpleDate { year, month, day },
578 DateFormat::Ymd { sep },
579 ));
580 }
581 }
582
583 // Option B: MDY or DMY (Year last) - len2 == 4 or 2
584 if len2 == 4 || len2 == 2 {
585 let year_raw = val2;
586 let year = if len2 == 2 {
587 locale.expand_two_digit_year(year_raw)
588 } else {
589 year_raw
590 };
591
592 match locale.date_order {
593 DateOrder::Dmy => {
594 let day = val0 as u32;
595 let month = val1 as u32;
596 if (1..=12).contains(&month)
597 && day >= 1
598 && day <= days_in_month(year, month)
599 {
600 return Some((
601 SimpleDate { year, month, day },
602 DateFormat::Dmy {
603 sep,
604 year_len: len2,
605 },
606 ));
607 }
608 }
609 DateOrder::Mdy => {
610 if val0 > 12 && val1 <= 12 {
611 // Unambiguous DMY fallback
612 let day = val0 as u32;
613 let month = val1 as u32;
614 if (1..=12).contains(&month)
615 && day >= 1
616 && day <= days_in_month(year, month)
617 {
618 return Some((
619 SimpleDate { year, month, day },
620 DateFormat::Dmy {
621 sep,
622 year_len: len2,
623 },
624 ));
625 }
626 } else {
627 let month = val0 as u32;
628 let day = val1 as u32;
629 if (1..=12).contains(&month)
630 && day >= 1
631 && day <= days_in_month(year, month)
632 {
633 return Some((
634 SimpleDate { year, month, day },
635 DateFormat::Mdy {
636 sep,
637 year_len: len2,
638 },
639 ));
640 }
641 }
642 }
643 DateOrder::Ymd => {
644 if len0 == 2 && len2 <= 2 {
645 let y = locale.expand_two_digit_year(val0);
646 let m = val1 as u32;
647 let d = val2 as u32;
648 if (1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m) {
649 return Some((
650 SimpleDate {
651 year: y,
652 month: m,
653 day: d,
654 },
655 DateFormat::Ymd { sep },
656 ));
657 }
658 }
659 let month = val0 as u32;
660 let day = val1 as u32;
661 if (1..=12).contains(&month)
662 && day >= 1
663 && day <= days_in_month(year, month)
664 {
665 return Some((
666 SimpleDate { year, month, day },
667 DateFormat::Mdy {
668 sep,
669 year_len: len2,
670 },
671 ));
672 }
673 }
674 }
675 }
676 }
677 }
678 }
679
680 // --- 2 PARTS ---
681 if parts.len() == 2 {
682 let mut month_word_info = None;
683 for (i, part) in parts.iter().enumerate() {
684 if let Some((m, is_full)) = locale.match_month_word(part) {
685 month_word_info = Some((i, m, is_full));
686 break;
687 }
688 }
689
690 if let Some((month_idx, month, is_full)) = month_word_info {
691 let digit_idx = if month_idx == 0 { 1 } else { 0 };
692 if let Some(digit_val) = parse_digits(parts[digit_idx]) {
693 let digit_len = parts[digit_idx].trim().trim_end_matches('.').len();
694 let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
695
696 // Case A: Month-Year (e.g. Jun-2026 or Jun-26 or 2026-Jun)
697 if digit_len == 4 || (digit_len == 2 && digit_val == default_year % 100) {
698 let year = if digit_len == 2 {
699 locale.expand_two_digit_year(digit_val)
700 } else {
701 digit_val
702 };
703 if (1..=12).contains(&month) {
704 if month_idx == 0 {
705 return Some((
706 SimpleDate {
707 year,
708 month,
709 day: 1,
710 },
711 DateFormat::MmmY {
712 sep,
713 year_len: digit_len,
714 month_case: case,
715 month_full: is_full,
716 },
717 ));
718 } else {
719 return Some((
720 SimpleDate {
721 year,
722 month,
723 day: 1,
724 },
725 DateFormat::YMmm {
726 sep,
727 year_len: digit_len,
728 month_case: case,
729 month_full: is_full,
730 },
731 ));
732 }
733 }
734 } else {
735 // Case B: Day-Month or Month-Day (assumes default_year)
736 let day = digit_val as u32;
737 if day >= 1 && day <= days_in_month(default_year, month) {
738 if month_idx == 1 {
739 // digit_idx is 0 (Day) -> e.g. 22-Jun
740 return Some((
741 SimpleDate {
742 year: default_year,
743 month,
744 day,
745 },
746 DateFormat::DMmm {
747 sep,
748 month_case: case,
749 month_full: is_full,
750 },
751 ));
752 } else {
753 // digit_idx is 1 (Day) -> e.g. Jun-22
754 return Some((
755 SimpleDate {
756 year: default_year,
757 month,
758 day,
759 },
760 DateFormat::MmmD {
761 sep,
762 month_case: case,
763 month_full: is_full,
764 },
765 ));
766 }
767 }
768 }
769 }
770 } else {
771 // All 2 parts are digits (e.g. 6/22, 6/2026)
772 if let (Some(val0), Some(val1)) = (parse_digits(parts[0]), parse_digits(parts[1])) {
773 let len0 = parts[0].trim().len();
774 let len1 = parts[1].trim().len();
775
776 // Option A: Month-Year (e.g. 6/2026)
777 if len1 == 4 {
778 let month = val0 as u32;
779 let year = val1;
780 if (1..=12).contains(&month) {
781 return Some((
782 SimpleDate {
783 year,
784 month,
785 day: 1,
786 },
787 DateFormat::My { sep, year_len: 4 },
788 ));
789 }
790 } else if len0 == 4 {
791 let year = val0;
792 let month = val1 as u32;
793 if (1..=12).contains(&month) {
794 return Some((
795 SimpleDate {
796 year,
797 month,
798 day: 1,
799 },
800 DateFormat::My { sep, year_len: 4 },
801 ));
802 }
803 } else if locale.date_order == DateOrder::Dmy {
804 let day = val0 as u32;
805 let month = val1 as u32;
806 if (1..=12).contains(&month)
807 && day >= 1
808 && day <= days_in_month(default_year, month)
809 {
810 return Some((
811 SimpleDate {
812 year: default_year,
813 month,
814 day,
815 },
816 DateFormat::Dm { sep },
817 ));
818 }
819 if (1..=12).contains(&(val0 as u32)) && len1 == 2 && val1 > 31 {
820 let year = locale.expand_two_digit_year(val1);
821 return Some((
822 SimpleDate {
823 year,
824 month: val0 as u32,
825 day: 1,
826 },
827 DateFormat::My { sep, year_len: 2 },
828 ));
829 }
830 } else {
831 // Option B: Month-Day (assumes default_year)
832 let month = val0 as u32;
833 let day = val1 as u32;
834 if (1..=12).contains(&month)
835 && day >= 1
836 && day <= days_in_month(default_year, month)
837 {
838 return Some((
839 SimpleDate {
840 year: default_year,
841 month,
842 day,
843 },
844 DateFormat::Md { sep },
845 ));
846 }
847
848 // Option C: Month-Year with a 2-digit year that isn't a
849 // valid day (e.g. "1-34" -> Jan 1934), matching Excel's
850 // fallback when the second part can't be a day.
851 if (1..=12).contains(&month) && len1 == 2 && val1 > 31 {
852 let year = locale.expand_two_digit_year(val1);
853 return Some((
854 SimpleDate {
855 year,
856 month,
857 day: 1,
858 },
859 DateFormat::My { sep, year_len: 2 },
860 ));
861 }
862 }
863 }
864 }
865 }
866 }
867
868 // Also check space/comma-separated strings (e.g. "June 22, 2026", "22 June 2026", "22. Juni 2026")
869 if src_trim.contains(' ') || src_trim.contains(',') {
870 let space_parts: Vec<&str> = src_trim
871 .split([' ', ','])
872 .filter(|p| !p.trim().is_empty())
873 .map(|p| p.trim())
874 .collect();
875
876 if space_parts.len() == 3 {
877 let mut month_word_info = None;
878 for (i, part) in space_parts.iter().enumerate() {
879 if let Some((m, is_full)) = locale.match_month_word(part) {
880 month_word_info = Some((i, m, is_full));
881 break;
882 }
883 }
884
885 if let Some((month_idx, month, is_full)) = month_word_info {
886 let mut digit_parts = Vec::new();
887 for (i, part) in space_parts.iter().enumerate() {
888 if i != month_idx
889 && let Some(val) = parse_digits(part)
890 {
891 digit_parts.push((i, val, part.trim_end_matches('.').len()));
892 }
893 }
894
895 if digit_parts.len() == 2 {
896 let case = detect_case(space_parts[month_idx].trim_end_matches('.'));
897 // e.g. "22 June 2026" or "22. Juni 2026" (month_idx == 1, digit_parts[0] is index 0, digit_parts[1] is index 2)
898 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
899 let day = digit_parts[0].1 as u32;
900 let year_raw = digit_parts[1].1;
901 let year_len = digit_parts[1].2;
902 let year = if year_len == 2 {
903 locale.expand_two_digit_year(year_raw)
904 } else {
905 year_raw
906 };
907 if day >= 1 && day <= days_in_month(year, month) {
908 return Some((
909 SimpleDate { year, month, day },
910 DateFormat::DMmmY {
911 sep: '-',
912 year_len,
913 month_case: case,
914 month_full: is_full,
915 },
916 ));
917 }
918 }
919
920 // e.g. "June 22, 2026" (month_idx == 0, digit_parts[0] is index 1, digit_parts[1] is index 2)
921 if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
922 let day = digit_parts[0].1 as u32;
923 let year_raw = digit_parts[1].1;
924 let year_len = digit_parts[1].2;
925 let year = if year_len == 2 {
926 locale.expand_two_digit_year(year_raw)
927 } else {
928 year_raw
929 };
930 if day >= 1 && day <= days_in_month(year, month) {
931 return Some((
932 SimpleDate { year, month, day },
933 DateFormat::MmmDY {
934 sep: '-',
935 year_len,
936 month_case: case,
937 month_full: is_full,
938 },
939 ));
940 }
941 }
942 }
943 }
944 }
945 }
946
947 None
948}
949
950/// Converts a date to Excel's day count, where 1 is 1900-01-01.
951///
952/// Reproduces Excel's 1900 leap-year bug -- serial 60 is the nonexistent
953/// 1900-02-29 -- by adding a day for every date after 1900-02-28, which is
954/// what makes serials agree with Excel's for every date a workbook is likely
955/// to contain. Dates before 1900 have no serial and return `0.0`.
956pub fn date_to_excel_serial(date: SimpleDate) -> f64 {
957 if date.year < 1900 {
958 return 0.0;
959 }
960 let mut days = 0;
961 for y in 1900..date.year {
962 days += if is_leap_year(y) { 366 } else { 365 };
963 }
964 for m in 1..date.month {
965 days += days_in_month(date.year, m) as i32;
966 }
967 days += date.day as i32;
968 if date.year > 1900 || (date.year == 1900 && date.month > 2) {
969 days += 1;
970 }
971 days as f64
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977
978 /// Every case pins both the parsed date and the [`DateFormat`] that
979 /// `parse_date` inferred.
980 #[test]
981 fn test_date_parsing_and_format_detection() {
982 let cases: &[(&str, SimpleDate, DateFormat)] = &[
983 (
984 "2026-06-22",
985 SimpleDate {
986 year: 2026,
987 month: 6,
988 day: 22,
989 },
990 DateFormat::Ymd { sep: '-' },
991 ),
992 (
993 "2026/06/22",
994 SimpleDate {
995 year: 2026,
996 month: 6,
997 day: 22,
998 },
999 DateFormat::Ymd { sep: '/' },
1000 ),
1001 (
1002 "06-22-2026",
1003 SimpleDate {
1004 year: 2026,
1005 month: 6,
1006 day: 22,
1007 },
1008 DateFormat::Mdy {
1009 sep: '-',
1010 year_len: 4,
1011 },
1012 ),
1013 (
1014 "22-06-2026",
1015 SimpleDate {
1016 year: 2026,
1017 month: 6,
1018 day: 22,
1019 },
1020 DateFormat::Dmy {
1021 sep: '-',
1022 year_len: 4,
1023 },
1024 ),
1025 (
1026 "06/22/26",
1027 SimpleDate {
1028 year: 2026,
1029 month: 6,
1030 day: 22,
1031 },
1032 DateFormat::Mdy {
1033 sep: '/',
1034 year_len: 2,
1035 },
1036 ),
1037 // 2-digit years below the pivot roll back into the 1900s.
1038 (
1039 "06/22/99",
1040 SimpleDate {
1041 year: 1999,
1042 month: 6,
1043 day: 22,
1044 },
1045 DateFormat::Mdy {
1046 sep: '/',
1047 year_len: 2,
1048 },
1049 ),
1050 (
1051 "22-Jun-2026",
1052 SimpleDate {
1053 year: 2026,
1054 month: 6,
1055 day: 22,
1056 },
1057 DateFormat::DMmmY {
1058 sep: '-',
1059 year_len: 4,
1060 month_case: StringCase::Title,
1061 month_full: false,
1062 },
1063 ),
1064 (
1065 "22-June-2026",
1066 SimpleDate {
1067 year: 2026,
1068 month: 6,
1069 day: 22,
1070 },
1071 DateFormat::DMmmY {
1072 sep: '-',
1073 year_len: 4,
1074 month_case: StringCase::Title,
1075 month_full: true,
1076 },
1077 ),
1078 (
1079 "Jun-22-2026",
1080 SimpleDate {
1081 year: 2026,
1082 month: 6,
1083 day: 22,
1084 },
1085 DateFormat::MmmDY {
1086 sep: '-',
1087 year_len: 4,
1088 month_case: StringCase::Title,
1089 month_full: false,
1090 },
1091 ),
1092 // 2-part forms infer the missing component.
1093 (
1094 "6/22",
1095 SimpleDate {
1096 year: 2026,
1097 month: 6,
1098 day: 22,
1099 },
1100 DateFormat::Md { sep: '/' },
1101 ),
1102 (
1103 "22-Jun",
1104 SimpleDate {
1105 year: 2026,
1106 month: 6,
1107 day: 22,
1108 },
1109 DateFormat::DMmm {
1110 sep: '-',
1111 month_case: StringCase::Title,
1112 month_full: false,
1113 },
1114 ),
1115 (
1116 "Jun-22",
1117 SimpleDate {
1118 year: 2026,
1119 month: 6,
1120 day: 22,
1121 },
1122 DateFormat::MmmD {
1123 sep: '-',
1124 month_case: StringCase::Title,
1125 month_full: false,
1126 },
1127 ),
1128 (
1129 "6/2026",
1130 SimpleDate {
1131 year: 2026,
1132 month: 6,
1133 day: 1,
1134 },
1135 DateFormat::My {
1136 sep: '/',
1137 year_len: 4,
1138 },
1139 ),
1140 (
1141 "Jun-26",
1142 SimpleDate {
1143 year: 2026,
1144 month: 6,
1145 day: 1,
1146 },
1147 DateFormat::MmmY {
1148 sep: '-',
1149 year_len: 2,
1150 month_case: StringCase::Title,
1151 month_full: false,
1152 },
1153 ),
1154 (
1155 "2026-Jun",
1156 SimpleDate {
1157 year: 2026,
1158 month: 6,
1159 day: 1,
1160 },
1161 DateFormat::YMmm {
1162 sep: '-',
1163 year_len: 4,
1164 month_case: StringCase::Title,
1165 month_full: false,
1166 },
1167 ),
1168 ];
1169
1170 for (src, want_date, want_format) in cases {
1171 let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1172 assert_eq!(date, *want_date, "date mismatch for {src}");
1173 assert_eq!(format, *want_format, "format mismatch for {src}");
1174 }
1175 }
1176
1177 #[test]
1178 fn test_locale_aware_date_parsing() {
1179 let de = Locale::de_de();
1180 let gb = Locale::en_gb();
1181 let us = Locale::en_us();
1182
1183 // In German locale: 22.06.2026 is Day=22, Month=6
1184 let (date, fmt) = parse_date_with_locale("22.06.2026", &de).unwrap();
1185 assert_eq!(
1186 date,
1187 SimpleDate {
1188 year: 2026,
1189 month: 6,
1190 day: 22
1191 }
1192 );
1193 assert_eq!(
1194 fmt,
1195 DateFormat::Dmy {
1196 sep: '.',
1197 year_len: 4
1198 }
1199 );
1200
1201 // German month word: 22. Juni 2026
1202 let (date, _) = parse_date_with_locale("22. Juni 2026", &de).unwrap();
1203 assert_eq!(
1204 date,
1205 SimpleDate {
1206 year: 2026,
1207 month: 6,
1208 day: 22
1209 }
1210 );
1211
1212 // In UK locale: 05/06/2026 is 5th June 2026
1213 let (date, _) = parse_date_with_locale("05/06/2026", &gb).unwrap();
1214 assert_eq!(
1215 date,
1216 SimpleDate {
1217 year: 2026,
1218 month: 6,
1219 day: 5
1220 }
1221 );
1222
1223 // In US locale: 05/06/2026 is May 6th 2026
1224 let (date, _) = parse_date_with_locale("05/06/2026", &us).unwrap();
1225 assert_eq!(
1226 date,
1227 SimpleDate {
1228 year: 2026,
1229 month: 5,
1230 day: 6
1231 }
1232 );
1233
1234 // In UK/DE locale: 2-part "22/6" is Day 22, Month 6
1235 let (date, fmt) = parse_date_with_locale("22/6", &gb).unwrap();
1236 assert_eq!(
1237 date,
1238 SimpleDate {
1239 year: 2026,
1240 month: 6,
1241 day: 22
1242 }
1243 );
1244 assert_eq!(fmt, DateFormat::Dm { sep: '/' });
1245
1246 // French month name: "14 juillet 2026"
1247 let fr = Locale::fr_fr();
1248 let (date, _) = parse_date_with_locale("14 juillet 2026", &fr).unwrap();
1249 assert_eq!(
1250 date,
1251 SimpleDate {
1252 year: 2026,
1253 month: 7,
1254 day: 14
1255 }
1256 );
1257
1258 // Spanish month name: "12 de octubre de 2026" or "12 octubre 2026"
1259 let es = Locale::es_es();
1260 let (date, _) = parse_date_with_locale("12 octubre 2026", &es).unwrap();
1261 assert_eq!(
1262 date,
1263 SimpleDate {
1264 year: 2026,
1265 month: 10,
1266 day: 12
1267 }
1268 );
1269 }
1270
1271 /// The point of detecting a format at all: a date echoes back in the
1272 /// notation it was typed in.
1273 #[test]
1274 fn test_format_date_round_trips_the_typed_notation() {
1275 let sources = [
1276 "2026-06-22",
1277 "2026/06/22",
1278 "6/22/26",
1279 "22-Jun-2026",
1280 "22-June-2026",
1281 "Jun-22-2026",
1282 "22-Jun",
1283 "Jun-22",
1284 "6/2026",
1285 "Jun-26",
1286 "2026-Jun",
1287 ];
1288 for src in sources {
1289 let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1290 assert_eq!(
1291 format_date(date, &format),
1292 src,
1293 "round trip failed for {src}"
1294 );
1295 }
1296 }
1297
1298 #[test]
1299 fn test_format_date_normalizes_zero_padding() {
1300 let (date, format) = parse_date("22-06-2026").unwrap();
1301 assert_eq!(format_date(date, &format), "22-6-2026");
1302
1303 let (date, format) = parse_date("06/22/2026").unwrap();
1304 assert_eq!(format_date(date, &format), "6/22/2026");
1305
1306 // Year-first keeps its padding: the code really is yyyy-mm-dd.
1307 let (date, format) = parse_date("2026-06-22").unwrap();
1308 assert_eq!(format_date(date, &format), "2026-06-22");
1309 }
1310
1311 #[test]
1312 fn test_format_date_preserves_month_name_case() {
1313 for src in ["22-JUN-2026", "22-jun-2026"] {
1314 let (date, format) = parse_date(src).unwrap();
1315 assert_eq!(format_date(date, &format), src);
1316 }
1317 let (date, format) = parse_date("22-JUN-2026").unwrap();
1318 assert_eq!(format.to_format_code(), "d-mmm-yyyy");
1319 assert_eq!(
1320 render_date_code(date, &format.to_format_code(), StringCase::Title),
1321 "22-Jun-2026"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_render_date_code_does_not_rescan_substituted_month_names() {
1327 let dec = SimpleDate {
1328 year: 2026,
1329 month: 12,
1330 day: 5,
1331 };
1332 assert_eq!(
1333 render_date_code(dec, "mmmm d, yyyy", StringCase::Title),
1334 "December 5, 2026"
1335 );
1336 let may = SimpleDate {
1337 year: 2026,
1338 month: 5,
1339 day: 5,
1340 };
1341 assert_eq!(render_date_code(may, "mmm-yy", StringCase::Title), "May-26");
1342 }
1343
1344 #[test]
1345 fn test_render_date_code_token_widths() {
1346 let d = SimpleDate {
1347 year: 2026,
1348 month: 6,
1349 day: 7,
1350 };
1351 assert_eq!(
1352 render_date_code(d, "yyyy-mm-dd", StringCase::Title),
1353 "2026-06-07"
1354 );
1355 assert_eq!(render_date_code(d, "m/d/yy", StringCase::Title), "6/7/26");
1356 assert_eq!(render_date_code(d, "mmmm", StringCase::Title), "June");
1357 // Non-token characters pass through untouched.
1358 assert_eq!(
1359 render_date_code(d, "[yyyy] week of d", StringCase::Title),
1360 "[2026] week of 7"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_render_date_code_honors_excel_escape_prefix() {
1366 let d = SimpleDate {
1367 year: 2026,
1368 month: 6,
1369 day: 7,
1370 };
1371 assert_eq!(
1372 render_date_code(d, "yyyy\\-mm\\-dd", StringCase::Title),
1373 "2026-06-07"
1374 );
1375 assert_eq!(
1376 render_date_code(d, "d\\-mmm\\-yyyy", StringCase::Title),
1377 "7-Jun-2026"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_is_date_code_rejects_numeric_formats() {
1383 assert!(is_date_code("m/d/yy"));
1384 assert!(is_date_code("yyyy-mm-dd"));
1385 assert!(!is_date_code("0.00"));
1386 assert!(!is_date_code("#,##0"));
1387 assert!(!is_date_code(""));
1388 }
1389
1390 #[test]
1391 fn test_invalid_dates_do_not_parse() {
1392 assert!(parse_date("2026-02-30").is_none());
1393 assert!(parse_date("2025-02-29").is_none()); // non-leap year
1394 assert!(parse_date("13/22/2026").is_none()); // invalid month
1395 assert!(parse_date("06-32-2026").is_none()); // invalid day
1396 }
1397
1398 #[test]
1399 fn test_date_to_excel_serial() {
1400 // Excel's epoch: 1900-01-01 is serial 1.
1401 assert_eq!(
1402 date_to_excel_serial(SimpleDate {
1403 year: 1900,
1404 month: 1,
1405 day: 1
1406 }),
1407 1.0
1408 );
1409 // Excel's deliberate 1900 leap-year bug means 1900-03-01 is 61, not 60.
1410 assert_eq!(
1411 date_to_excel_serial(SimpleDate {
1412 year: 1900,
1413 month: 3,
1414 day: 1
1415 }),
1416 61.0
1417 );
1418 assert_eq!(
1419 date_to_excel_serial(SimpleDate {
1420 year: 2026,
1421 month: 6,
1422 day: 22
1423 }),
1424 46195.0
1425 );
1426 }
1427
1428 use proptest::prelude::*;
1429
1430 proptest! {
1431 #[test]
1432 fn fuzz_parse_date_never_panics(
1433 s in "\\PC*",
1434 loc_idx in 0..11usize
1435 ) {
1436 let locales = [
1437 Locale::en_us(),
1438 Locale::en_gb(),
1439 Locale::de_de(),
1440 Locale::fr_fr(),
1441 Locale::es_es(),
1442 Locale::it_it(),
1443 Locale::pt_br(),
1444 Locale::nl_nl(),
1445 Locale::ru_ru(),
1446 Locale::zh_cn(),
1447 Locale::ja_jp(),
1448 ];
1449 let loc = &locales[loc_idx];
1450 let _ = parse_date_with_locale(&s, loc);
1451 }
1452
1453 #[test]
1454 fn fuzz_date_parsing_roundtrip_us(
1455 y in 1900..=2099i32,
1456 m in 1..=12u32,
1457 d in 1..=28u32,
1458 sep in "[/\\-]"
1459 ) {
1460 let loc = Locale::en_us();
1461 let src = format!("{}{}{}{}{}", m, sep, d, sep, y);
1462 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid MDY date must parse");
1463 prop_assert_eq!(date.year, y);
1464 prop_assert_eq!(date.month, m);
1465 prop_assert_eq!(date.day, d);
1466 }
1467
1468 #[test]
1469 fn fuzz_date_parsing_roundtrip_gb(
1470 y in 1900..=2099i32,
1471 m in 1..=12u32,
1472 d in 1..=28u32,
1473 sep in "[/\\-]"
1474 ) {
1475 let loc = Locale::en_gb();
1476 let src = format!("{}{}{}{}{}", d, sep, m, sep, y);
1477 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid DMY date must parse");
1478 prop_assert_eq!(date.year, y);
1479 prop_assert_eq!(date.month, m);
1480 prop_assert_eq!(date.day, d);
1481 }
1482
1483 #[test]
1484 fn fuzz_date_parsing_roundtrip_de_dot(
1485 y in 1900..=2099i32,
1486 m in 1..=12u32,
1487 d in 1..=28u32,
1488 ) {
1489 let loc = Locale::de_de();
1490 let src = format!("{}.{}.{}", d, m, y);
1491 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid German dot date must parse");
1492 prop_assert_eq!(date.year, y);
1493 prop_assert_eq!(date.month, m);
1494 prop_assert_eq!(date.day, d);
1495 }
1496
1497 #[test]
1498 fn fuzz_date_parsing_2digit_pivot(
1499 yy in 0..=99i32,
1500 m in 1..=12u32,
1501 d in 1..=28u32
1502 ) {
1503 let loc = Locale::en_us();
1504 let src = format!("{}/{}/{:02}", m, d, yy);
1505 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid 2-digit year date must parse");
1506 let expected_y = if yy <= 29 { 2000 + yy } else { 1900 + yy };
1507 prop_assert_eq!(date.year, expected_y);
1508 prop_assert_eq!(date.month, m);
1509 prop_assert_eq!(date.day, d);
1510 }
1511 }
1512}