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 // Try standard delimiters: '-', '/', '.'
459 for &sep in &['-', '/', '.'] {
460 let parts: Vec<&str> = src_trim.split(sep).collect();
461
462 // --- 3 PARTS ---
463 if parts.len() == 3 {
464 // Check if there is a word month in the parts
465 let mut month_word_info = None;
466 for (i, part) in parts.iter().enumerate() {
467 if let Some((m, is_full)) = locale.match_month_word(part) {
468 month_word_info = Some((i, m, is_full));
469 break;
470 }
471 }
472
473 if let Some((month_idx, month, is_full)) = month_word_info {
474 // If one part is a word month, the other two must be digits
475 let mut digit_parts = Vec::new();
476 for (i, part) in parts.iter().enumerate() {
477 if i != month_idx
478 && let Some(val) = parse_digits(part)
479 {
480 digit_parts.push((i, val, part.trim().trim_end_matches('.').len()));
481 }
482 }
483
484 if digit_parts.len() == 2 {
485 let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
486
487 // Case A: Day-Month-Year (e.g., 22-Jun-2026, 22-Jun-26)
488 // month_idx is 1. digit_parts[0] is index 0 (day), digit_parts[1] is index 2 (year).
489 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
490 let day = digit_parts[0].1 as u32;
491 let year_raw = digit_parts[1].1;
492 let year_len = digit_parts[1].2;
493 let year = if year_len == 2 {
494 locale.expand_two_digit_year(year_raw)
495 } else {
496 year_raw
497 };
498 if day >= 1 && day <= days_in_month(year, month) {
499 return Some((
500 SimpleDate { year, month, day },
501 DateFormat::DMmmY {
502 sep,
503 year_len,
504 month_case: case,
505 month_full: is_full,
506 },
507 ));
508 }
509 }
510
511 // Case B: Month-Day-Year (e.g., Jun-22-2026)
512 // month_idx is 0. digit_parts[0] is index 1 (day), digit_parts[1] is index 2 (year).
513 if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
514 let day = digit_parts[0].1 as u32;
515 let year_raw = digit_parts[1].1;
516 let year_len = digit_parts[1].2;
517 let year = if year_len == 2 {
518 locale.expand_two_digit_year(year_raw)
519 } else {
520 year_raw
521 };
522 if day >= 1 && day <= days_in_month(year, month) {
523 return Some((
524 SimpleDate { year, month, day },
525 DateFormat::MmmDY {
526 sep,
527 year_len,
528 month_case: case,
529 month_full: is_full,
530 },
531 ));
532 }
533 }
534
535 // Case C: Year-Month-Day (e.g. 2026-Jun-22)
536 // month_idx is 1. digit_parts[0] is index 0 (year), digit_parts[1] is index 2 (day).
537 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
538 let year_raw = digit_parts[0].1;
539 let year_len = digit_parts[0].2;
540 let year = if year_len == 2 {
541 locale.expand_two_digit_year(year_raw)
542 } else {
543 year_raw
544 };
545 let day = digit_parts[1].1 as u32;
546 if day >= 1 && day <= days_in_month(year, month) {
547 return Some((
548 SimpleDate { year, month, day },
549 DateFormat::YMmmD {
550 sep,
551 year_len,
552 month_case: case,
553 month_full: is_full,
554 },
555 ));
556 }
557 }
558 }
559 } else {
560 // All 3 parts are digits (e.g. 2026-06-22, 06-22-2026, 22-06-2026)
561 if let (Some(val0), Some(val1), Some(val2)) = (
562 parse_digits(parts[0]),
563 parse_digits(parts[1]),
564 parse_digits(parts[2]),
565 ) {
566 let len0 = parts[0].trim().len();
567 let len2 = parts[2].trim().len();
568
569 // Option A: YMD (Year first) - len0 == 4
570 if len0 == 4 {
571 let year = val0;
572 let month = val1 as u32;
573 let day = val2 as u32;
574 if (1..=12).contains(&month)
575 && day >= 1
576 && day <= days_in_month(year, month)
577 {
578 return Some((
579 SimpleDate { year, month, day },
580 DateFormat::Ymd { sep },
581 ));
582 }
583 }
584
585 // Option B: MDY or DMY (Year last) - len2 == 4 or 2
586 if len2 == 4 || len2 == 2 {
587 let year_raw = val2;
588 let year = if len2 == 2 {
589 locale.expand_two_digit_year(year_raw)
590 } else {
591 year_raw
592 };
593
594 match locale.date_order {
595 DateOrder::Dmy => {
596 let day = val0 as u32;
597 let month = val1 as u32;
598 if (1..=12).contains(&month)
599 && day >= 1
600 && day <= days_in_month(year, month)
601 {
602 return Some((
603 SimpleDate { year, month, day },
604 DateFormat::Dmy {
605 sep,
606 year_len: len2,
607 },
608 ));
609 }
610 }
611 DateOrder::Mdy => {
612 if val0 > 12 && val1 <= 12 {
613 // Unambiguous DMY fallback
614 let day = val0 as u32;
615 let month = val1 as u32;
616 if (1..=12).contains(&month)
617 && day >= 1
618 && day <= days_in_month(year, month)
619 {
620 return Some((
621 SimpleDate { year, month, day },
622 DateFormat::Dmy {
623 sep,
624 year_len: len2,
625 },
626 ));
627 }
628 } else {
629 let month = val0 as u32;
630 let day = val1 as u32;
631 if (1..=12).contains(&month)
632 && day >= 1
633 && day <= days_in_month(year, month)
634 {
635 return Some((
636 SimpleDate { year, month, day },
637 DateFormat::Mdy {
638 sep,
639 year_len: len2,
640 },
641 ));
642 }
643 }
644 }
645 DateOrder::Ymd => {
646 if len0 == 2 && len2 <= 2 {
647 let y = locale.expand_two_digit_year(val0);
648 let m = val1 as u32;
649 let d = val2 as u32;
650 if (1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m) {
651 return Some((
652 SimpleDate {
653 year: y,
654 month: m,
655 day: d,
656 },
657 DateFormat::Ymd { sep },
658 ));
659 }
660 }
661 let month = val0 as u32;
662 let day = val1 as u32;
663 if (1..=12).contains(&month)
664 && day >= 1
665 && day <= days_in_month(year, month)
666 {
667 return Some((
668 SimpleDate { year, month, day },
669 DateFormat::Mdy {
670 sep,
671 year_len: len2,
672 },
673 ));
674 }
675 }
676 }
677 }
678 }
679 }
680 }
681
682 // --- 2 PARTS ---
683 if parts.len() == 2 {
684 // Check if there is a word month in the parts
685 let mut month_word_info = None;
686 for (i, part) in parts.iter().enumerate() {
687 if let Some((m, is_full)) = locale.match_month_word(part) {
688 month_word_info = Some((i, m, is_full));
689 break;
690 }
691 }
692
693 if let Some((month_idx, month, is_full)) = month_word_info {
694 let digit_idx = if month_idx == 0 { 1 } else { 0 };
695 if let Some(digit_val) = parse_digits(parts[digit_idx]) {
696 let digit_len = parts[digit_idx].trim().trim_end_matches('.').len();
697 let case = detect_case(parts[month_idx].trim().trim_end_matches('.'));
698
699 // Case A: Month-Year (e.g. Jun-2026 or Jun-26 or 2026-Jun)
700 if digit_len == 4 || (digit_len == 2 && digit_val == default_year % 100) {
701 let year = if digit_len == 2 {
702 locale.expand_two_digit_year(digit_val)
703 } else {
704 digit_val
705 };
706 if (1..=12).contains(&month) {
707 if month_idx == 0 {
708 return Some((
709 SimpleDate {
710 year,
711 month,
712 day: 1,
713 },
714 DateFormat::MmmY {
715 sep,
716 year_len: digit_len,
717 month_case: case,
718 month_full: is_full,
719 },
720 ));
721 } else {
722 return Some((
723 SimpleDate {
724 year,
725 month,
726 day: 1,
727 },
728 DateFormat::YMmm {
729 sep,
730 year_len: digit_len,
731 month_case: case,
732 month_full: is_full,
733 },
734 ));
735 }
736 }
737 } else {
738 // Case B: Day-Month or Month-Day (assumes default_year)
739 let day = digit_val as u32;
740 if day >= 1 && day <= days_in_month(default_year, month) {
741 if month_idx == 1 {
742 // digit_idx is 0 (Day) -> e.g. 22-Jun
743 return Some((
744 SimpleDate {
745 year: default_year,
746 month,
747 day,
748 },
749 DateFormat::DMmm {
750 sep,
751 month_case: case,
752 month_full: is_full,
753 },
754 ));
755 } else {
756 // digit_idx is 1 (Day) -> e.g. Jun-22
757 return Some((
758 SimpleDate {
759 year: default_year,
760 month,
761 day,
762 },
763 DateFormat::MmmD {
764 sep,
765 month_case: case,
766 month_full: is_full,
767 },
768 ));
769 }
770 }
771 }
772 }
773 } else {
774 // All 2 parts are digits (e.g. 6/22, 6/2026)
775 if let (Some(val0), Some(val1)) = (parse_digits(parts[0]), parse_digits(parts[1])) {
776 let len0 = parts[0].trim().len();
777 let len1 = parts[1].trim().len();
778
779 // Option A: Month-Year (e.g. 6/2026)
780 if len1 == 4 {
781 let month = val0 as u32;
782 let year = val1;
783 if (1..=12).contains(&month) {
784 return Some((
785 SimpleDate {
786 year,
787 month,
788 day: 1,
789 },
790 DateFormat::My { sep, year_len: 4 },
791 ));
792 }
793 } else if len0 == 4 {
794 let year = val0;
795 let month = val1 as u32;
796 if (1..=12).contains(&month) {
797 return Some((
798 SimpleDate {
799 year,
800 month,
801 day: 1,
802 },
803 DateFormat::My { sep, year_len: 4 },
804 ));
805 }
806 } else if locale.date_order == DateOrder::Dmy {
807 let day = val0 as u32;
808 let month = val1 as u32;
809 if (1..=12).contains(&month)
810 && day >= 1
811 && day <= days_in_month(default_year, month)
812 {
813 return Some((
814 SimpleDate {
815 year: default_year,
816 month,
817 day,
818 },
819 DateFormat::Dm { sep },
820 ));
821 }
822 if (1..=12).contains(&(val0 as u32)) && len1 == 2 && val1 > 31 {
823 let year = locale.expand_two_digit_year(val1);
824 return Some((
825 SimpleDate {
826 year,
827 month: val0 as u32,
828 day: 1,
829 },
830 DateFormat::My { sep, year_len: 2 },
831 ));
832 }
833 } else {
834 // Option B: Month-Day (assumes default_year)
835 let month = val0 as u32;
836 let day = val1 as u32;
837 if (1..=12).contains(&month)
838 && day >= 1
839 && day <= days_in_month(default_year, month)
840 {
841 return Some((
842 SimpleDate {
843 year: default_year,
844 month,
845 day,
846 },
847 DateFormat::Md { sep },
848 ));
849 }
850
851 // Option C: Month-Year with a 2-digit year that isn't a
852 // valid day (e.g. "1-34" -> Jan 1934), matching Excel's
853 // fallback when the second part can't be a day.
854 if (1..=12).contains(&month) && len1 == 2 && val1 > 31 {
855 let year = locale.expand_two_digit_year(val1);
856 return Some((
857 SimpleDate {
858 year,
859 month,
860 day: 1,
861 },
862 DateFormat::My { sep, year_len: 2 },
863 ));
864 }
865 }
866 }
867 }
868 }
869 }
870
871 // Also check space/comma-separated strings (e.g. "June 22, 2026", "22 June 2026", "22. Juni 2026")
872 if src_trim.contains(' ') || src_trim.contains(',') {
873 let space_parts: Vec<&str> = src_trim
874 .split([' ', ','])
875 .filter(|p| !p.trim().is_empty())
876 .map(|p| p.trim())
877 .collect();
878
879 if space_parts.len() == 3 {
880 let mut month_word_info = None;
881 for (i, part) in space_parts.iter().enumerate() {
882 if let Some((m, is_full)) = locale.match_month_word(part) {
883 month_word_info = Some((i, m, is_full));
884 break;
885 }
886 }
887
888 if let Some((month_idx, month, is_full)) = month_word_info {
889 let mut digit_parts = Vec::new();
890 for (i, part) in space_parts.iter().enumerate() {
891 if i != month_idx
892 && let Some(val) = parse_digits(part)
893 {
894 digit_parts.push((i, val, part.trim_end_matches('.').len()));
895 }
896 }
897
898 if digit_parts.len() == 2 {
899 let case = detect_case(space_parts[month_idx].trim_end_matches('.'));
900 // e.g. "22 June 2026" or "22. Juni 2026" (month_idx == 1, digit_parts[0] is index 0, digit_parts[1] is index 2)
901 if month_idx == 1 && digit_parts[0].0 == 0 && digit_parts[1].0 == 2 {
902 let day = digit_parts[0].1 as u32;
903 let year_raw = digit_parts[1].1;
904 let year_len = digit_parts[1].2;
905 let year = if year_len == 2 {
906 locale.expand_two_digit_year(year_raw)
907 } else {
908 year_raw
909 };
910 if day >= 1 && day <= days_in_month(year, month) {
911 return Some((
912 SimpleDate { year, month, day },
913 DateFormat::DMmmY {
914 sep: '-',
915 year_len,
916 month_case: case,
917 month_full: is_full,
918 },
919 ));
920 }
921 }
922
923 // e.g. "June 22, 2026" (month_idx == 0, digit_parts[0] is index 1, digit_parts[1] is index 2)
924 if month_idx == 0 && digit_parts[0].0 == 1 && digit_parts[1].0 == 2 {
925 let day = digit_parts[0].1 as u32;
926 let year_raw = digit_parts[1].1;
927 let year_len = digit_parts[1].2;
928 let year = if year_len == 2 {
929 locale.expand_two_digit_year(year_raw)
930 } else {
931 year_raw
932 };
933 if day >= 1 && day <= days_in_month(year, month) {
934 return Some((
935 SimpleDate { year, month, day },
936 DateFormat::MmmDY {
937 sep: '-',
938 year_len,
939 month_case: case,
940 month_full: is_full,
941 },
942 ));
943 }
944 }
945 }
946 }
947 }
948 }
949
950 None
951}
952
953/// Converts a date to Excel's day count, where 1 is 1900-01-01.
954///
955/// Reproduces Excel's 1900 leap-year bug -- serial 60 is the nonexistent
956/// 1900-02-29 -- by adding a day for every date after 1900-02-28, which is
957/// what makes serials agree with Excel's for every date a workbook is likely
958/// to contain. Dates before 1900 have no serial and return `0.0`.
959pub fn date_to_excel_serial(date: SimpleDate) -> f64 {
960 if date.year < 1900 {
961 return 0.0;
962 }
963 let mut days = 0;
964 for y in 1900..date.year {
965 days += if is_leap_year(y) { 366 } else { 365 };
966 }
967 for m in 1..date.month {
968 days += days_in_month(date.year, m) as i32;
969 }
970 days += date.day as i32;
971 if date.year > 1900 || (date.year == 1900 && date.month > 2) {
972 days += 1;
973 }
974 days as f64
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 /// Every case pins both the parsed date and the [`DateFormat`] that
982 /// `parse_date` inferred.
983 #[test]
984 fn test_date_parsing_and_format_detection() {
985 let cases: &[(&str, SimpleDate, DateFormat)] = &[
986 (
987 "2026-06-22",
988 SimpleDate {
989 year: 2026,
990 month: 6,
991 day: 22,
992 },
993 DateFormat::Ymd { sep: '-' },
994 ),
995 (
996 "2026/06/22",
997 SimpleDate {
998 year: 2026,
999 month: 6,
1000 day: 22,
1001 },
1002 DateFormat::Ymd { sep: '/' },
1003 ),
1004 (
1005 "06-22-2026",
1006 SimpleDate {
1007 year: 2026,
1008 month: 6,
1009 day: 22,
1010 },
1011 DateFormat::Mdy {
1012 sep: '-',
1013 year_len: 4,
1014 },
1015 ),
1016 (
1017 "22-06-2026",
1018 SimpleDate {
1019 year: 2026,
1020 month: 6,
1021 day: 22,
1022 },
1023 DateFormat::Dmy {
1024 sep: '-',
1025 year_len: 4,
1026 },
1027 ),
1028 (
1029 "06/22/26",
1030 SimpleDate {
1031 year: 2026,
1032 month: 6,
1033 day: 22,
1034 },
1035 DateFormat::Mdy {
1036 sep: '/',
1037 year_len: 2,
1038 },
1039 ),
1040 // 2-digit years below the pivot roll back into the 1900s.
1041 (
1042 "06/22/99",
1043 SimpleDate {
1044 year: 1999,
1045 month: 6,
1046 day: 22,
1047 },
1048 DateFormat::Mdy {
1049 sep: '/',
1050 year_len: 2,
1051 },
1052 ),
1053 (
1054 "22-Jun-2026",
1055 SimpleDate {
1056 year: 2026,
1057 month: 6,
1058 day: 22,
1059 },
1060 DateFormat::DMmmY {
1061 sep: '-',
1062 year_len: 4,
1063 month_case: StringCase::Title,
1064 month_full: false,
1065 },
1066 ),
1067 (
1068 "22-June-2026",
1069 SimpleDate {
1070 year: 2026,
1071 month: 6,
1072 day: 22,
1073 },
1074 DateFormat::DMmmY {
1075 sep: '-',
1076 year_len: 4,
1077 month_case: StringCase::Title,
1078 month_full: true,
1079 },
1080 ),
1081 (
1082 "Jun-22-2026",
1083 SimpleDate {
1084 year: 2026,
1085 month: 6,
1086 day: 22,
1087 },
1088 DateFormat::MmmDY {
1089 sep: '-',
1090 year_len: 4,
1091 month_case: StringCase::Title,
1092 month_full: false,
1093 },
1094 ),
1095 // 2-part forms infer the missing component.
1096 (
1097 "6/22",
1098 SimpleDate {
1099 year: 2026,
1100 month: 6,
1101 day: 22,
1102 },
1103 DateFormat::Md { sep: '/' },
1104 ),
1105 (
1106 "22-Jun",
1107 SimpleDate {
1108 year: 2026,
1109 month: 6,
1110 day: 22,
1111 },
1112 DateFormat::DMmm {
1113 sep: '-',
1114 month_case: StringCase::Title,
1115 month_full: false,
1116 },
1117 ),
1118 (
1119 "Jun-22",
1120 SimpleDate {
1121 year: 2026,
1122 month: 6,
1123 day: 22,
1124 },
1125 DateFormat::MmmD {
1126 sep: '-',
1127 month_case: StringCase::Title,
1128 month_full: false,
1129 },
1130 ),
1131 (
1132 "6/2026",
1133 SimpleDate {
1134 year: 2026,
1135 month: 6,
1136 day: 1,
1137 },
1138 DateFormat::My {
1139 sep: '/',
1140 year_len: 4,
1141 },
1142 ),
1143 (
1144 "Jun-26",
1145 SimpleDate {
1146 year: 2026,
1147 month: 6,
1148 day: 1,
1149 },
1150 DateFormat::MmmY {
1151 sep: '-',
1152 year_len: 2,
1153 month_case: StringCase::Title,
1154 month_full: false,
1155 },
1156 ),
1157 (
1158 "2026-Jun",
1159 SimpleDate {
1160 year: 2026,
1161 month: 6,
1162 day: 1,
1163 },
1164 DateFormat::YMmm {
1165 sep: '-',
1166 year_len: 4,
1167 month_case: StringCase::Title,
1168 month_full: false,
1169 },
1170 ),
1171 ];
1172
1173 for (src, want_date, want_format) in cases {
1174 let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1175 assert_eq!(date, *want_date, "date mismatch for {src}");
1176 assert_eq!(format, *want_format, "format mismatch for {src}");
1177 }
1178 }
1179
1180 #[test]
1181 fn test_locale_aware_date_parsing() {
1182 let de = Locale::de_de();
1183 let gb = Locale::en_gb();
1184 let us = Locale::en_us();
1185
1186 // In German locale: 22.06.2026 is Day=22, Month=6
1187 let (date, fmt) = parse_date_with_locale("22.06.2026", &de).unwrap();
1188 assert_eq!(
1189 date,
1190 SimpleDate {
1191 year: 2026,
1192 month: 6,
1193 day: 22
1194 }
1195 );
1196 assert_eq!(
1197 fmt,
1198 DateFormat::Dmy {
1199 sep: '.',
1200 year_len: 4
1201 }
1202 );
1203
1204 // German month word: 22. Juni 2026
1205 let (date, _) = parse_date_with_locale("22. Juni 2026", &de).unwrap();
1206 assert_eq!(
1207 date,
1208 SimpleDate {
1209 year: 2026,
1210 month: 6,
1211 day: 22
1212 }
1213 );
1214
1215 // In UK locale: 05/06/2026 is 5th June 2026
1216 let (date, _) = parse_date_with_locale("05/06/2026", &gb).unwrap();
1217 assert_eq!(
1218 date,
1219 SimpleDate {
1220 year: 2026,
1221 month: 6,
1222 day: 5
1223 }
1224 );
1225
1226 // In US locale: 05/06/2026 is May 6th 2026
1227 let (date, _) = parse_date_with_locale("05/06/2026", &us).unwrap();
1228 assert_eq!(
1229 date,
1230 SimpleDate {
1231 year: 2026,
1232 month: 5,
1233 day: 6
1234 }
1235 );
1236
1237 // In UK/DE locale: 2-part "22/6" is Day 22, Month 6
1238 let (date, fmt) = parse_date_with_locale("22/6", &gb).unwrap();
1239 assert_eq!(
1240 date,
1241 SimpleDate {
1242 year: 2026,
1243 month: 6,
1244 day: 22
1245 }
1246 );
1247 assert_eq!(fmt, DateFormat::Dm { sep: '/' });
1248
1249 // French month name: "14 juillet 2026"
1250 let fr = Locale::fr_fr();
1251 let (date, _) = parse_date_with_locale("14 juillet 2026", &fr).unwrap();
1252 assert_eq!(
1253 date,
1254 SimpleDate {
1255 year: 2026,
1256 month: 7,
1257 day: 14
1258 }
1259 );
1260
1261 // Spanish month name: "12 de octubre de 2026" or "12 octubre 2026"
1262 let es = Locale::es_es();
1263 let (date, _) = parse_date_with_locale("12 octubre 2026", &es).unwrap();
1264 assert_eq!(
1265 date,
1266 SimpleDate {
1267 year: 2026,
1268 month: 10,
1269 day: 12
1270 }
1271 );
1272 }
1273
1274 /// The point of detecting a format at all: a date echoes back in the
1275 /// notation it was typed in.
1276 #[test]
1277 fn test_format_date_round_trips_the_typed_notation() {
1278 let sources = [
1279 "2026-06-22",
1280 "2026/06/22",
1281 "6/22/26",
1282 "22-Jun-2026",
1283 "22-June-2026",
1284 "Jun-22-2026",
1285 "22-Jun",
1286 "Jun-22",
1287 "6/2026",
1288 "Jun-26",
1289 "2026-Jun",
1290 ];
1291 for src in sources {
1292 let (date, format) = parse_date(src).unwrap_or_else(|| panic!("{src} did not parse"));
1293 assert_eq!(
1294 format_date(date, &format),
1295 src,
1296 "round trip failed for {src}"
1297 );
1298 }
1299 }
1300
1301 #[test]
1302 fn test_format_date_normalizes_zero_padding() {
1303 let (date, format) = parse_date("22-06-2026").unwrap();
1304 assert_eq!(format_date(date, &format), "22-6-2026");
1305
1306 let (date, format) = parse_date("06/22/2026").unwrap();
1307 assert_eq!(format_date(date, &format), "6/22/2026");
1308
1309 // Year-first keeps its padding: the code really is yyyy-mm-dd.
1310 let (date, format) = parse_date("2026-06-22").unwrap();
1311 assert_eq!(format_date(date, &format), "2026-06-22");
1312 }
1313
1314 #[test]
1315 fn test_format_date_preserves_month_name_case() {
1316 for src in ["22-JUN-2026", "22-jun-2026"] {
1317 let (date, format) = parse_date(src).unwrap();
1318 assert_eq!(format_date(date, &format), src);
1319 }
1320 let (date, format) = parse_date("22-JUN-2026").unwrap();
1321 assert_eq!(format.to_format_code(), "d-mmm-yyyy");
1322 assert_eq!(
1323 render_date_code(date, &format.to_format_code(), StringCase::Title),
1324 "22-Jun-2026"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_render_date_code_does_not_rescan_substituted_month_names() {
1330 let dec = SimpleDate {
1331 year: 2026,
1332 month: 12,
1333 day: 5,
1334 };
1335 assert_eq!(
1336 render_date_code(dec, "mmmm d, yyyy", StringCase::Title),
1337 "December 5, 2026"
1338 );
1339 let may = SimpleDate {
1340 year: 2026,
1341 month: 5,
1342 day: 5,
1343 };
1344 assert_eq!(render_date_code(may, "mmm-yy", StringCase::Title), "May-26");
1345 }
1346
1347 #[test]
1348 fn test_render_date_code_token_widths() {
1349 let d = SimpleDate {
1350 year: 2026,
1351 month: 6,
1352 day: 7,
1353 };
1354 assert_eq!(
1355 render_date_code(d, "yyyy-mm-dd", StringCase::Title),
1356 "2026-06-07"
1357 );
1358 assert_eq!(render_date_code(d, "m/d/yy", StringCase::Title), "6/7/26");
1359 assert_eq!(render_date_code(d, "mmmm", StringCase::Title), "June");
1360 // Non-token characters pass through untouched.
1361 assert_eq!(
1362 render_date_code(d, "[yyyy] week of d", StringCase::Title),
1363 "[2026] week of 7"
1364 );
1365 }
1366
1367 #[test]
1368 fn test_render_date_code_honors_excel_escape_prefix() {
1369 let d = SimpleDate {
1370 year: 2026,
1371 month: 6,
1372 day: 7,
1373 };
1374 assert_eq!(
1375 render_date_code(d, "yyyy\\-mm\\-dd", StringCase::Title),
1376 "2026-06-07"
1377 );
1378 assert_eq!(
1379 render_date_code(d, "d\\-mmm\\-yyyy", StringCase::Title),
1380 "7-Jun-2026"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_is_date_code_rejects_numeric_formats() {
1386 assert!(is_date_code("m/d/yy"));
1387 assert!(is_date_code("yyyy-mm-dd"));
1388 assert!(!is_date_code("0.00"));
1389 assert!(!is_date_code("#,##0"));
1390 assert!(!is_date_code(""));
1391 }
1392
1393 #[test]
1394 fn test_invalid_dates_do_not_parse() {
1395 assert!(parse_date("2026-02-30").is_none());
1396 assert!(parse_date("2025-02-29").is_none()); // non-leap year
1397 assert!(parse_date("13/22/2026").is_none()); // invalid month
1398 assert!(parse_date("06-32-2026").is_none()); // invalid day
1399 }
1400
1401 #[test]
1402 fn test_date_to_excel_serial() {
1403 // Excel's epoch: 1900-01-01 is serial 1.
1404 assert_eq!(
1405 date_to_excel_serial(SimpleDate {
1406 year: 1900,
1407 month: 1,
1408 day: 1
1409 }),
1410 1.0
1411 );
1412 // Excel's deliberate 1900 leap-year bug means 1900-03-01 is 61, not 60.
1413 assert_eq!(
1414 date_to_excel_serial(SimpleDate {
1415 year: 1900,
1416 month: 3,
1417 day: 1
1418 }),
1419 61.0
1420 );
1421 assert_eq!(
1422 date_to_excel_serial(SimpleDate {
1423 year: 2026,
1424 month: 6,
1425 day: 22
1426 }),
1427 46195.0
1428 );
1429 }
1430
1431 use proptest::prelude::*;
1432
1433 proptest! {
1434 #[test]
1435 fn fuzz_parse_date_never_panics(
1436 s in "\\PC*",
1437 loc_idx in 0..11usize
1438 ) {
1439 let locales = [
1440 Locale::en_us(),
1441 Locale::en_gb(),
1442 Locale::de_de(),
1443 Locale::fr_fr(),
1444 Locale::es_es(),
1445 Locale::it_it(),
1446 Locale::pt_br(),
1447 Locale::nl_nl(),
1448 Locale::ru_ru(),
1449 Locale::zh_cn(),
1450 Locale::ja_jp(),
1451 ];
1452 let loc = &locales[loc_idx];
1453 let _ = parse_date_with_locale(&s, loc);
1454 }
1455
1456 #[test]
1457 fn fuzz_date_parsing_roundtrip_us(
1458 y in 1900..=2099i32,
1459 m in 1..=12u32,
1460 d in 1..=28u32,
1461 sep in "[/\\-]"
1462 ) {
1463 let loc = Locale::en_us();
1464 let src = format!("{}{}{}{}{}", m, sep, d, sep, y);
1465 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid MDY date must parse");
1466 prop_assert_eq!(date.year, y);
1467 prop_assert_eq!(date.month, m);
1468 prop_assert_eq!(date.day, d);
1469 }
1470
1471 #[test]
1472 fn fuzz_date_parsing_roundtrip_gb(
1473 y in 1900..=2099i32,
1474 m in 1..=12u32,
1475 d in 1..=28u32,
1476 sep in "[/\\-]"
1477 ) {
1478 let loc = Locale::en_gb();
1479 let src = format!("{}{}{}{}{}", d, sep, m, sep, y);
1480 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid DMY date must parse");
1481 prop_assert_eq!(date.year, y);
1482 prop_assert_eq!(date.month, m);
1483 prop_assert_eq!(date.day, d);
1484 }
1485
1486 #[test]
1487 fn fuzz_date_parsing_roundtrip_de_dot(
1488 y in 1900..=2099i32,
1489 m in 1..=12u32,
1490 d in 1..=28u32,
1491 ) {
1492 let loc = Locale::de_de();
1493 let src = format!("{}.{}.{}", d, m, y);
1494 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid German dot date must parse");
1495 prop_assert_eq!(date.year, y);
1496 prop_assert_eq!(date.month, m);
1497 prop_assert_eq!(date.day, d);
1498 }
1499
1500 #[test]
1501 fn fuzz_date_parsing_2digit_pivot(
1502 yy in 0..=99i32,
1503 m in 1..=12u32,
1504 d in 1..=28u32
1505 ) {
1506 let loc = Locale::en_us();
1507 let src = format!("{}/{}/{:02}", m, d, yy);
1508 let (date, _) = parse_date_with_locale(&src, &loc).expect("valid 2-digit year date must parse");
1509 let expected_y = if yy <= 29 { 2000 + yy } else { 1900 + yy };
1510 prop_assert_eq!(date.year, expected_y);
1511 prop_assert_eq!(date.month, m);
1512 prop_assert_eq!(date.day, d);
1513 }
1514 }
1515}