Skip to main content

formualizer_eval/builtins/datetime/
serial.rs

1//! Compatibility date-serial helpers retained at the released eval path.
2//!
3//! The checked conversion APIs in `formualizer-common` are the canonical
4//! production conversion path. The decode helpers here intentionally keep the
5//! component-wise behavior released in 0.7.1, including its handling of
6//! negative fractional serials and rounded near-midnight times. This is an
7//! eval-local compatibility adapter, not a second canonical conversion API.
8
9use chrono::{Days, NaiveDate, NaiveDateTime, NaiveTime};
10use formualizer_common::{DateSystem, ExcelError};
11
12const EXCEL_1900_EPOCH: NaiveDate = NaiveDate::from_ymd_opt(1899, 12, 31).unwrap();
13const EXCEL_1904_EPOCH: NaiveDate = NaiveDate::from_ymd_opt(1904, 1, 1).unwrap();
14const SECONDS_PER_DAY: f64 = 86_400.0;
15
16fn checked_add_days(date: NaiveDate, days: i64) -> Result<NaiveDate, ExcelError> {
17    if days >= 0 {
18        date.checked_add_days(Days::new(days as u64))
19    } else {
20        date.checked_sub_days(Days::new(days.unsigned_abs()))
21    }
22    .ok_or_else(ExcelError::new_num)
23}
24
25fn legacy_time(serial: f64) -> Result<NaiveTime, ExcelError> {
26    // This deliberately clamps components instead of carrying 24:00 into the
27    // next date: 0.999995... was observable as 23:00:00 in 0.7.1.
28    let total_seconds = (serial.fract() * SECONDS_PER_DAY).round() as u32;
29    let hours = total_seconds / 3600;
30    let minutes = (total_seconds % 3600) / 60;
31    let seconds = total_seconds % 60;
32    NaiveTime::from_hms_opt(hours.min(23), minutes.min(59), seconds.min(59))
33        .ok_or_else(ExcelError::new_num)
34}
35
36fn legacy_excel_1900_date(serial: f64) -> Result<NaiveDate, ExcelError> {
37    if serial.is_infinite() {
38        return Err(ExcelError::new_num());
39    }
40
41    // Keep the old truncation-before-validation behavior: -0.25 and NaN both
42    // truncate/cast to zero and therefore decode to the epoch date, while
43    // negative whole serials remain #NUM.
44    let serial_int = serial.trunc();
45    if serial_int < 0.0 {
46        return Err(ExcelError::new_num());
47    }
48    let serial_int = serial_int as i64;
49    if serial_int == 60 {
50        return Ok(NaiveDate::from_ymd_opt(1900, 2, 28).unwrap());
51    }
52    let offset = if serial_int < 60 {
53        serial_int
54    } else {
55        serial_int - 1
56    };
57    checked_add_days(EXCEL_1900_EPOCH, offset)
58}
59
60/// Convert a serial to a representable Excel-1900 date.
61pub fn serial_to_date(serial: f64) -> Result<NaiveDate, ExcelError> {
62    legacy_excel_1900_date(serial)
63}
64
65/// Convert a date to an Excel-1900 serial.
66pub fn date_to_serial(date: &NaiveDate) -> f64 {
67    formualizer_common::date_to_serial_for(DateSystem::Excel1900, date)
68}
69
70/// Convert a date to a serial in the selected date system.
71pub fn date_to_serial_for(system: DateSystem, date: &NaiveDate) -> f64 {
72    formualizer_common::date_to_serial_for(system, date)
73}
74
75/// Convert a datetime to an Excel-1900 serial.
76pub fn datetime_to_serial(datetime: &NaiveDateTime) -> f64 {
77    formualizer_common::datetime_to_serial_for(DateSystem::Excel1900, datetime)
78}
79
80/// Convert a datetime to a serial in the selected date system.
81pub fn datetime_to_serial_for(system: DateSystem, datetime: &NaiveDateTime) -> f64 {
82    formualizer_common::datetime_to_serial_for(system, datetime)
83}
84
85/// Convert an Excel-1900 serial to a representable datetime.
86///
87/// This preserves the released component-clamping behavior. Unlike the
88/// released implementation, infinities and date arithmetic overflow return
89/// `#NUM!` rather than panicking.
90pub fn serial_to_datetime(serial: f64) -> Result<NaiveDateTime, ExcelError> {
91    let date = legacy_excel_1900_date(serial)?;
92    Ok(NaiveDateTime::new(date, legacy_time(serial)?))
93}
94
95/// Convert a serial to a representable datetime in the selected date system.
96///
97/// The Excel-1900 and Excel-1904 branches retain the released eval helper's
98/// component-wise decode behavior. Infinities and chrono date overflow are
99/// hardened to typed `#NUM!` errors.
100pub fn serial_to_datetime_for(
101    system: DateSystem,
102    serial: f64,
103) -> Result<NaiveDateTime, ExcelError> {
104    if serial.is_infinite() {
105        return Err(ExcelError::new_num());
106    }
107    match system {
108        DateSystem::Excel1900 => serial_to_datetime(serial),
109        DateSystem::Excel1904 => {
110            if serial.is_nan() {
111                return Err(ExcelError::new_num());
112            }
113            let days = serial.trunc() as i64;
114            let date = checked_add_days(EXCEL_1904_EPOCH, days)?;
115            Ok(NaiveDateTime::new(date, legacy_time(serial)?))
116        }
117    }
118}
119
120/// Convert a time to a fractional day.
121pub fn time_to_fraction(time: &NaiveTime) -> f64 {
122    formualizer_common::time_to_fraction(time)
123}
124
125/// Create a date using the normalization behavior released in 0.7.1.
126pub fn create_date_normalized(year: i32, month: i32, day: i32) -> Result<NaiveDate, ExcelError> {
127    let total_months = (year * 12) + month - 1;
128    let normalized_year = total_months / 12;
129    let normalized_month = (total_months % 12) + 1;
130    let first_of_month = NaiveDate::from_ymd_opt(normalized_year, normalized_month as u32, 1)
131        .ok_or_else(ExcelError::new_num)?;
132    first_of_month
133        .checked_add_signed(chrono::TimeDelta::days((day - 1) as i64))
134        .ok_or_else(ExcelError::new_num)
135}