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 2 if year <= 29 => Some(2000 + year),
103 2 => Some(1900 + year),
104 4 => Some(year),
105 _ => None,
106 }
107}
108
109fn parse_iso_date(text: &str) -> Option<NaiveDate> {
110 let (year, rest) = text.split_once('-')?;
111 if year.len() != 4 || !year.bytes().all(|byte| byte.is_ascii_digit()) {
112 return None;
113 }
114 let normalized = format!("{}-{rest}", year.parse::<i32>().ok()?);
115 NaiveDate::parse_from_str(&normalized, "%Y-%m-%d").ok()
116}
117
118fn parse_month_name_date(text: &str) -> Option<NaiveDate> {
119 const FORMATS: &[&str] = &["%B %d, %Y", "%b %d, %Y", "%d-%b-%Y"];
120 FORMATS.iter().find_map(|format| {
121 let separator = if *format == "%d-%b-%Y" { '-' } else { ' ' };
122 let (prefix, year_text) = text.rsplit_once(separator)?;
123 let year = parse_excel_year(year_text)?;
124 let normalized = format!("{prefix}{separator}{year:04}");
125 NaiveDate::parse_from_str(&normalized, format).ok()
126 })
127}
128
129pub fn parse_excel_time_text(input: &str) -> Option<NaiveTime> {
134 let text = input.trim();
135 let mut normalized = String::with_capacity(text.len());
136 let mut pending_space = false;
137 for ch in text.chars() {
138 if ch.is_ascii_whitespace() {
139 pending_space = true;
140 } else {
141 if pending_space && ch != ':' && !normalized.ends_with(':') && !normalized.is_empty() {
142 normalized.push(' ');
143 }
144 normalized.push(ch);
145 pending_space = false;
146 }
147 }
148 const FORMATS: &[&str] = &["%H:%M:%S", "%H:%M", "%I:%M:%S %p", "%I:%M %p"];
149 FORMATS
150 .iter()
151 .find_map(|format| NaiveTime::parse_from_str(&normalized, format).ok())
152}
153
154pub fn parse_excel_datetime_text(input: &str) -> Option<NaiveDateTime> {
161 let text = input.trim();
162 text.char_indices()
163 .filter(|(_, ch)| *ch == 'T' || ch.is_ascii_whitespace())
164 .find_map(|(index, ch)| {
165 let time_start = index + ch.len_utf8();
166 let date = if ch == 'T' {
167 parse_iso_date(&text[..index])?
168 } else {
169 parse_excel_date_text(&text[..index])?
170 };
171 let time = parse_excel_time_text(&text[time_start..])?;
172 Some(date.and_time(time))
173 })
174}
175
176pub fn parse_excel_datetime_text_to_serial_for(system: DateSystem, input: &str) -> Option<f64> {
183 if let Some(datetime) = parse_excel_datetime_text(input) {
184 return Some(datetime_to_serial_for(system, &datetime));
185 }
186 if let Some(date) = parse_excel_date_text(input) {
187 return Some(date_to_serial_for(system, &date));
188 }
189 parse_excel_time_text(input).map(|time| time_to_fraction(&time))
190}
191
192pub fn max_excel_serial_for(system: DateSystem) -> f64 {
194 date_to_serial_for(system, &EXCEL_MAX_DATE)
195}
196
197pub fn validate_excel_serial(system: DateSystem, serial: f64) -> Result<(), ExcelError> {
199 if !serial.is_finite() || serial < 0.0 || serial.trunc() > max_excel_serial_for(system) {
200 return Err(ExcelError::new_num());
201 }
202 Ok(())
203}
204
205fn normalized_serial_parts(
206 system: DateSystem,
207 serial: f64,
208) -> Result<(i64, NaiveTime), ExcelError> {
209 validate_excel_serial(system, serial)?;
210
211 let mut whole_days = serial.trunc() as i64;
212 let mut total_seconds = (serial.fract() * SECONDS_PER_DAY).round() as u32;
213 if total_seconds == SECONDS_PER_DAY as u32 {
214 whole_days = whole_days.checked_add(1).ok_or_else(ExcelError::new_num)?;
215 if whole_days as f64 > max_excel_serial_for(system) {
216 return Err(ExcelError::new_num());
217 }
218 total_seconds = 0;
219 }
220
221 let time = NaiveTime::from_num_seconds_from_midnight_opt(total_seconds, 0)
222 .ok_or_else(ExcelError::new_num)?;
223 Ok((whole_days, time))
224}
225
226fn date_for_whole_serial(system: DateSystem, whole_days: i64) -> Result<NaiveDate, ExcelError> {
227 match system {
228 DateSystem::Excel1900 => {
229 if whole_days == 60 {
230 return Ok(EXCEL_1900_PHANTOM_PREVIOUS_DATE);
231 }
232 let offset = if whole_days < 60 {
233 whole_days
234 } else {
235 whole_days - 1
236 };
237 EXCEL_1900_EPOCH
238 .checked_add_signed(chrono::TimeDelta::days(offset))
239 .ok_or_else(ExcelError::new_num)
240 }
241 DateSystem::Excel1904 => EXCEL_1904_EPOCH
242 .checked_add_signed(chrono::TimeDelta::days(whole_days))
243 .ok_or_else(ExcelError::new_num),
244 }
245}
246
247pub fn try_serial_to_date_for(system: DateSystem, serial: f64) -> Result<NaiveDate, ExcelError> {
253 validate_excel_serial(system, serial)?;
254 date_for_whole_serial(system, serial.trunc() as i64)
255}
256
257pub fn try_serial_to_datetime_for(
264 system: DateSystem,
265 serial: f64,
266) -> Result<NaiveDateTime, ExcelError> {
267 let (whole_days, time) = normalized_serial_parts(system, serial)?;
268 let date = date_for_whole_serial(system, whole_days)?;
269 Ok(NaiveDateTime::new(date, time))
270}
271
272pub fn try_serial_to_display_date_parts_for(
278 system: DateSystem,
279 serial: f64,
280) -> Result<ExcelDateParts, ExcelError> {
281 validate_excel_serial(system, serial)?;
282 let whole_days = serial.trunc();
283 if system == DateSystem::Excel1900 {
284 if whole_days == 0.0 {
285 return Ok(ExcelDateParts {
286 year: 1900,
287 month: 1,
288 day: 0,
289 });
290 }
291 if whole_days == 60.0 {
292 return Ok(ExcelDateParts {
293 year: 1900,
294 month: 2,
295 day: 29,
296 });
297 }
298 }
299
300 let date = try_serial_to_date_for(system, whole_days)?;
301 Ok(ExcelDateParts {
302 year: date.year(),
303 month: date.month(),
304 day: date.day(),
305 })
306}
307
308pub fn datetime_to_serial(datetime: &NaiveDateTime) -> f64 {
310 datetime_to_serial_for(DateSystem::Excel1900, datetime)
311}
312
313fn legacy_serial_to_datetime(serial: f64) -> NaiveDateTime {
314 let days = serial.trunc() as i64;
315 let fractional_seconds = (serial.fract() * SECONDS_PER_DAY).round() as i64;
316 let offset_days = if days == 60 {
317 59
318 } else if days < 60 {
319 days
320 } else {
321 days - 1
322 };
323 let date = EXCEL_1900_EPOCH + ChronoDuration::days(offset_days);
324 let time = NaiveTime::from_num_seconds_from_midnight_opt(
325 fractional_seconds.rem_euclid(SECONDS_PER_DAY as i64) as u32,
326 0,
327 )
328 .expect("legacy fractional-day normalization must produce a valid time");
329 date.and_time(time)
330}
331
332pub fn serial_to_datetime(serial: f64) -> NaiveDateTime {
339 try_serial_to_datetime_for(DateSystem::Excel1900, serial)
340 .unwrap_or_else(|_| legacy_serial_to_datetime(serial))
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
348 NaiveDate::from_ymd_opt(year, month, day).unwrap()
349 }
350
351 fn datetime(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> NaiveDateTime {
352 date(year, month, day).and_hms_opt(hour, minute, 0).unwrap()
353 }
354
355 #[test]
356 fn excel_1900_representable_and_display_boundaries() {
357 let cases = [
358 (0.0, date(1899, 12, 31)),
359 (1.0, date(1900, 1, 1)),
360 (59.0, date(1900, 2, 28)),
361 (60.0, date(1900, 2, 28)),
362 (61.0, date(1900, 3, 1)),
363 (45_306.0, date(2024, 1, 15)),
364 ];
365 for (serial, expected) in cases {
366 assert_eq!(
367 try_serial_to_date_for(DateSystem::Excel1900, serial).unwrap(),
368 expected,
369 "serial {serial}"
370 );
371 }
372
373 assert_eq!(
374 try_serial_to_display_date_parts_for(DateSystem::Excel1900, 0.0).unwrap(),
375 ExcelDateParts {
376 year: 1900,
377 month: 1,
378 day: 0,
379 }
380 );
381 assert_eq!(
382 try_serial_to_display_date_parts_for(DateSystem::Excel1900, 60.0).unwrap(),
383 ExcelDateParts {
384 year: 1900,
385 month: 2,
386 day: 29,
387 }
388 );
389 }
390
391 #[test]
392 fn excel_1904_boundaries() {
393 let cases = [
394 (0.0, date(1904, 1, 1)),
395 (1.0, date(1904, 1, 2)),
396 (59.0, date(1904, 2, 29)),
397 (60.0, date(1904, 3, 1)),
398 (61.0, date(1904, 3, 2)),
399 (43_844.0, date(2024, 1, 15)),
400 ];
401 for (serial, expected) in cases {
402 assert_eq!(
403 try_serial_to_date_for(DateSystem::Excel1904, serial).unwrap(),
404 expected,
405 "serial {serial}"
406 );
407 }
408 }
409
410 #[test]
411 fn date_and_datetime_encode_for_both_systems() {
412 assert_eq!(
413 date_to_serial_for(DateSystem::Excel1900, &date(1900, 1, 1)),
414 1.0
415 );
416 assert_eq!(
417 date_to_serial_for(DateSystem::Excel1900, &date(1900, 2, 28)),
418 59.0
419 );
420 assert_eq!(
421 date_to_serial_for(DateSystem::Excel1900, &date(1900, 3, 1)),
422 61.0
423 );
424 assert_eq!(
425 date_to_serial_for(DateSystem::Excel1900, &date(1904, 1, 1)),
426 1462.0
427 );
428 assert_eq!(
429 date_to_serial_for(DateSystem::Excel1904, &date(1904, 1, 1)),
430 0.0
431 );
432 assert_eq!(
433 datetime_to_serial_for(DateSystem::Excel1904, &datetime(2024, 1, 15, 12, 0)),
434 43_844.5
435 );
436 }
437
438 #[test]
439 fn fractional_seconds_round_and_carry_across_boundaries() {
440 let stays = 86_399.4 / 86_400.0;
441 let carries = 86_399.6 / 86_400.0;
442
443 assert_eq!(
444 try_serial_to_datetime_for(DateSystem::Excel1900, 59.0 + stays).unwrap(),
445 date(1900, 2, 28).and_hms_opt(23, 59, 59).unwrap()
446 );
447 assert_eq!(
448 try_serial_to_datetime_for(DateSystem::Excel1900, 59.0 + carries).unwrap(),
449 date(1900, 2, 28).and_hms_opt(0, 0, 0).unwrap()
450 );
451 assert_eq!(
452 try_serial_to_datetime_for(DateSystem::Excel1900, 60.0 + carries).unwrap(),
453 date(1900, 3, 1).and_hms_opt(0, 0, 0).unwrap()
454 );
455 assert_eq!(
456 try_serial_to_datetime_for(DateSystem::Excel1904, 59.0 + carries).unwrap(),
457 date(1904, 3, 1).and_hms_opt(0, 0, 0).unwrap()
458 );
459 }
460
461 #[test]
462 fn invalid_and_out_of_bounds_serials_are_rejected() {
463 for system in [DateSystem::Excel1900, DateSystem::Excel1904] {
464 for serial in [
465 -1.0,
466 -f64::MIN_POSITIVE,
467 f64::NAN,
468 f64::INFINITY,
469 f64::NEG_INFINITY,
470 f64::MAX,
471 ] {
472 assert!(try_serial_to_datetime_for(system, serial).is_err());
473 assert!(try_serial_to_date_for(system, serial).is_err());
474 assert!(try_serial_to_display_date_parts_for(system, serial).is_err());
475 }
476
477 let max = max_excel_serial_for(system);
478 assert_eq!(try_serial_to_date_for(system, max).unwrap(), EXCEL_MAX_DATE);
479 assert!(try_serial_to_date_for(system, max + 1.0).is_err());
480 assert!(try_serial_to_datetime_for(system, max + 86_399.6 / 86_400.0).is_err());
481 }
482 }
483
484 #[test]
485 fn real_dates_round_trip_and_phantom_day_is_documented_non_bijective() {
486 for system in [DateSystem::Excel1900, DateSystem::Excel1904] {
487 for expected in [date(1904, 1, 1), date(2024, 1, 15), EXCEL_MAX_DATE] {
488 let serial = date_to_serial_for(system, &expected);
489 assert_eq!(try_serial_to_date_for(system, serial).unwrap(), expected);
490 }
491 }
492
493 let phantom = try_serial_to_date_for(DateSystem::Excel1900, 60.0).unwrap();
494 assert_eq!(phantom, date(1900, 2, 28));
495 assert_eq!(date_to_serial_for(DateSystem::Excel1900, &phantom), 59.0);
496 }
497
498 #[test]
499 fn compatibility_wrappers_match_excel_1900_and_retain_negative_serials() {
500 let expected = datetime(2024, 1, 15, 12, 0);
501 assert_eq!(datetime_to_serial(&expected), 45_306.5);
502 assert_eq!(serial_to_datetime(45_306.5), expected);
503 assert_eq!(
504 serial_to_datetime(-1.0),
505 date(1899, 12, 30).and_hms_opt(0, 0, 0).unwrap()
506 );
507 assert_eq!(
508 serial_to_datetime(-1.25),
509 date(1899, 12, 30).and_hms_opt(18, 0, 0).unwrap()
510 );
511 }
512
513 #[test]
514 fn time_fraction_is_second_precision() {
515 let time = NaiveTime::from_hms_nano_opt(12, 0, 0, 999_999_999).unwrap();
516 assert_eq!(time_to_fraction(&time), 0.5);
517 }
518
519 #[test]
520 fn temporal_text_parser_uses_excel_year_window_and_date_system() {
521 assert_eq!(
522 parse_excel_datetime_text_to_serial_for(DateSystem::Excel1900, "1/1/03"),
523 Some(37_622.0)
524 );
525 assert_eq!(
526 parse_excel_datetime_text_to_serial_for(DateSystem::Excel1904, "1/1/03 12:00"),
527 Some(36_160.5)
528 );
529
530 for (input, expected) in [
532 ("1/1/29", date(2029, 1, 1)),
533 ("1/1/30", date(1930, 1, 1)),
534 ("January 1, 29", date(2029, 1, 1)),
535 ("January 1, 30", date(1930, 1, 1)),
536 ("Jan 1, 29", date(2029, 1, 1)),
537 ("Jan 1, 30", date(1930, 1, 1)),
538 ("1-Jan-29", date(2029, 1, 1)),
539 ("1-Jan-30", date(1930, 1, 1)),
540 ] {
541 assert_eq!(parse_excel_date_text(input), Some(expected), "{input}");
542 }
543
544 assert_eq!(parse_excel_date_text("03-01-01"), None);
546 assert_eq!(
547 parse_excel_datetime_text_to_serial_for(DateSystem::Excel1900, "12:00"),
548 Some(0.5)
549 );
550 }
551
552 #[test]
553 fn temporal_text_parser_restricts_slash_order_and_t_separator() {
554 assert_eq!(parse_excel_date_text("15/01/2003"), None);
557 assert_eq!(parse_excel_date_text("2003/1/1"), None);
558 assert_eq!(parse_excel_datetime_text("1/1/03T12:00"), None);
559 assert_eq!(
560 parse_excel_datetime_text("2003-01-01T12:00"),
561 Some(datetime(2003, 1, 1, 12, 0))
562 );
563 }
564
565 #[test]
566 fn temporal_text_parser_rejects_invalid_and_non_dates() {
567 for text in ["2/30/03", "abc", "", "13/13/13", "123-456"] {
568 assert!(
569 parse_excel_datetime_text_to_serial_for(DateSystem::Excel1900, text).is_none(),
570 "{text}"
571 );
572 }
573 }
574}