Skip to main content

formualizer_common/
date_serial.rs

1//! Canonical Excel date-serial conversion.
2//!
3//! Excel workbooks use either the 1900 or 1904 date system. The 1900 system
4//! also contains a fictitious 1900-02-29 at serial 60. Since `chrono` cannot
5//! represent that date (or Excel's display-only 1900-01-00 at serial 0),
6//! calendar conversion and display conversion are intentionally separate.
7
8use 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/// Calendar fields rendered by Excel, including display-only dates that
20/// cannot be represented by `chrono::NaiveDate`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct ExcelDateParts {
23    pub year: i32,
24    pub month: u32,
25    pub day: u32,
26}
27
28/// Convert a date to an Excel serial in the selected date system.
29///
30/// Dates before the selected epoch produce negative serials. Checked
31/// serial-to-calendar conversion rejects those serials because Excel does not
32/// treat them as valid calendar values.
33pub 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
47/// Convert a datetime to an Excel serial in the selected date system.
48///
49/// Formualizer's existing temporal representation is second-precision:
50/// subsecond nanoseconds are intentionally not encoded.
51pub 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
55/// Convert a time to its fractional-day representation.
56///
57/// Subsecond nanoseconds are intentionally ignored for compatibility with the
58/// existing Formualizer temporal model.
59pub fn time_to_fraction(time: &NaiveTime) -> f64 {
60    time.num_seconds_from_midnight() as f64 / SECONDS_PER_DAY
61}
62
63/// Parse date text using Formualizer's deterministic en-US spreadsheet convention.
64///
65/// Numeric slash dates use month/day/year ordering. Two-digit years in slash
66/// and English month-name forms use Excel's fixed window: `00..=29` means
67/// 2000 through 2029 and `30..=99` means 1930 through 1999. ISO dates require
68/// a four-digit year. Parsing has no locale parameter and never consults the
69/// host locale.
70pub 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
129/// Parse time text using fixed 24-hour or English AM/PM formats.
130///
131/// Parsing has no locale parameter, uses English AM/PM markers, and never
132/// consults the host locale. ASCII whitespace around separators is ignored.
133pub 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
154/// Parse an en-US date and time separated by whitespace or an ISO `T`.
155///
156/// Date and time components use [`parse_excel_date_text`] and
157/// [`parse_excel_time_text`]. `T` is accepted only after a four-digit-year ISO
158/// date. There is no locale parameter, and parsing is independent of the host
159/// locale.
160pub 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
176/// Parse spreadsheet date, time, or datetime text and return its serial.
177///
178/// This is the canonical entry point for text operands that need a temporal
179/// serial. Dates use deterministic en-US month/day/year ordering, with no
180/// locale parameter. Date-bearing results honor the selected workbook date
181/// system; time-only results are fractional days in either system.
182pub 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
192/// Return the final whole-day serial supported by Excel's calendar.
193pub fn max_excel_serial_for(system: DateSystem) -> f64 {
194    date_to_serial_for(system, &EXCEL_MAX_DATE)
195}
196
197/// Validate an Excel serial before converting it to a calendar value.
198pub 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
247/// Convert an Excel serial to a representable `chrono` date.
248///
249/// In the 1900 system, serial 60 maps to 1900-02-28 because the fictitious
250/// 1900-02-29 cannot be represented. Use
251/// [`try_serial_to_display_date_parts_for`] when rendering Excel date fields.
252pub 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
257/// Convert an Excel serial to a representable `chrono` datetime.
258///
259/// Fractional days are rounded to the nearest second. A rounded value of
260/// 24:00 carries into the next serial day and is rejected if it exceeds
261/// Excel's maximum date. In the 1900 system, carrying into phantom serial 60
262/// still aliases to representable 1900-02-28.
263pub 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
272/// Return the date fields Excel displays for a serial.
273///
274/// In the 1900 system this returns `1900-01-00` for serial 0 and the phantom
275/// `1900-02-29` for serial 60. Those values are deliberately not exposed as a
276/// `chrono::NaiveDate`.
277pub 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
308/// Compatibility wrapper for the historical, implicit Excel-1900 API.
309pub 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
332/// Compatibility wrapper for the historical, implicit Excel-1900 API.
333///
334/// Valid Excel serials use the canonical checked conversion. Inputs outside
335/// Excel's calendar domain retain the legacy common behavior, including
336/// finite negative serials that represent pre-epoch datetimes. New code should
337/// use [`try_serial_to_datetime_for`] when invalid input must return an error.
338pub 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        // oracle: lo-verified for every accepted two-digit-year date shape.
531        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        // oracle: lo-verified. A short year is not accepted in ISO year position.
545        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        // oracle: lo-verified. Arithmetic follows en-US m/d/y, unlike DATEVALUE's
555        // separately retained legacy fallbacks.
556        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}