1use chrono::{Datelike, Duration as ChronoDuration, NaiveDate, NaiveDateTime, NaiveTime, Timelike};
9
10use crate::{DateSystem, ExcelError};
11
12const SECONDS_PER_DAY: f64 = 86_400.0;
13const EXCEL_1900_EPOCH: NaiveDate = NaiveDate::from_ymd_opt(1899, 12, 31).unwrap();
14const EXCEL_1904_EPOCH: NaiveDate = NaiveDate::from_ymd_opt(1904, 1, 1).unwrap();
15const EXCEL_MAX_DATE: NaiveDate = NaiveDate::from_ymd_opt(9999, 12, 31).unwrap();
16const EXCEL_1900_PHANTOM_CUTOFF: NaiveDate = NaiveDate::from_ymd_opt(1900, 3, 1).unwrap();
17const EXCEL_1900_PHANTOM_PREVIOUS_DATE: NaiveDate = NaiveDate::from_ymd_opt(1900, 2, 28).unwrap();
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct ExcelDateParts {
23 pub year: i32,
24 pub month: u32,
25 pub day: u32,
26}
27
28pub fn date_to_serial_for(system: DateSystem, date: &NaiveDate) -> f64 {
34 match system {
35 DateSystem::Excel1900 => {
36 let days = (*date - EXCEL_1900_EPOCH).num_days();
37 if *date >= EXCEL_1900_PHANTOM_CUTOFF {
38 (days + 1) as f64
39 } else {
40 days as f64
41 }
42 }
43 DateSystem::Excel1904 => (*date - EXCEL_1904_EPOCH).num_days() as f64,
44 }
45}
46
47pub fn datetime_to_serial_for(system: DateSystem, datetime: &NaiveDateTime) -> f64 {
52 date_to_serial_for(system, &datetime.date()) + time_to_fraction(&datetime.time())
53}
54
55pub fn time_to_fraction(time: &NaiveTime) -> f64 {
60 time.num_seconds_from_midnight() as f64 / SECONDS_PER_DAY
61}
62
63pub fn parse_excel_date_text(input: &str) -> Option<NaiveDate> {
71 let text = input.trim();
72 if text.is_empty() {
73 return None;
74 }
75
76 if let Some(date) = parse_numeric_slash_date(text) {
77 return Some(date);
78 }
79
80 parse_iso_date(text).or_else(|| parse_month_name_date(text))
81}
82
83fn parse_numeric_slash_date(text: &str) -> Option<NaiveDate> {
84 let parts: Vec<&str> = text.split('/').collect();
85 if parts.len() != 3
86 || parts
87 .iter()
88 .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
89 {
90 return None;
91 }
92
93 let month = parts[0].parse::<u32>().ok()?;
94 let day = parts[1].parse::<u32>().ok()?;
95 let year = parse_excel_year(parts[2])?;
96 NaiveDate::from_ymd_opt(year, month, day)
97}
98
99fn parse_excel_year(text: &str) -> Option<i32> {
100 let year = text.parse::<i32>().ok()?;
101 match text.len() {
102 1 => Some(2000 + year),
103 2 if year <= 29 => Some(2000 + year),
104 2 => Some(1900 + year),
105 4 => Some(year),
106 _ => None,
107 }
108}
109
110fn parse_iso_date(text: &str) -> Option<NaiveDate> {
111 let (year, rest) = text.split_once('-')?;
112 if year.len() != 4 || !year.bytes().all(|byte| byte.is_ascii_digit()) {
113 return None;
114 }
115 let normalized = format!("{}-{rest}", year.parse::<i32>().ok()?);
116 NaiveDate::parse_from_str(&normalized, "%Y-%m-%d").ok()
117}
118
119fn parse_month_name_date(text: &str) -> Option<NaiveDate> {
120 const FORMATS: &[&str] = &["%B %d, %Y", "%b %d, %Y", "%d-%b-%Y"];
121 if let Some(date) = FORMATS.iter().find_map(|format| {
122 let separator = if *format == "%d-%b-%Y" { '-' } else { ' ' };
123 let (prefix, year_text) = text.rsplit_once(separator)?;
124 let year = parse_excel_year(year_text)?;
125 let normalized = format!("{prefix}{separator}{year:04}");
126 NaiveDate::parse_from_str(&normalized, format).ok()
127 }) {
128 return Some(date);
129 }
130
131 parse_month_year_only(text)
134}
135
136fn parse_month_year_only(text: &str) -> Option<NaiveDate> {
144 let (month_text, year_text) = text.rsplit_once(' ')?;
145 let year_text = year_text.trim();
146 if year_text.len() != 4 || !year_text.bytes().all(|byte| byte.is_ascii_digit()) {
147 return None;
148 }
149 let year = year_text.parse::<i32>().ok()?;
150 let month = parse_month_name(month_text.trim())?;
152 NaiveDate::from_ymd_opt(year, month, 1)
153}
154
155fn parse_month_name(text: &str) -> Option<u32> {
156 const MONTHS_FULL: &[&str] = &[
157 "january",
158 "february",
159 "march",
160 "april",
161 "may",
162 "june",
163 "july",
164 "august",
165 "september",
166 "october",
167 "november",
168 "december",
169 ];
170 const MONTHS_ABBR: &[&str] = &[
171 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
172 ];
173 let lower = text.to_lowercase();
174 if let Some(pos) = MONTHS_FULL.iter().position(|&m| m == lower) {
175 return Some(pos as u32 + 1);
176 }
177 if let Some(pos) = MONTHS_ABBR.iter().position(|&m| m == lower) {
178 return Some(pos as u32 + 1);
179 }
180 None
181}
182
183pub fn parse_excel_time_text(input: &str) -> Option<NaiveTime> {
190 let text = input.trim();
191 let mut normalized = String::with_capacity(text.len());
192 let mut pending_space = false;
193 for ch in text.chars() {
194 if ch.is_ascii_whitespace() {
195 pending_space = true;
196 } else {
197 if pending_space && ch != ':' && !normalized.ends_with(':') && !normalized.is_empty() {
198 normalized.push(' ');
199 }
200 normalized.push(ch);
201 pending_space = false;
202 }
203 }
204
205 if normalized == "24:00" || normalized == "24:00:00" {
207 return Some(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
208 }
209
210 let normalized = strip_fractional_seconds(&normalized);
213
214 const FORMATS: &[&str] = &["%H:%M:%S", "%H:%M", "%I:%M:%S %p", "%I:%M %p"];
215 FORMATS
216 .iter()
217 .find_map(|format| NaiveTime::parse_from_str(&normalized, format).ok())
218}
219
220fn strip_fractional_seconds(text: &str) -> String {
228 if let Some(dot_pos) = text.find('.') {
231 let before_dot = &text[..dot_pos];
233 let after_dot = &text[dot_pos + 1..];
234 if before_dot.matches(':').count() >= 2
235 && before_dot.ends_with(|c: char| c.is_ascii_digit())
236 {
237 let frac_end = after_dot
239 .find(|c: char| !c.is_ascii_digit())
240 .unwrap_or(after_dot.len());
241 if frac_end > 0 {
242 let mut result = before_dot.to_string();
244 result.push_str(&after_dot[frac_end..]);
245 return result;
246 }
247 }
248 }
249 text.to_string()
250}
251
252pub fn parse_excel_datetime_text(input: &str) -> Option<NaiveDateTime> {
259 let text = input.trim();
260 text.char_indices()
261 .filter(|(_, ch)| *ch == 'T' || ch.is_ascii_whitespace())
262 .find_map(|(index, ch)| {
263 let time_start = index + ch.len_utf8();
264 let date = if ch == 'T' {
265 parse_iso_date(&text[..index])?
266 } else {
267 parse_excel_date_text(&text[..index])?
268 };
269 let time = parse_excel_time_text(&text[time_start..])?;
270 Some(date.and_time(time))
271 })
272}
273
274pub fn parse_excel_datetime_text_to_serial_for(system: DateSystem, input: &str) -> Option<f64> {
281 if let Some(datetime) = parse_excel_datetime_text(input) {
282 return Some(datetime_to_serial_for(system, &datetime));
283 }
284 if let Some(date) = parse_excel_date_text(input) {
285 return Some(date_to_serial_for(system, &date));
286 }
287 parse_excel_time_text(input).map(|time| time_to_fraction(&time))
288}
289
290pub fn max_excel_serial_for(system: DateSystem) -> f64 {
292 date_to_serial_for(system, &EXCEL_MAX_DATE)
293}
294
295pub fn validate_excel_serial(system: DateSystem, serial: f64) -> Result<(), ExcelError> {
297 if !serial.is_finite() || serial < 0.0 || serial.trunc() > max_excel_serial_for(system) {
298 return Err(ExcelError::new_num());
299 }
300 Ok(())
301}
302
303fn normalized_serial_parts(
304 system: DateSystem,
305 serial: f64,
306) -> Result<(i64, NaiveTime), ExcelError> {
307 validate_excel_serial(system, serial)?;
308
309 let mut whole_days = serial.trunc() as i64;
310 let mut total_seconds = (serial.fract() * SECONDS_PER_DAY).round() as u32;
311 if total_seconds == SECONDS_PER_DAY as u32 {
312 whole_days = whole_days.checked_add(1).ok_or_else(ExcelError::new_num)?;
313 if whole_days as f64 > max_excel_serial_for(system) {
314 return Err(ExcelError::new_num());
315 }
316 total_seconds = 0;
317 }
318
319 let time = NaiveTime::from_num_seconds_from_midnight_opt(total_seconds, 0)
320 .ok_or_else(ExcelError::new_num)?;
321 Ok((whole_days, time))
322}
323
324fn date_for_whole_serial(system: DateSystem, whole_days: i64) -> Result<NaiveDate, ExcelError> {
325 match system {
326 DateSystem::Excel1900 => {
327 if whole_days == 60 {
328 return Ok(EXCEL_1900_PHANTOM_PREVIOUS_DATE);
329 }
330 let offset = if whole_days < 60 {
331 whole_days
332 } else {
333 whole_days - 1
334 };
335 EXCEL_1900_EPOCH
336 .checked_add_signed(chrono::TimeDelta::days(offset))
337 .ok_or_else(ExcelError::new_num)
338 }
339 DateSystem::Excel1904 => EXCEL_1904_EPOCH
340 .checked_add_signed(chrono::TimeDelta::days(whole_days))
341 .ok_or_else(ExcelError::new_num),
342 }
343}
344
345pub fn try_serial_to_date_for(system: DateSystem, serial: f64) -> Result<NaiveDate, ExcelError> {
351 validate_excel_serial(system, serial)?;
352 date_for_whole_serial(system, serial.trunc() as i64)
353}
354
355pub fn try_serial_to_datetime_for(
362 system: DateSystem,
363 serial: f64,
364) -> Result<NaiveDateTime, ExcelError> {
365 let (whole_days, time) = normalized_serial_parts(system, serial)?;
366 let date = date_for_whole_serial(system, whole_days)?;
367 Ok(NaiveDateTime::new(date, time))
368}
369
370pub fn try_serial_to_display_date_parts_for(
376 system: DateSystem,
377 serial: f64,
378) -> Result<ExcelDateParts, ExcelError> {
379 validate_excel_serial(system, serial)?;
380 let whole_days = serial.trunc();
381 if system == DateSystem::Excel1900 {
382 if whole_days == 0.0 {
383 return Ok(ExcelDateParts {
384 year: 1900,
385 month: 1,
386 day: 0,
387 });
388 }
389 if whole_days == 60.0 {
390 return Ok(ExcelDateParts {
391 year: 1900,
392 month: 2,
393 day: 29,
394 });
395 }
396 }
397
398 let date = try_serial_to_date_for(system, whole_days)?;
399 Ok(ExcelDateParts {
400 year: date.year(),
401 month: date.month(),
402 day: date.day(),
403 })
404}
405
406pub fn datetime_to_serial(datetime: &NaiveDateTime) -> f64 {
408 datetime_to_serial_for(DateSystem::Excel1900, datetime)
409}
410
411fn legacy_serial_to_datetime(serial: f64) -> NaiveDateTime {
412 let days = serial.trunc() as i64;
413 let fractional_seconds = (serial.fract() * SECONDS_PER_DAY).round() as i64;
414 let offset_days = if days == 60 {
415 59
416 } else if days < 60 {
417 days
418 } else {
419 days - 1
420 };
421 let date = EXCEL_1900_EPOCH + ChronoDuration::days(offset_days);
422 let time = NaiveTime::from_num_seconds_from_midnight_opt(
423 fractional_seconds.rem_euclid(SECONDS_PER_DAY as i64) as u32,
424 0,
425 )
426 .expect("legacy fractional-day normalization must produce a valid time");
427 date.and_time(time)
428}
429
430pub fn serial_to_datetime(serial: f64) -> NaiveDateTime {
437 try_serial_to_datetime_for(DateSystem::Excel1900, serial)
438 .unwrap_or_else(|_| legacy_serial_to_datetime(serial))
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
446 NaiveDate::from_ymd_opt(year, month, day).unwrap()
447 }
448
449 fn datetime(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> NaiveDateTime {
450 date(year, month, day).and_hms_opt(hour, minute, 0).unwrap()
451 }
452
453 #[test]
454 fn excel_1900_representable_and_display_boundaries() {
455 let cases = [
456 (0.0, date(1899, 12, 31)),
457 (1.0, date(1900, 1, 1)),
458 (59.0, date(1900, 2, 28)),
459 (60.0, date(1900, 2, 28)),
460 (61.0, date(1900, 3, 1)),
461 (45_306.0, date(2024, 1, 15)),
462 ];
463 for (serial, expected) in cases {
464 assert_eq!(
465 try_serial_to_date_for(DateSystem::Excel1900, serial).unwrap(),
466 expected,
467 "serial {serial}"
468 );
469 }
470
471 assert_eq!(
472 try_serial_to_display_date_parts_for(DateSystem::Excel1900, 0.0).unwrap(),
473 ExcelDateParts {
474 year: 1900,
475 month: 1,
476 day: 0,
477 }
478 );
479 assert_eq!(
480 try_serial_to_display_date_parts_for(DateSystem::Excel1900, 60.0).unwrap(),
481 ExcelDateParts {
482 year: 1900,
483 month: 2,
484 day: 29,
485 }
486 );
487 }
488
489 #[test]
490 fn excel_1904_boundaries() {
491 let cases = [
492 (0.0, date(1904, 1, 1)),
493 (1.0, date(1904, 1, 2)),
494 (59.0, date(1904, 2, 29)),
495 (60.0, date(1904, 3, 1)),
496 (61.0, date(1904, 3, 2)),
497 (43_844.0, date(2024, 1, 15)),
498 ];
499 for (serial, expected) in cases {
500 assert_eq!(
501 try_serial_to_date_for(DateSystem::Excel1904, serial).unwrap(),
502 expected,
503 "serial {serial}"
504 );
505 }
506 }
507
508 #[test]
509 fn date_and_datetime_encode_for_both_systems() {
510 assert_eq!(
511 date_to_serial_for(DateSystem::Excel1900, &date(1900, 1, 1)),
512 1.0
513 );
514 assert_eq!(
515 date_to_serial_for(DateSystem::Excel1900, &date(1900, 2, 28)),
516 59.0
517 );
518 assert_eq!(
519 date_to_serial_for(DateSystem::Excel1900, &date(1900, 3, 1)),
520 61.0
521 );
522 assert_eq!(
523 date_to_serial_for(DateSystem::Excel1900, &date(1904, 1, 1)),
524 1462.0
525 );
526 assert_eq!(
527 date_to_serial_for(DateSystem::Excel1904, &date(1904, 1, 1)),
528 0.0
529 );
530 assert_eq!(
531 datetime_to_serial_for(DateSystem::Excel1904, &datetime(2024, 1, 15, 12, 0)),
532 43_844.5
533 );
534 }
535
536 #[test]
537 fn fractional_seconds_round_and_carry_across_boundaries() {
538 let stays = 86_399.4 / 86_400.0;
539 let carries = 86_399.6 / 86_400.0;
540
541 assert_eq!(
542 try_serial_to_datetime_for(DateSystem::Excel1900, 59.0 + stays).unwrap(),
543 date(1900, 2, 28).and_hms_opt(23, 59, 59).unwrap()
544 );
545 assert_eq!(
546 try_serial_to_datetime_for(DateSystem::Excel1900, 59.0 + carries).unwrap(),
547 date(1900, 2, 28).and_hms_opt(0, 0, 0).unwrap()
548 );
549 assert_eq!(
550 try_serial_to_datetime_for(DateSystem::Excel1900, 60.0 + carries).unwrap(),
551 date(1900, 3, 1).and_hms_opt(0, 0, 0).unwrap()
552 );
553 assert_eq!(
554 try_serial_to_datetime_for(DateSystem::Excel1904, 59.0 + carries).unwrap(),
555 date(1904, 3, 1).and_hms_opt(0, 0, 0).unwrap()
556 );
557 }
558
559 #[test]
560 fn invalid_and_out_of_bounds_serials_are_rejected() {
561 for system in [DateSystem::Excel1900, DateSystem::Excel1904] {
562 for serial in [
563 -1.0,
564 -f64::MIN_POSITIVE,
565 f64::NAN,
566 f64::INFINITY,
567 f64::NEG_INFINITY,
568 f64::MAX,
569 ] {
570 assert!(try_serial_to_datetime_for(system, serial).is_err());
571 assert!(try_serial_to_date_for(system, serial).is_err());
572 assert!(try_serial_to_display_date_parts_for(system, serial).is_err());
573 }
574
575 let max = max_excel_serial_for(system);
576 assert_eq!(try_serial_to_date_for(system, max).unwrap(), EXCEL_MAX_DATE);
577 assert!(try_serial_to_date_for(system, max + 1.0).is_err());
578 assert!(try_serial_to_datetime_for(system, max + 86_399.6 / 86_400.0).is_err());
579 }
580 }
581
582 #[test]
583 fn real_dates_round_trip_and_phantom_day_is_documented_non_bijective() {
584 for system in [DateSystem::Excel1900, DateSystem::Excel1904] {
585 for expected in [date(1904, 1, 1), date(2024, 1, 15), EXCEL_MAX_DATE] {
586 let serial = date_to_serial_for(system, &expected);
587 assert_eq!(try_serial_to_date_for(system, serial).unwrap(), expected);
588 }
589 }
590
591 let phantom = try_serial_to_date_for(DateSystem::Excel1900, 60.0).unwrap();
592 assert_eq!(phantom, date(1900, 2, 28));
593 assert_eq!(date_to_serial_for(DateSystem::Excel1900, &phantom), 59.0);
594 }
595
596 #[test]
597 fn compatibility_wrappers_match_excel_1900_and_retain_negative_serials() {
598 let expected = datetime(2024, 1, 15, 12, 0);
599 assert_eq!(datetime_to_serial(&expected), 45_306.5);
600 assert_eq!(serial_to_datetime(45_306.5), expected);
601 assert_eq!(
602 serial_to_datetime(-1.0),
603 date(1899, 12, 30).and_hms_opt(0, 0, 0).unwrap()
604 );
605 assert_eq!(
606 serial_to_datetime(-1.25),
607 date(1899, 12, 30).and_hms_opt(18, 0, 0).unwrap()
608 );
609 }
610
611 #[test]
612 fn time_fraction_is_second_precision() {
613 let time = NaiveTime::from_hms_nano_opt(12, 0, 0, 999_999_999).unwrap();
614 assert_eq!(time_to_fraction(&time), 0.5);
615 }
616
617 #[test]
618 fn temporal_text_parser_uses_excel_year_window_and_date_system() {
619 assert_eq!(
620 parse_excel_datetime_text_to_serial_for(DateSystem::Excel1900, "1/1/03"),
621 Some(37_622.0)
622 );
623 assert_eq!(
624 parse_excel_datetime_text_to_serial_for(DateSystem::Excel1904, "1/1/03 12:00"),
625 Some(36_160.5)
626 );
627
628 for (input, expected) in [
630 ("1/1/29", date(2029, 1, 1)),
631 ("1/1/30", date(1930, 1, 1)),
632 ("January 1, 29", date(2029, 1, 1)),
633 ("January 1, 30", date(1930, 1, 1)),
634 ("Jan 1, 29", date(2029, 1, 1)),
635 ("Jan 1, 30", date(1930, 1, 1)),
636 ("1-Jan-29", date(2029, 1, 1)),
637 ("1-Jan-30", date(1930, 1, 1)),
638 ] {
639 assert_eq!(parse_excel_date_text(input), Some(expected), "{input}");
640 }
641
642 assert_eq!(parse_excel_date_text("03-01-01"), None);
644 assert_eq!(
645 parse_excel_datetime_text_to_serial_for(DateSystem::Excel1900, "12:00"),
646 Some(0.5)
647 );
648 }
649
650 #[test]
651 fn temporal_text_parser_restricts_slash_order_and_t_separator() {
652 assert_eq!(parse_excel_date_text("15/01/2003"), None);
655 assert_eq!(parse_excel_date_text("2003/1/1"), None);
656 assert_eq!(parse_excel_datetime_text("1/1/03T12:00"), None);
657 assert_eq!(
658 parse_excel_datetime_text("2003-01-01T12:00"),
659 Some(datetime(2003, 1, 1, 12, 0))
660 );
661 }
662
663 #[test]
664 fn temporal_text_parser_rejects_invalid_and_non_dates() {
665 for text in ["2/30/03", "abc", "", "13/13/13", "123-456"] {
666 assert!(
667 parse_excel_datetime_text_to_serial_for(DateSystem::Excel1900, text).is_none(),
668 "{text}"
669 );
670 }
671 }
672
673 #[test]
674 fn single_digit_year_uses_2000_window() {
675 assert_eq!(parse_excel_date_text("1/2/5"), Some(date(2005, 1, 2)));
677 assert_eq!(parse_excel_date_text("3/15/9"), Some(date(2009, 3, 15)));
679 assert_eq!(parse_excel_date_text("12/31/0"), Some(date(2000, 12, 31)));
681 }
682
683 #[test]
684 fn time_24_00_parses_as_midnight() {
685 assert_eq!(
686 parse_excel_time_text("24:00"),
687 Some(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
688 );
689 assert_eq!(
690 parse_excel_time_text("24:00:00"),
691 Some(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
692 );
693 }
694
695 #[test]
696 fn fractional_seconds_are_truncated() {
697 assert_eq!(
699 parse_excel_time_text("12:30:45.5"),
700 Some(NaiveTime::from_hms_opt(12, 30, 45).unwrap())
701 );
702 assert_eq!(
704 parse_excel_time_text("08:15:30.999"),
705 Some(NaiveTime::from_hms_opt(8, 15, 30).unwrap())
706 );
707 assert_eq!(
709 parse_excel_time_text("2:05:00.0"),
710 Some(NaiveTime::from_hms_opt(2, 5, 0).unwrap())
711 );
712 }
713
714 #[test]
715 fn month_year_only_parses_as_first_of_month() {
716 assert_eq!(parse_excel_date_text("Jan 2024"), Some(date(2024, 1, 1)));
717 assert_eq!(
718 parse_excel_date_text("February 2024"),
719 Some(date(2024, 2, 1))
720 );
721 assert_eq!(parse_excel_date_text("July 2000"), Some(date(2000, 7, 1)));
722 }
723
724 #[test]
725 fn month_year_only_requires_a_four_digit_year() {
726 assert_eq!(parse_excel_date_text("Jan 3"), None);
731 assert_eq!(parse_excel_date_text("Mar 05"), None);
732 assert_eq!(parse_excel_date_text("Dec 99"), None);
733 }
734
735 #[test]
736 fn malformed_single_colon_time_with_dot_is_rejected() {
737 assert_eq!(parse_excel_time_text("12:00.5"), None);
740 assert_eq!(
742 parse_excel_time_text("12:00:00.5"),
743 Some(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
744 );
745 }
746}