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