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        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    // Month-year only: "Jan 2024", "January 2024" → first day of month.
132    // Excel accepts these and returns serial for the 1st of that month.
133    parse_month_year_only(text)
134}
135
136/// Parse "Jan 2024" or "January 2024" as the first day of that month.
137///
138/// The year token must be four digits. `parse_excel_year` also accepts one- and
139/// two-digit years, but admitting them here reads `"Jan 3"` as `2003-01-01`,
140/// which collides with a month-plus-day-without-year form. #290 explicitly
141/// refuses those (`"Jan-03"`, `"1/03"`) because a wall-clock year fill-in is
142/// ambiguous, so the month-year form is held to an unambiguous four-digit year.
143fn 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    // Try abbreviated and full month names
151    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
183/// Parse time text using fixed 24-hour or English AM/PM formats.
184///
185/// Parsing has no locale parameter, uses English AM/PM markers, and never
186/// consults the host locale. ASCII whitespace around separators is ignored.
187/// Fractional seconds (e.g. `12:30:45.5`) are truncated to whole seconds.
188/// `24:00` and `24:00:00` are accepted as midnight (Excel compatibility).
189pub 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    // Handle 24:00 and 24:00:00 as midnight (Excel treats this as end-of-day = 0:00)
206    if normalized == "24:00" || normalized == "24:00:00" {
207        return Some(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
208    }
209
210    // Strip fractional seconds: "12:30:45.5" → "12:30:45"
211    // Find the seconds decimal point (after the second colon) and truncate
212    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
220/// Strip fractional seconds from a time string: "12:30:45.123" → "12:30:45"
221///
222/// Only a dot that terminates a full `HH:MM:SS` field is treated as fractional
223/// seconds. The dot must be preceded by two colons (the hour and minute
224/// separators), so `"12:00.5"` — a single colon, i.e. a malformed time — is
225/// left untouched and subsequently rejected by the parser as `#VALUE!` rather
226/// than silently accepted as `12:00`.
227fn strip_fractional_seconds(text: &str) -> String {
228    // Find pattern: digits followed by '.' followed by digits, where this
229    // appears after the second ':' (seconds position) or before a space/AM/PM
230    if let Some(dot_pos) = text.find('.') {
231        // Verify the dot is in a seconds position (preceded by digits, followed by digits)
232        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            // Find where the fractional digits end
238            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                // Reconstruct without the fractional part
243                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
252/// Parse an en-US date and time separated by whitespace or an ISO `T`.
253///
254/// Date and time components use [`parse_excel_date_text`] and
255/// [`parse_excel_time_text`]. `T` is accepted only after a four-digit-year ISO
256/// date. There is no locale parameter, and parsing is independent of the host
257/// locale.
258pub 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
274/// Parse spreadsheet date, time, or datetime text and return its serial.
275///
276/// This is the canonical entry point for text operands that need a temporal
277/// serial. Dates use deterministic en-US month/day/year ordering, with no
278/// locale parameter. Date-bearing results honor the selected workbook date
279/// system; time-only results are fractional days in either system.
280pub 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
290/// Return the final whole-day serial supported by Excel's calendar.
291pub fn max_excel_serial_for(system: DateSystem) -> f64 {
292    date_to_serial_for(system, &EXCEL_MAX_DATE)
293}
294
295/// Validate an Excel serial before converting it to a calendar value.
296pub 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
345/// Convert an Excel serial to a representable `chrono` date.
346///
347/// In the 1900 system, serial 60 maps to 1900-02-28 because the fictitious
348/// 1900-02-29 cannot be represented. Use
349/// [`try_serial_to_display_date_parts_for`] when rendering Excel date fields.
350pub 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
355/// Convert an Excel serial to a representable `chrono` datetime.
356///
357/// Fractional days are rounded to the nearest second. A rounded value of
358/// 24:00 carries into the next serial day and is rejected if it exceeds
359/// Excel's maximum date. In the 1900 system, carrying into phantom serial 60
360/// still aliases to representable 1900-02-28.
361pub 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
370/// Return the date fields Excel displays for a serial.
371///
372/// In the 1900 system this returns `1900-01-00` for serial 0 and the phantom
373/// `1900-02-29` for serial 60. Those values are deliberately not exposed as a
374/// `chrono::NaiveDate`.
375pub 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
406/// Compatibility wrapper for the historical, implicit Excel-1900 API.
407pub 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
430/// Compatibility wrapper for the historical, implicit Excel-1900 API.
431///
432/// Valid Excel serials use the canonical checked conversion. Inputs outside
433/// Excel's calendar domain retain the legacy common behavior, including
434/// finite negative serials that represent pre-epoch datetimes. New code should
435/// use [`try_serial_to_datetime_for`] when invalid input must return an error.
436pub 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        // oracle: lo-verified for every accepted two-digit-year date shape.
629        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        // oracle: lo-verified. A short year is not accepted in ISO year position.
643        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        // oracle: lo-verified. Arithmetic follows en-US m/d/y, unlike DATEVALUE's
653        // separately retained legacy fallbacks.
654        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        // "1/2/5" → January 2, 2005
676        assert_eq!(parse_excel_date_text("1/2/5"), Some(date(2005, 1, 2)));
677        // "3/15/9" → March 15, 2009
678        assert_eq!(parse_excel_date_text("3/15/9"), Some(date(2009, 3, 15)));
679        // "12/31/0" → December 31, 2000
680        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        // "12:30:45.5" → 12:30:45 (fractional part ignored)
698        assert_eq!(
699            parse_excel_time_text("12:30:45.5"),
700            Some(NaiveTime::from_hms_opt(12, 30, 45).unwrap())
701        );
702        // "08:15:30.999" → 08:15:30
703        assert_eq!(
704            parse_excel_time_text("08:15:30.999"),
705            Some(NaiveTime::from_hms_opt(8, 15, 30).unwrap())
706        );
707        // "2:05:00.0" → 02:05:00
708        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        // A one- or two-digit trailing token is a day, not a year: the
727        // month-year form is held to an unambiguous four-digit year so that
728        // "Jan 3" is not silently read as 2003-01-01 (#290). Excel and
729        // LibreOffice reject the month-plus-day-without-year shape.
730        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        // "12:00.5" has a single colon, so the dot does not terminate an
738        // HH:MM:SS field. It must not be silently accepted as 12:00 (#290).
739        assert_eq!(parse_excel_time_text("12:00.5"), None);
740        // The well-formed HH:MM:SS.f case still truncates.
741        assert_eq!(
742            parse_excel_time_text("12:00:00.5"),
743            Some(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
744        );
745    }
746}